|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+import axios from 'axios';
|
|
|
2
|
+import crypto from 'crypto';
|
|
|
3
|
+import moment from 'moment';
|
|
|
4
|
+import config from '../../config/index.js';
|
|
|
5
|
+import { getConnection, query } from '../../util/db.js';
|
|
|
6
|
+import { calcPaySig, calcUserSignature, createTradeNo, moneyToFen } from './virtualPaySign.js';
|
|
|
7
|
+import { assertAllowedMiaoguoPrice, resolveConfiguredProduct } from './virtualPayProduct.js';
|
|
|
8
|
+import { parseVirtualPayNotifyXml } from './virtualPayXml.js';
|
|
|
9
|
+
|
|
|
10
|
+const PRODUCT_ID = 166;
|
|
|
11
|
+const MODE = 'short_series_goods';
|
|
|
12
|
+const PAID_ORDER_STATUS = new Set([2, 3, 4]);
|
|
|
13
|
+const accessTokenCache = { value: '', expiresAt: 0 };
|
|
|
14
|
+
|
|
|
15
|
+function success(ctx, result) {
|
|
|
16
|
+ ctx.body = { errcode: 10000, result };
|
|
|
17
|
+}
|
|
|
18
|
+
|
|
|
19
|
+function failure(ctx, error, errcode = 101) {
|
|
|
20
|
+ const message = error instanceof Error ? error.message : String(error || '虚拟支付处理失败');
|
|
|
21
|
+ ctx.body = { errcode, errStr: message, result: { errorMessage: message } };
|
|
|
22
|
+}
|
|
|
23
|
+
|
|
|
24
|
+function parseJson(value, fallback = null) {
|
|
|
25
|
+ if (value === undefined || value === null || value === '') return fallback;
|
|
|
26
|
+ if (typeof value === 'object') return value;
|
|
|
27
|
+ try {
|
|
|
28
|
+ return JSON.parse(value);
|
|
|
29
|
+ } catch {
|
|
|
30
|
+ return fallback;
|
|
|
31
|
+ }
|
|
|
32
|
+}
|
|
|
33
|
+
|
|
|
34
|
+function escapeXml(value) {
|
|
|
35
|
+ return String(value ?? '')
|
|
|
36
|
+ .replaceAll('&', '&')
|
|
|
37
|
+ .replaceAll('<', '<')
|
|
|
38
|
+ .replaceAll('>', '>')
|
|
|
39
|
+ .replaceAll('"', '"')
|
|
|
40
|
+ .replaceAll("'", ''');
|
|
|
41
|
+}
|
|
|
42
|
+
|
|
|
43
|
+function cdata(value) {
|
|
|
44
|
+ return String(value ?? '').replaceAll(']]>', ']]]]><![CDATA[>');
|
|
|
45
|
+}
|
|
|
46
|
+
|
|
|
47
|
+function extractXmlValue(xml, tag) {
|
|
|
48
|
+ const match = String(xml || '').match(new RegExp(`<${tag}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))<\\/${tag}>`, 'i'));
|
|
|
49
|
+ return match ? (match[1] !== undefined ? match[1] : match[2]) : '';
|
|
|
50
|
+}
|
|
|
51
|
+
|
|
|
52
|
+function readOrderMetadata(order) {
|
|
|
53
|
+ const xml = order?.XMLPre || '';
|
|
|
54
|
+ return {
|
|
|
55
|
+ detail: parseJson(extractXmlValue(xml, 'detail'), {}),
|
|
|
56
|
+ virtualPay: parseJson(extractXmlValue(xml, 'virtual_pay'), {})
|
|
|
57
|
+ };
|
|
|
58
|
+}
|
|
|
59
|
+
|
|
|
60
|
+function normalizePlatform(value) {
|
|
|
61
|
+ const platform = String(value || '').toLowerCase();
|
|
|
62
|
+ if (platform.includes('ios') || platform.includes('iphone') || platform.includes('ipad')) return 'ios';
|
|
|
63
|
+ if (platform.includes('harmony') || platform.includes('ohos')) return 'harmony';
|
|
|
64
|
+ if (platform.includes('windows')) return 'windows';
|
|
|
65
|
+ if (platform.includes('android')) return 'android';
|
|
|
66
|
+ return platform || 'unknown';
|
|
|
67
|
+}
|
|
|
68
|
+
|
|
|
69
|
+function resolveEnvironment(platform) {
|
|
|
70
|
+ if (platform === 'ios') return 0;
|
|
|
71
|
+ const env = Number(config.virtualPay.env);
|
|
|
72
|
+ if (env !== 0 && env !== 1)
|
|
|
73
|
+ throw new Error('WX_VIRTUAL_PAY_ENV 只能配置为 0(正式)或 1(沙箱)');
|
|
|
74
|
+ return env;
|
|
|
75
|
+}
|
|
|
76
|
+
|
|
|
77
|
+function getAppKey(env) {
|
|
|
78
|
+ return env === 1 ? config.virtualPay.sandboxAppKey : config.virtualPay.productionAppKey;
|
|
|
79
|
+}
|
|
|
80
|
+
|
|
|
81
|
+function assertVirtualPayConfigured(env) {
|
|
|
82
|
+ if (!config.virtualPay.offerId)
|
|
|
83
|
+ throw new Error('缺少 WX_VIRTUAL_PAY_OFFER_ID 配置');
|
|
|
84
|
+ if (!getAppKey(env))
|
|
|
85
|
+ throw new Error(env === 1 ? '缺少 WX_VIRTUAL_PAY_SANDBOX_APP_KEY 配置' : '缺少 WX_VIRTUAL_PAY_APP_KEY 配置');
|
|
|
86
|
+}
|
|
|
87
|
+
|
|
|
88
|
+async function codeToSession(code) {
|
|
|
89
|
+ const { data } = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
|
|
|
90
|
+ params: {
|
|
|
91
|
+ appid: config.wx.miaoguo_appid,
|
|
|
92
|
+ secret: config.wx.miaoguo_appsecret,
|
|
|
93
|
+ js_code: code,
|
|
|
94
|
+ grant_type: 'authorization_code'
|
|
|
95
|
+ },
|
|
|
96
|
+ timeout: config.virtualPay.requestTimeoutMs
|
|
|
97
|
+ });
|
|
|
98
|
+ if (!data?.openid || !data?.session_key)
|
|
|
99
|
+ throw new Error(`微信登录态获取失败${data?.errcode ? `(${data.errcode})` : ''}`);
|
|
|
100
|
+ return data;
|
|
|
101
|
+}
|
|
|
102
|
+
|
|
|
103
|
+function buildCompatiblePrepayXml({ tradeNo, openid, body, detail, money, remark, virtualPay }) {
|
|
|
104
|
+ return `<xml>`
|
|
|
105
|
+ + `<appid>${escapeXml(config.wx.miaoguo_appid)}</appid>`
|
|
|
106
|
+ + `<body><![CDATA[${cdata(body)}]]></body>`
|
|
|
107
|
+ + `<detail><![CDATA[${cdata(JSON.stringify(detail))}]]></detail>`
|
|
|
108
|
+ + `<openid>${escapeXml(openid)}</openid>`
|
|
|
109
|
+ + `<out_trade_no>${escapeXml(tradeNo)}</out_trade_no>`
|
|
|
110
|
+ + `<total_fee>${money}</total_fee>`
|
|
|
111
|
+ + `<attach><![CDATA[${cdata(remark)}]]></attach>`
|
|
|
112
|
+ + `<virtual_pay><![CDATA[${cdata(JSON.stringify(virtualPay))}]]></virtual_pay>`
|
|
|
113
|
+ + `</xml>`;
|
|
|
114
|
+}
|
|
|
115
|
+
|
|
|
116
|
+async function addCompatiblePayInfo(item) {
|
|
|
117
|
+ const sql = `INSERT INTO ProductPayInfo
|
|
|
118
|
+ (TradeNo,PayType,BuyType,CreateTime,OpenID,Body,Status,Money,XMLPre,ProductID,Remark)
|
|
|
119
|
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`;
|
|
|
120
|
+ await query(sql, [
|
|
|
121
|
+ item.TradeNo, item.PayType, 112, new Date(), item.OpenID, item.Body,
|
|
|
122
|
+ 0, item.Money, item.XMLPre, PRODUCT_ID, item.Remark
|
|
|
123
|
+ ]);
|
|
|
124
|
+}
|
|
|
125
|
+
|
|
|
126
|
+function getCaseInsensitive(object, ...names) {
|
|
|
127
|
+ if (!object || typeof object !== 'object') return undefined;
|
|
|
128
|
+ const wanted = new Set(names.map(name => name.toLowerCase()));
|
|
|
129
|
+ const key = Object.keys(object).find(item => wanted.has(item.toLowerCase()));
|
|
|
130
|
+ return key === undefined ? undefined : object[key];
|
|
|
131
|
+}
|
|
|
132
|
+
|
|
|
133
|
+function objectValue(value) {
|
|
|
134
|
+ if (Array.isArray(value) && value.length === 1) value = value[0];
|
|
|
135
|
+ return parseJson(value, typeof value === 'object' ? value : {});
|
|
|
136
|
+}
|
|
|
137
|
+
|
|
|
138
|
+function normalizeDeliverEvent(payload) {
|
|
|
139
|
+ const goodsInfo = objectValue(getCaseInsensitive(payload, 'GoodsInfo', 'goods_info'));
|
|
|
140
|
+ const wechatPayInfo = objectValue(getCaseInsensitive(payload, 'WeChatPayInfo', 'wechat_pay_info'));
|
|
|
141
|
+ return {
|
|
|
142
|
+ event: String(getCaseInsensitive(payload, 'Event', 'event') || ''),
|
|
|
143
|
+ outTradeNo: String(getCaseInsensitive(payload, 'OutTradeNo', 'out_trade_no') || ''),
|
|
|
144
|
+ openid: String(getCaseInsensitive(payload, 'OpenId', 'OpenID', 'openid') || ''),
|
|
|
145
|
+ env: Number(getCaseInsensitive(payload, 'Env', 'env')),
|
|
|
146
|
+ productId: String(getCaseInsensitive(goodsInfo, 'ProductId', 'product_id') || ''),
|
|
|
147
|
+ quantity: Number(getCaseInsensitive(goodsInfo, 'Quantity', 'quantity') || 1),
|
|
|
148
|
+ originalPrice: Number(getCaseInsensitive(goodsInfo, 'OrigPrice', 'orig_price')),
|
|
|
149
|
+ actualPrice: Number(getCaseInsensitive(goodsInfo, 'ActualPrice', 'actual_price')),
|
|
|
150
|
+ transactionId: String(getCaseInsensitive(wechatPayInfo, 'TransactionId', 'transaction_id') || ''),
|
|
|
151
|
+ paidTime: Number(getCaseInsensitive(wechatPayInfo, 'PaidTime', 'paid_time')),
|
|
|
152
|
+ raw: payload
|
|
|
153
|
+ };
|
|
|
154
|
+}
|
|
|
155
|
+
|
|
|
156
|
+function calculateMemberUpdate(user, order, detail) {
|
|
|
157
|
+ if (Number(order.PayType) !== 7 || Number(user.IsMember) !== 1)
|
|
|
158
|
+ return { IsApply: 1, ProductServiceTime: null, paidMember: false };
|
|
|
159
|
+
|
|
|
160
|
+ let productServiceTime;
|
|
|
161
|
+ const requestedEndTime = detail?.EndTime;
|
|
|
162
|
+ const requestedMoment = requestedEndTime && requestedEndTime !== 'Invalid date'
|
|
|
163
|
+ ? moment(requestedEndTime)
|
|
|
164
|
+ : null;
|
|
|
165
|
+ if (requestedMoment?.isValid()) {
|
|
|
166
|
+ productServiceTime = requestedMoment.format('YYYY-MM-DD HH:mm:ss');
|
|
|
167
|
+ } else {
|
|
|
168
|
+ const oldEndTime = moment(user.ProductServiceTime);
|
|
|
169
|
+ const baseTime = oldEndTime.isValid() && oldEndTime.isAfter(moment()) ? oldEndTime : moment();
|
|
|
170
|
+ productServiceTime = baseTime.add(12, 'months').format('YYYY-MM-DD HH:mm:ss');
|
|
|
171
|
+ }
|
|
|
172
|
+
|
|
|
173
|
+ return {
|
|
|
174
|
+ IsPay: 1,
|
|
|
175
|
+ IsMember: 1,
|
|
|
176
|
+ PayTime: moment().format('YYYY-MM-DD HH:mm:ss'),
|
|
|
177
|
+ IsApply: requestedEndTime === 'Invalid date' ? 1 : undefined,
|
|
|
178
|
+ ProductServiceTime: productServiceTime,
|
|
|
179
|
+ paidMember: true
|
|
|
180
|
+ };
|
|
|
181
|
+}
|
|
|
182
|
+
|
|
|
183
|
+export async function fulfillOrder(tradeNo, payment, dependencies = {}) {
|
|
|
184
|
+ const connectionFactory = dependencies.getConnection || getConnection;
|
|
|
185
|
+ const postPayActions = dependencies.runPostPayActions || runCompatiblePostPayActions;
|
|
|
186
|
+ const schedulePostCommit = dependencies.schedulePostCommit || (callback => setTimeout(callback, 0));
|
|
|
187
|
+ const conn = await connectionFactory();
|
|
|
188
|
+ let postCommit = null;
|
|
|
189
|
+ try {
|
|
|
190
|
+ await conn.beginTransaction();
|
|
|
191
|
+ const [orders] = await conn.query(
|
|
|
192
|
+ 'SELECT * FROM ProductPayInfo WHERE TradeNo=? AND ProductID=? FOR UPDATE',
|
|
|
193
|
+ [tradeNo, PRODUCT_ID]
|
|
|
194
|
+ );
|
|
|
195
|
+ if (!orders.length) throw new Error('未找到本地支付订单');
|
|
|
196
|
+
|
|
|
197
|
+ const order = orders[0];
|
|
|
198
|
+ const metadata = readOrderMetadata(order);
|
|
|
199
|
+ if (Number(order.Status) === 1) {
|
|
|
200
|
+ await conn.commit();
|
|
|
201
|
+ return { alreadyFulfilled: true, order };
|
|
|
202
|
+ }
|
|
|
203
|
+ if (payment.openid && payment.openid !== order.OpenID)
|
|
|
204
|
+ throw new Error('支付用户与本地订单不一致');
|
|
|
205
|
+ if (payment.productId && payment.productId !== metadata.virtualPay.productId)
|
|
|
206
|
+ throw new Error('支付商品与本地订单不一致');
|
|
|
207
|
+ if (Number.isFinite(payment.env) && payment.env !== Number(metadata.virtualPay.env))
|
|
|
208
|
+ throw new Error('支付环境与本地订单不一致');
|
|
|
209
|
+ const reportedFees = [payment.orderFee, payment.paidFee].filter(Number.isFinite);
|
|
|
210
|
+ if (reportedFees.length && !reportedFees.includes(Number(order.Money)))
|
|
|
211
|
+ throw new Error('微信订单金额与本地订单不一致');
|
|
|
212
|
+ if (Number.isFinite(payment.originalPrice)
|
|
|
213
|
+ && payment.originalPrice > 0
|
|
|
214
|
+ && payment.originalPrice * (payment.quantity || 1) !== Number(metadata.virtualPay.goodsPrice))
|
|
|
215
|
+ throw new Error('发货通知原价与本地商品不一致');
|
|
|
216
|
+ if (Number.isFinite(payment.actualPrice)
|
|
|
217
|
+ && payment.actualPrice > 0
|
|
|
218
|
+ && payment.actualPrice * (payment.quantity || 1) !== Number(order.Money))
|
|
|
219
|
+ throw new Error('发货通知实付价与本地订单不一致');
|
|
|
220
|
+
|
|
|
221
|
+ const [users] = await conn.query('SELECT * FROM MiaoguoWXUsers WHERE OpenID=? FOR UPDATE', [order.OpenID]);
|
|
|
222
|
+ if (!users.length) throw new Error('未找到秒过用户');
|
|
|
223
|
+ const user = users[0];
|
|
|
224
|
+ const memberUpdate = calculateMemberUpdate(user, order, metadata.detail);
|
|
|
225
|
+
|
|
|
226
|
+ const userFields = [];
|
|
|
227
|
+ const userValues = [];
|
|
|
228
|
+ for (const field of ['IsPay', 'IsMember', 'PayTime', 'IsApply', 'ProductServiceTime']) {
|
|
|
229
|
+ if (memberUpdate[field] !== undefined && memberUpdate[field] !== null) {
|
|
|
230
|
+ userFields.push(`${field}=?`);
|
|
|
231
|
+ userValues.push(memberUpdate[field]);
|
|
|
232
|
+ }
|
|
|
233
|
+ }
|
|
|
234
|
+ if (userFields.length) {
|
|
|
235
|
+ userValues.push(order.OpenID);
|
|
|
236
|
+ await conn.query(`UPDATE MiaoguoWXUsers SET ${userFields.join(',')} WHERE OpenID=?`, userValues);
|
|
|
237
|
+ }
|
|
|
238
|
+
|
|
|
239
|
+ if (memberUpdate.paidMember) {
|
|
|
240
|
+ await conn.query('UPDATE MiaoguoCoupon SET IsUse=1 WHERE UserID=? AND CouponType IN (132,133)', [user.UserID]);
|
|
|
241
|
+ }
|
|
|
242
|
+
|
|
|
243
|
+ const payTime = payment.paidTime ? new Date(Number(payment.paidTime) * 1000) : new Date();
|
|
|
244
|
+ await conn.query(
|
|
|
245
|
+ `UPDATE ProductPayInfo
|
|
|
246
|
+ SET PayEndTime=?, XMLPay=?, Status=1, ProductServiceTime=?, UserID=?
|
|
|
247
|
+ WHERE ID=?`,
|
|
|
248
|
+ [payTime, JSON.stringify(payment.raw || payment), memberUpdate.ProductServiceTime, user.UserID, order.ID]
|
|
|
249
|
+ );
|
|
|
250
|
+ await conn.commit();
|
|
|
251
|
+
|
|
|
252
|
+ postCommit = { order, user, detail: metadata.detail, memberUpdate };
|
|
|
253
|
+ return { alreadyFulfilled: false, order, memberUpdate };
|
|
|
254
|
+ } catch (error) {
|
|
|
255
|
+ await conn.rollback();
|
|
|
256
|
+ throw error;
|
|
|
257
|
+ } finally {
|
|
|
258
|
+ conn.release();
|
|
|
259
|
+ if (postCommit) {
|
|
|
260
|
+ schedulePostCommit(() => {
|
|
|
261
|
+ Promise.resolve().then(() => postPayActions(postCommit)).catch(error =>
|
|
|
262
|
+ console.error('Miaoguo virtual pay post action failed:', error.message)
|
|
|
263
|
+ );
|
|
|
264
|
+ });
|
|
|
265
|
+ }
|
|
|
266
|
+ }
|
|
|
267
|
+}
|
|
|
268
|
+
|
|
|
269
|
+async function runCompatiblePostPayActions({ order, user, detail, memberUpdate }) {
|
|
|
270
|
+ if (!memberUpdate.paidMember) return;
|
|
|
271
|
+
|
|
|
272
|
+ try {
|
|
|
273
|
+ const serviceUsers = await query(
|
|
|
274
|
+ `SELECT wu.* FROM WechatServiceWXUsers wu
|
|
|
275
|
+ INNER JOIN MiaoguoWXUsers mu ON wu.UnionID=mu.UnionID
|
|
|
276
|
+ WHERE mu.UserID=?`,
|
|
|
277
|
+ [user.UserID]
|
|
|
278
|
+ );
|
|
|
279
|
+ if (serviceUsers.length) {
|
|
|
280
|
+ const messageParam = { Price: `${Math.floor(Number(order.Money) / 100)}.00` };
|
|
|
281
|
+ if (detail?.PayType) messageParam.PayType = detail.PayType;
|
|
|
282
|
+ await axios.get(`http://localhost:${config.port}/api/SendWXServiceTemplateMessage`, {
|
|
|
283
|
+ params: {
|
|
|
284
|
+ UserID: serviceUsers[0].UserID,
|
|
|
285
|
+ TemplateID: 'PayFinished',
|
|
|
286
|
+ ParamStr: JSON.stringify(messageParam)
|
|
|
287
|
+ },
|
|
|
288
|
+ timeout: config.virtualPay.requestTimeoutMs
|
|
|
289
|
+ });
|
|
|
290
|
+ }
|
|
|
291
|
+ } catch (error) {
|
|
|
292
|
+ console.error('Miaoguo virtual pay message action failed:', error.message);
|
|
|
293
|
+ }
|
|
|
294
|
+
|
|
|
295
|
+ try {
|
|
|
296
|
+ const classInfo = await query(
|
|
|
297
|
+ 'SELECT * FROM kylx365_db.MiaoguoClassSchedule WHERE Flag=0 ORDER BY ClassID LIMIT 1'
|
|
|
298
|
+ );
|
|
|
299
|
+ if (classInfo.length) {
|
|
|
300
|
+ const startTime = moment(classInfo[0].StartDate).startOf('day');
|
|
|
301
|
+ const sevenDay = moment(startTime).add(6, 'days').endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
|
|
302
|
+ const oneYear = moment(startTime).add(6, 'days').add(1, 'years').add(30, 'days').endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
|
|
303
|
+ await query(
|
|
|
304
|
+ 'UPDATE MiaoguoWXUsers SET ProductServiceTime=? WHERE ClassID=? AND IsPay=1 AND PayTime<?',
|
|
|
305
|
+ [oneYear, classInfo[0].ClassID, sevenDay]
|
|
|
306
|
+ );
|
|
|
307
|
+ }
|
|
|
308
|
+ await query(
|
|
|
309
|
+ `UPDATE MiaoguoWXUsers u,
|
|
|
310
|
+ (SELECT OpenID,MAX(ProductServiceTime) ProductServiceTime
|
|
|
311
|
+ FROM ProductPayInfo WHERE ProductID=166 AND Status=1 AND Money>100 GROUP BY OpenID) p
|
|
|
312
|
+ SET u.ProductServiceTime=p.ProductServiceTime
|
|
|
313
|
+ WHERE p.OpenID=u.OpenID
|
|
|
314
|
+ AND (u.ProductServiceTime='0000-00-00 00:00:00' OR u.ProductServiceTime<p.ProductServiceTime)`
|
|
|
315
|
+ );
|
|
|
316
|
+ } catch (error) {
|
|
|
317
|
+ console.error('Miaoguo virtual pay compatibility action failed:', error.message);
|
|
|
318
|
+ }
|
|
|
319
|
+
|
|
|
320
|
+ try {
|
|
|
321
|
+ await axios.get(`http://localhost:${config.port}/api/BuildStatisticsShareUserPay`, {
|
|
|
322
|
+ timeout: config.virtualPay.requestTimeoutMs
|
|
|
323
|
+ });
|
|
|
324
|
+ } catch (error) {
|
|
|
325
|
+ console.error('Miaoguo virtual pay statistics action failed:', error.message);
|
|
|
326
|
+ }
|
|
|
327
|
+}
|
|
|
328
|
+
|
|
|
329
|
+async function getAccessToken(forceRefresh = false) {
|
|
|
330
|
+ if (!forceRefresh && accessTokenCache.value && accessTokenCache.expiresAt > Date.now())
|
|
|
331
|
+ return accessTokenCache.value;
|
|
|
332
|
+
|
|
|
333
|
+ const { data } = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
|
|
334
|
+ params: {
|
|
|
335
|
+ grant_type: 'client_credential',
|
|
|
336
|
+ appid: config.wx.miaoguo_appid,
|
|
|
337
|
+ secret: config.wx.miaoguo_appsecret
|
|
|
338
|
+ },
|
|
|
339
|
+ timeout: config.virtualPay.requestTimeoutMs
|
|
|
340
|
+ });
|
|
|
341
|
+ if (!data?.access_token)
|
|
|
342
|
+ throw new Error(`获取微信 access_token 失败${data?.errcode ? `(${data.errcode})` : ''}`);
|
|
|
343
|
+ accessTokenCache.value = data.access_token;
|
|
|
344
|
+ accessTokenCache.expiresAt = Date.now() + Math.max(60, Number(data.expires_in || 7200) - 300) * 1000;
|
|
|
345
|
+ return accessTokenCache.value;
|
|
|
346
|
+}
|
|
|
347
|
+
|
|
|
348
|
+async function callXpay(path, body, env, retry = true) {
|
|
|
349
|
+ assertVirtualPayConfigured(env);
|
|
|
350
|
+ const bodyString = JSON.stringify(body);
|
|
|
351
|
+ const appKey = getAppKey(env);
|
|
|
352
|
+ const accessToken = await getAccessToken();
|
|
|
353
|
+ const paySig = calcPaySig(path, bodyString, appKey);
|
|
|
354
|
+ const { data } = await axios.post(`https://api.weixin.qq.com${path}`, bodyString, {
|
|
|
355
|
+ params: { access_token: accessToken, pay_sig: paySig },
|
|
|
356
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
357
|
+ timeout: config.virtualPay.requestTimeoutMs,
|
|
|
358
|
+ transformRequest: [value => value]
|
|
|
359
|
+ });
|
|
|
360
|
+ if (retry && [40014, 42001].includes(Number(data?.errcode))) {
|
|
|
361
|
+ await getAccessToken(true);
|
|
|
362
|
+ return callXpay(path, body, env, false);
|
|
|
363
|
+ }
|
|
|
364
|
+ return data;
|
|
|
365
|
+}
|
|
|
366
|
+
|
|
|
367
|
+async function queryWechatOrder(order) {
|
|
|
368
|
+ const metadata = readOrderMetadata(order);
|
|
|
369
|
+ const env = Number(metadata.virtualPay.env);
|
|
|
370
|
+ if (env !== 0 && env !== 1)
|
|
|
371
|
+ throw new Error('本地订单缺少有效的虚拟支付环境');
|
|
|
372
|
+ const result = await callXpay('/xpay/query_order', {
|
|
|
373
|
+ openid: order.OpenID,
|
|
|
374
|
+ env,
|
|
|
375
|
+ order_id: String(order.TradeNo)
|
|
|
376
|
+ }, env);
|
|
|
377
|
+ return { result, env, metadata };
|
|
|
378
|
+}
|
|
|
379
|
+
|
|
|
380
|
+async function notifyProvideGoods(order, env) {
|
|
|
381
|
+ const result = await callXpay('/xpay/notify_provide_goods', {
|
|
|
382
|
+ order_id: String(order.TradeNo),
|
|
|
383
|
+ env
|
|
|
384
|
+ }, env);
|
|
|
385
|
+ if (Number(result?.errcode) !== 0)
|
|
|
386
|
+ throw new Error(`微信通知发货失败(${result?.errcode ?? 'unknown'})`);
|
|
|
387
|
+ return result;
|
|
|
388
|
+}
|
|
|
389
|
+
|
|
|
390
|
+function retryNotifyProvideGoods(order, env, attempt = 1) {
|
|
|
391
|
+ if (attempt > 3) return;
|
|
|
392
|
+ const delay = attempt === 1 ? 0 : (attempt === 2 ? 2000 : 8000);
|
|
|
393
|
+ setTimeout(() => {
|
|
|
394
|
+ notifyProvideGoods(order, env).catch(error => {
|
|
|
395
|
+ console.error(`Miaoguo virtual pay notify goods attempt ${attempt} failed:`, error.message);
|
|
|
396
|
+ retryNotifyProvideGoods(order, env, attempt + 1);
|
|
|
397
|
+ });
|
|
|
398
|
+ }, delay);
|
|
|
399
|
+}
|
|
|
400
|
+
|
|
|
401
|
+function isValidMessageSignature(ctx) {
|
|
|
402
|
+ const token = config.virtualPay.messageToken;
|
|
|
403
|
+ if (!token) return process.env.NODE_ENV !== 'production';
|
|
|
404
|
+ const signature = String(ctx.query.signature || '');
|
|
|
405
|
+ const timestamp = String(ctx.query.timestamp || '');
|
|
|
406
|
+ const nonce = String(ctx.query.nonce || '');
|
|
|
407
|
+ if (!signature || !timestamp || !nonce) return false;
|
|
|
408
|
+ const expected = crypto.createHash('sha1').update([token, timestamp, nonce].sort().join('')).digest('hex');
|
|
|
409
|
+ if (signature.length !== expected.length) return false;
|
|
|
410
|
+ return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
|
411
|
+}
|
|
|
412
|
+
|
|
|
413
|
+function callbackPayload(ctx) {
|
|
|
414
|
+ const body = ctx.request.body || {};
|
|
|
415
|
+ if (body.rawBody) return parseVirtualPayNotifyXml(body.rawBody);
|
|
|
416
|
+ return body.xml || body;
|
|
|
417
|
+}
|
|
|
418
|
+
|
|
|
419
|
+function respondDeliverNotify(ctx, errCode = 0, errMsg = 'success') {
|
|
|
420
|
+ const contentType = String(ctx.request.headers['content-type'] || '').toLowerCase();
|
|
|
421
|
+ if (contentType.includes('xml')) {
|
|
|
422
|
+ ctx.type = 'application/xml';
|
|
|
423
|
+ ctx.body = `<xml><ErrCode>${Number(errCode)}</ErrCode><ErrMsg><![CDATA[${cdata(errMsg)}]]></ErrMsg></xml>`;
|
|
|
424
|
+ } else {
|
|
|
425
|
+ ctx.body = { ErrCode: Number(errCode), ErrMsg: errMsg };
|
|
|
426
|
+ }
|
|
|
427
|
+}
|
|
|
428
|
+
|
|
|
429
|
+export async function MiaoguoVirtualPayLogin500(ctx) {
|
|
|
430
|
+ try {
|
|
|
431
|
+ const code = String(ctx.query.code || '');
|
|
|
432
|
+ if (!code) throw new Error('缺少微信登录 code');
|
|
|
433
|
+ const payType = Number(ctx.query.payType);
|
|
|
434
|
+ if (![7, 9].includes(payType)) throw new Error('当前虚拟支付只支持秒过购买和试用');
|
|
|
435
|
+ const productID = Number(ctx.query.productID || PRODUCT_ID);
|
|
|
436
|
+ if (productID !== PRODUCT_ID) throw new Error('虚拟支付产品不正确');
|
|
|
437
|
+
|
|
|
438
|
+ const money = moneyToFen(ctx.query.money);
|
|
|
439
|
+ const platform = normalizePlatform(ctx.query.platform);
|
|
|
440
|
+ const env = resolveEnvironment(platform);
|
|
|
441
|
+ assertVirtualPayConfigured(env);
|
|
|
442
|
+ const session = await codeToSession(code);
|
|
|
443
|
+ const users = await query('SELECT UserID FROM MiaoguoWXUsers WHERE OpenID=? LIMIT 1', [session.openid]);
|
|
|
444
|
+ if (!users.length) throw new Error('秒过用户尚未登录或不存在');
|
|
|
445
|
+ assertAllowedMiaoguoPrice(payType, money, users[0].UserID);
|
|
|
446
|
+ const product = resolveConfiguredProduct({
|
|
|
447
|
+ products: config.virtualPay.products,
|
|
|
448
|
+ payType,
|
|
|
449
|
+ price: money,
|
|
|
450
|
+ env,
|
|
|
451
|
+ internalProductId: productID
|
|
|
452
|
+ });
|
|
|
453
|
+
|
|
|
454
|
+ const detail = parseJson(ctx.query.detail, null);
|
|
|
455
|
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail))
|
|
|
456
|
+ throw new Error('支付详情格式不正确');
|
|
|
457
|
+ detail.UserID = users[0].UserID;
|
|
|
458
|
+ const remark = !ctx.query.Remark || ctx.query.Remark === 'null' ? '' : String(ctx.query.Remark);
|
|
|
459
|
+ if (JSON.stringify(detail).length > 1000) throw new Error('支付详情过长');
|
|
|
460
|
+ if (remark.length > 300) throw new Error('支付备注过长');
|
|
|
461
|
+ const tradeNo = createTradeNo();
|
|
|
462
|
+ const attach = JSON.stringify({ payType, productID: PRODUCT_ID });
|
|
|
463
|
+ const signDataObject = {
|
|
|
464
|
+ offerId: String(config.virtualPay.offerId),
|
|
|
465
|
+ buyQuantity: 1,
|
|
|
466
|
+ env,
|
|
|
467
|
+ currencyType: 'CNY',
|
|
|
468
|
+ productId: String(product.productId),
|
|
|
469
|
+ goodsPrice: product.resolvedGoodsPrice
|
|
|
470
|
+ };
|
|
|
471
|
+ if (money < product.resolvedGoodsPrice)
|
|
|
472
|
+ signDataObject.activitySellingPrice = money;
|
|
|
473
|
+ signDataObject.outTradeNo = tradeNo;
|
|
|
474
|
+ signDataObject.attach = attach;
|
|
|
475
|
+ const signData = JSON.stringify(signDataObject);
|
|
|
476
|
+ const appKey = getAppKey(env);
|
|
|
477
|
+ const body = payType === 9 ? '秒过-试用' : '秒过-购买(小程序)';
|
|
|
478
|
+ const virtualPay = {
|
|
|
479
|
+ env,
|
|
|
480
|
+ platform,
|
|
|
481
|
+ productId: String(product.productId),
|
|
|
482
|
+ goodsPrice: product.resolvedGoodsPrice,
|
|
|
483
|
+ sellingPrice: money,
|
|
|
484
|
+ offerId: String(config.virtualPay.offerId)
|
|
|
485
|
+ };
|
|
|
486
|
+ const xmlPre = buildCompatiblePrepayXml({
|
|
|
487
|
+ tradeNo, openid: session.openid, body, detail, money, remark, virtualPay
|
|
|
488
|
+ });
|
|
|
489
|
+ if (xmlPre.length > 1900) throw new Error('支付预下单信息过长');
|
|
|
490
|
+ await addCompatiblePayInfo({
|
|
|
491
|
+ TradeNo: tradeNo,
|
|
|
492
|
+ PayType: payType,
|
|
|
493
|
+ OpenID: session.openid,
|
|
|
494
|
+ Body: body,
|
|
|
495
|
+ Money: money,
|
|
|
496
|
+ XMLPre: xmlPre,
|
|
|
497
|
+ Remark: remark
|
|
|
498
|
+ });
|
|
|
499
|
+
|
|
|
500
|
+ success(ctx, {
|
|
|
501
|
+ mode: MODE,
|
|
|
502
|
+ signData,
|
|
|
503
|
+ paySig: calcPaySig('requestVirtualPayment', signData, appKey),
|
|
|
504
|
+ signature: calcUserSignature(signData, session.session_key),
|
|
|
505
|
+ TradeNo: tradeNo,
|
|
|
506
|
+ env,
|
|
|
507
|
+ productId: String(product.productId)
|
|
|
508
|
+ });
|
|
|
509
|
+ } catch (error) {
|
|
|
510
|
+ failure(ctx, error);
|
|
|
511
|
+ }
|
|
|
512
|
+}
|
|
|
513
|
+
|
|
|
514
|
+export async function MiaoguoVirtualPayOrderStatus500(ctx) {
|
|
|
515
|
+ try {
|
|
|
516
|
+ const tradeNo = String(ctx.query.TradeNo || ctx.query.tradeNo || '');
|
|
|
517
|
+ if (!/^[0-9A-Za-z_\-|*@]{8,32}$/.test(tradeNo) || tradeNo.startsWith('_'))
|
|
|
518
|
+ throw new Error('订单号格式不正确');
|
|
|
519
|
+ const orders = await query(
|
|
|
520
|
+ 'SELECT * FROM ProductPayInfo WHERE TradeNo=? AND ProductID=? LIMIT 1',
|
|
|
521
|
+ [tradeNo, PRODUCT_ID]
|
|
|
522
|
+ );
|
|
|
523
|
+ if (!orders.length) throw new Error('订单不存在');
|
|
|
524
|
+ const localOrder = orders[0];
|
|
|
525
|
+ if (Number(localOrder.Status) === 1) {
|
|
|
526
|
+ success(ctx, { TradeNo: tradeNo, Status: 1, Fulfilled: true });
|
|
|
527
|
+ return;
|
|
|
528
|
+ }
|
|
|
529
|
+
|
|
|
530
|
+ const { result: wxResult, env } = await queryWechatOrder(localOrder);
|
|
|
531
|
+ if (Number(wxResult?.errcode) !== 0 || !wxResult?.order) {
|
|
|
532
|
+ success(ctx, {
|
|
|
533
|
+ TradeNo: tradeNo,
|
|
|
534
|
+ Status: 0,
|
|
|
535
|
+ Fulfilled: false,
|
|
|
536
|
+ WxErrorCode: wxResult?.errcode,
|
|
|
537
|
+ WxErrorMessage: wxResult?.errmsg || ''
|
|
|
538
|
+ });
|
|
|
539
|
+ return;
|
|
|
540
|
+ }
|
|
|
541
|
+
|
|
|
542
|
+ const wxOrder = wxResult.order;
|
|
|
543
|
+ const wxStatus = Number(wxOrder.status);
|
|
|
544
|
+ if (PAID_ORDER_STATUS.has(wxStatus)) {
|
|
|
545
|
+ await fulfillOrder(tradeNo, {
|
|
|
546
|
+ openid: localOrder.OpenID,
|
|
|
547
|
+ orderFee: Number(wxOrder.order_fee),
|
|
|
548
|
+ paidFee: Number(wxOrder.paid_fee),
|
|
|
549
|
+ env,
|
|
|
550
|
+ paidTime: wxOrder.paid_time,
|
|
|
551
|
+ raw: { source: 'query_order', order: wxOrder }
|
|
|
552
|
+ });
|
|
|
553
|
+ if (wxStatus === 2 || wxStatus === 3)
|
|
|
554
|
+ retryNotifyProvideGoods(localOrder, env);
|
|
|
555
|
+ success(ctx, { TradeNo: tradeNo, Status: 1, Fulfilled: true, WxStatus: wxStatus });
|
|
|
556
|
+ return;
|
|
|
557
|
+ }
|
|
|
558
|
+
|
|
|
559
|
+ success(ctx, { TradeNo: tradeNo, Status: 0, Fulfilled: false, WxStatus: wxStatus });
|
|
|
560
|
+ } catch (error) {
|
|
|
561
|
+ failure(ctx, error);
|
|
|
562
|
+ }
|
|
|
563
|
+}
|
|
|
564
|
+
|
|
|
565
|
+export function MiaoguoVirtualPayNotifyVerify500(ctx) {
|
|
|
566
|
+ if (!isValidMessageSignature(ctx)) {
|
|
|
567
|
+ ctx.status = 403;
|
|
|
568
|
+ ctx.body = 'invalid signature';
|
|
|
569
|
+ return;
|
|
|
570
|
+ }
|
|
|
571
|
+ ctx.body = String(ctx.query.echostr || 'success');
|
|
|
572
|
+}
|
|
|
573
|
+
|
|
|
574
|
+export async function MiaoguoVirtualPayNotify500(ctx, dependencies = {}) {
|
|
|
575
|
+ try {
|
|
|
576
|
+ const validateMessageSignature = dependencies.isValidMessageSignature || isValidMessageSignature;
|
|
|
577
|
+ const completeOrder = dependencies.fulfillOrder || fulfillOrder;
|
|
|
578
|
+ if (!validateMessageSignature(ctx)) {
|
|
|
579
|
+ respondDeliverNotify(ctx, 1, 'invalid signature');
|
|
|
580
|
+ return;
|
|
|
581
|
+ }
|
|
|
582
|
+ const payload = callbackPayload(ctx);
|
|
|
583
|
+ if (getCaseInsensitive(payload, 'Encrypt')) {
|
|
|
584
|
+ respondDeliverNotify(ctx, 1, 'encrypted callback is not supported');
|
|
|
585
|
+ return;
|
|
|
586
|
+ }
|
|
|
587
|
+ const event = normalizeDeliverEvent(payload);
|
|
|
588
|
+ if (event.event !== 'xpay_goods_deliver_notify') {
|
|
|
589
|
+ respondDeliverNotify(ctx);
|
|
|
590
|
+ return;
|
|
|
591
|
+ }
|
|
|
592
|
+ if (!event.outTradeNo || !event.openid || !event.productId)
|
|
|
593
|
+ throw new Error('发货通知缺少订单号、openid 或商品 ID');
|
|
|
594
|
+ await completeOrder(event.outTradeNo, {
|
|
|
595
|
+ openid: event.openid,
|
|
|
596
|
+ env: event.env,
|
|
|
597
|
+ productId: event.productId,
|
|
|
598
|
+ quantity: event.quantity,
|
|
|
599
|
+ originalPrice: event.originalPrice,
|
|
|
600
|
+ actualPrice: event.actualPrice,
|
|
|
601
|
+ transactionId: event.transactionId,
|
|
|
602
|
+ paidTime: event.paidTime,
|
|
|
603
|
+ raw: { source: 'xpay_goods_deliver_notify', payload }
|
|
|
604
|
+ });
|
|
|
605
|
+ respondDeliverNotify(ctx);
|
|
|
606
|
+ } catch (error) {
|
|
|
607
|
+ console.error('Miaoguo virtual pay deliver notify failed:', error.message);
|
|
|
608
|
+ respondDeliverNotify(ctx, 1, error.message);
|
|
|
609
|
+ }
|
|
|
610
|
+}
|