ソースを参照

虚拟支付的代码提交

chengjie 2 週間 前
コミット
96c0c0c5f1

+ 75 - 0
doc/miaoguo_virtual_payment.md

@@ -0,0 +1,75 @@
1
+# 秒过小程序虚拟支付接入
2
+
3
+本模块与原有 `src/api/pay` 并行,旧支付接口保持不变。新模块继续使用
4
+`ProductPayInfo`、`MiaoguoWXUsers` 和 `MiaoguoCoupon`,订单状态、金额单位及秒过
5
+`PayType=7/9` 的业务含义与旧代码兼容。
6
+
7
+## 环境配置
8
+
9
+服务端启动前配置以下环境变量:
10
+
11
+```bash
12
+WX_VIRTUAL_PAY_OFFER_ID=微信虚拟支付OfferId
13
+WX_VIRTUAL_PAY_APP_KEY=正式环境AppKey
14
+WX_VIRTUAL_PAY_SANDBOX_APP_KEY=沙箱环境AppKey
15
+WX_VIRTUAL_PAY_ENV=1
16
+WX_MIAOGUO_MESSAGE_TOKEN=秒过小程序消息推送Token
17
+WX_VIRTUAL_PAY_PRODUCTS_JSON='[
18
+  {"productId":"后台正式商品ID","payType":7,"price":19900,"goodsPrice":36500,"env":0},
19
+  {"productId":"后台正式试用商品ID","payType":9,"price":100,"env":0},
20
+  {"productId":"后台沙箱商品ID","payType":7,"price":19900,"goodsPrice":36500,"env":1},
21
+  {"productId":"后台沙箱试用商品ID","payType":9,"price":100,"env":1}
22
+]'
23
+```
24
+
25
+- `price` 是用户实际支付金额,单位为分;`goodsPrice` 是小程序后台已发布商品原价。两者不同时,服务端会把 `price` 作为 `activitySellingPrice` 签名。
26
+- 省略 `goodsPrice` 时默认与 `price` 相同。
27
+- `WX_VIRTUAL_PAY_PRODUCTS_JSON` 可以省略。没有匹配配置时,服务端按“内部产品编号_实际金额(元)”生成微信道具 ID。秒过当前对应关系为:1 元使用 `166_1`、199 元使用 `166_199`、258 元使用 `166_258`。以后其他小程序也采用相同规则,例如内部产品编号 205 的 199 元道具为 `205_199`。
28
+- 秒过服务端会校验业务金额:试用 `PayType=9` 只允许 1 元,购买 `PayType=7` 通常只允许 199 元或 258 元。服务端通过 OpenID 查到的真实 `UserID < 8` 或 `UserID=3089` 可用 1 元测试购买;不能依赖客户端自行传入的 UserID,以免普通用户篡改金额后低价开通权益。
29
+- 如果微信后台商品原价与实际支付价不同,需要在配置表中加入对应售价,服务端才能正确传入 `activitySellingPrice`。
30
+- `WX_VIRTUAL_PAY_ENV=1` 为沙箱,`0` 为正式环境。iOS 没有沙箱,服务端会强制使用正式环境商品和正式 AppKey。
31
+- AppKey 只放在服务端环境变量中,不能写入小程序代码或接口响应。
32
+
33
+## 新接口
34
+
35
+| 接口 | 用途 |
36
+| --- | --- |
37
+| `GET /api/MiaoguoVirtualPayLogin500` | 换取登录态、创建兼容订单并返回虚拟支付签名 |
38
+| `GET /api/MiaoguoVirtualPayOrderStatus500` | 主动查询微信订单并在漏回调时补发货 |
39
+| `POST /api/MiaoguoVirtualPayNotify500` | 接收 `xpay_goods_deliver_notify` 并发放权益 |
40
+
41
+微信公众平台的秒过小程序消息推送地址应指向:
42
+
43
+```text
44
+https://www.kylx365.com/api/MiaoguoVirtualPayNotify500
45
+```
46
+
47
+当前代码按明文 XML/JSON 消息接收;Token 必须与 `WX_MIAOGUO_MESSAGE_TOKEN` 一致。
48
+同一路径的 GET 请求已实现公众平台首次保存地址时的签名校验与 `echostr` 回显。
49
+若公众平台启用了安全模式(AES 加密消息),需先切换为明文模式,或后续增加消息解密。
50
+
51
+## 客户端与业务处理
52
+
53
+- 小程序新增 `main.virtualPayMoney`,原 `main.payMoney` 保留。
54
+- Android、鸿蒙和 Windows 在测试环境使用沙箱;iOS 自动使用 Apple 支付正式环境。
55
+- iOS 要求 iOS 15 及以上、微信 8.0.68 及以上,单笔最低 1 元,并受中国大陆 App Store 账号条件限制。
56
+- 微信重复推送不会重复增加会员有效期:订单和用户更新在同一数据库事务中完成,订单行加锁并以 `Status=1` 判定幂等。
57
+- 支付消息漏推时,小程序会轮询查单;查到状态为已支付后,服务端发放权益并调用 `/xpay/notify_provide_goods`。
58
+
59
+本次没有实现退款、iOS 退款询问事件和投诉处理。
60
+
61
+## 本地测试支付完成逻辑
62
+
63
+支付完成后的核心逻辑由发货通知和主动查单共同调用 `fulfillOrder`。该函数支持在测试中注入假数据库,因此以下命令不会连接或修改真实数据库,也不会调用微信接口:
64
+
65
+```bash
66
+npm run test:virtual-pay:completion
67
+```
68
+
69
+测试覆盖:正常购买完成、测试用户 1 元购买、1 元试用、重复发货幂等、金额不一致回滚,以及模拟微信 `xpay_goods_deliver_notify` 发货通知。完整虚拟支付测试可运行:
70
+
71
+```bash
72
+npm run test:virtual-pay
73
+```
74
+
75
+只有最终的微信客户端拉起支付、微信真实扣款和公网消息推送需要做少量线上联调;会员更新、优惠券消耗、订单完成与异常回滚都可先在本地自动测试。

+ 14 - 1
doc/server_error_log_monitor.md

@@ -113,7 +113,10 @@ PM2_ERROR_LOG_NAMES=app-24
113 113
 PM2_ERROR_LOG_NAMES=app-24,another-app
114 114
 ```
115 115
 
116
-采集脚本也会忽略负载均衡健康检查导致的 `clb-healthcheck + write ECONNRESET` 噪音。
116
+采集脚本也会忽略以下正常噪音:
117
+
118
+- 负载均衡健康检查导致的 `clb-healthcheck + write ECONNRESET`
119
+- VolcesAI 正常节流提示 `火山云AI请求节流,等待N秒`
117 120
 
118 121
 检查状态:
119 122
 
@@ -329,6 +332,16 @@ GET /
329 332
 
330 333
 通常是负载均衡健康检查断开连接产生的噪音。当前 `scripts/collect-pm2-error-logs.js` 已默认过滤这类日志。
331 334
 
335
+### VolcesAI 节流提示
336
+
337
+如果错误类似:
338
+
339
+```text
340
+火山云AI请求节流,等待12秒
341
+```
342
+
343
+这是文章生成调用 VolcesAI 时的正常请求节流提示,不是服务端 bug。当前 `scripts/collect-pm2-error-logs.js` 已默认过滤这类日志。
344
+
332 345
 ### pm2: command not found
333 346
 
334 347
 非交互 SSH 可能不会加载完整 PATH。排查 PM2 时可以用登录 shell:

+ 3 - 1
package.json

@@ -15,7 +15,9 @@
15 15
     "build": "cross-env NODE_ENV=production node src/build.js",
16 16
     "error-log:pm2": "node scripts/collect-pm2-error-logs.js",
17 17
     "error-log:poll": "node scripts/poll-error-logs.js",
18
-    "test": "node --check src/app.js",
18
+    "test": "node --check src/app.js && node --test test/*.test.js",
19
+    "test:virtual-pay": "node --test test/virtualPay*.test.js",
20
+    "test:virtual-pay:completion": "node --test test/virtualPayFulfillment.test.js",
19 21
     "node": "node",
20 22
     "check-version": "node scripts/check-node-version.js"
21 23
   },

+ 6 - 3
scripts/collect-pm2-error-logs.js

@@ -117,9 +117,12 @@ function getMessageFromBlock(block) {
117 117
 
118 118
 function shouldIgnoreBlock(block) {
119 119
   return (
120
-    /ECONNRESET/.test(block) &&
121
-    /write ECONNRESET/.test(block) &&
122
-    /clb-healthcheck/i.test(block)
120
+    (
121
+      /ECONNRESET/.test(block) &&
122
+      /write ECONNRESET/.test(block) &&
123
+      /clb-healthcheck/i.test(block)
124
+    ) ||
125
+    /火山云AI请求节流,等待\d+秒/.test(block)
123 126
   );
124 127
 }
125 128
 

+ 11 - 0
src/api/virtualPay/routes.js

@@ -0,0 +1,11 @@
1
+import Router from '@koa/router';
2
+import * as virtualPayController from './virtualPayController.js';
3
+
4
+const router = new Router();
5
+
6
+router.get('/api/MiaoguoVirtualPayLogin500', virtualPayController.MiaoguoVirtualPayLogin500);
7
+router.get('/api/MiaoguoVirtualPayOrderStatus500', virtualPayController.MiaoguoVirtualPayOrderStatus500);
8
+router.get('/api/MiaoguoVirtualPayNotify500', virtualPayController.MiaoguoVirtualPayNotifyVerify500);
9
+router.post('/api/MiaoguoVirtualPayNotify500', virtualPayController.MiaoguoVirtualPayNotify500);
10
+
11
+export default router;

+ 610 - 0
src/api/virtualPay/virtualPayController.js

@@ -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('&', '&amp;')
37
+        .replaceAll('<', '&lt;')
38
+        .replaceAll('>', '&gt;')
39
+        .replaceAll('"', '&quot;')
40
+        .replaceAll("'", '&apos;');
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
+}

+ 59 - 0
src/api/virtualPay/virtualPayProduct.js

@@ -0,0 +1,59 @@
1
+const PRODUCT_ID_PATTERN = /^[0-9A-Za-z_-]{1,64}$/;
2
+const MIAOGUO_PRICES_BY_PAY_TYPE = new Map([
3
+    [7, new Set([19900, 25800])],
4
+    [9, new Set([100])]
5
+]);
6
+
7
+export function isMiaoguoTestUser(userId) {
8
+    const id = Number(userId);
9
+    return Number.isInteger(id) && id > 0 && (id < 8 || id === 3089);
10
+}
11
+
12
+export function assertAllowedMiaoguoPrice(payType, priceFen, userId) {
13
+    if (Number(payType) === 7 && Number(priceFen) === 100 && isMiaoguoTestUser(userId))
14
+        return;
15
+    const allowedPrices = MIAOGUO_PRICES_BY_PAY_TYPE.get(Number(payType));
16
+    if (!allowedPrices?.has(Number(priceFen)))
17
+        throw new Error(`秒过暂不支持该虚拟支付金额(${Number(priceFen) / 100} 元)`);
18
+}
19
+
20
+export function buildWechatProductId(internalProductId, priceFen) {
21
+    const baseProductId = String(internalProductId || '');
22
+    if (!PRODUCT_ID_PATTERN.test(baseProductId))
23
+        throw new Error('内部产品编号格式无效');
24
+    if (!Number.isSafeInteger(priceFen) || priceFen <= 0 || priceFen % 100 !== 0)
25
+        throw new Error('虚拟道具命名要求支付金额为整数元');
26
+
27
+    const productId = `${baseProductId}_${priceFen / 100}`;
28
+    if (!PRODUCT_ID_PATTERN.test(productId))
29
+        throw new Error('虚拟商品 productId 格式无效');
30
+    return productId;
31
+}
32
+
33
+export function resolveConfiguredProduct({ products, payType, price, env, internalProductId }) {
34
+    const candidates = (products || []).filter(item =>
35
+        Number(item.payType) === Number(payType)
36
+        && Number(item.price) === Number(price)
37
+    );
38
+    let product = candidates.find(item => item.env !== undefined && Number(item.env) === Number(env))
39
+        || candidates.find(item => item.env === undefined);
40
+    if (!product) {
41
+        product = {
42
+            productId: buildWechatProductId(internalProductId, price),
43
+            price,
44
+            goodsPrice: price,
45
+            env,
46
+            source: 'internal-product-id-and-price'
47
+        };
48
+    }
49
+    if (!product.productId)
50
+        throw new Error('虚拟商品缺少 productId');
51
+    if (!PRODUCT_ID_PATTERN.test(String(product.productId)))
52
+        throw new Error('虚拟商品 productId 格式无效');
53
+    const goodsPrice = Number(product.goodsPrice ?? product.price);
54
+    if (!Number.isSafeInteger(goodsPrice) || goodsPrice <= 0)
55
+        throw new Error(`虚拟商品 ${product.productId} 的 goodsPrice 配置无效`);
56
+    if (price > goodsPrice)
57
+        throw new Error(`虚拟商品 ${product.productId} 的活动价不能高于后台商品价格`);
58
+    return { ...product, resolvedGoodsPrice: goodsPrice };
59
+}

+ 28 - 0
src/api/virtualPay/virtualPaySign.js

@@ -0,0 +1,28 @@
1
+import crypto from 'crypto';
2
+
3
+export function hmacSha256(key, content) {
4
+    return crypto.createHmac('sha256', String(key)).update(String(content), 'utf8').digest('hex');
5
+}
6
+
7
+export function calcPaySig(uri, body, appKey) {
8
+    return hmacSha256(appKey, `${uri}&${body}`);
9
+}
10
+
11
+export function calcUserSignature(signData, sessionKey) {
12
+    return hmacSha256(sessionKey, signData);
13
+}
14
+
15
+export function createTradeNo(now = Date.now()) {
16
+    const suffix = crypto.randomInt(100000, 1000000);
17
+    return `${now}${suffix}`;
18
+}
19
+
20
+export function moneyToFen(value) {
21
+    const text = String(value ?? '').trim();
22
+    if (!/^\d+(?:\.\d{1,2})?$/.test(text))
23
+        throw new Error('支付金额格式不正确');
24
+    const fen = Math.round(Number(text) * 100);
25
+    if (!Number.isSafeInteger(fen) || fen <= 0)
26
+        throw new Error('支付金额必须大于 0');
27
+    return fen;
28
+}

+ 51 - 0
src/api/virtualPay/virtualPayXml.js

@@ -0,0 +1,51 @@
1
+function decodeXml(value) {
2
+    return String(value || '')
3
+        .replaceAll('&lt;', '<')
4
+        .replaceAll('&gt;', '>')
5
+        .replaceAll('&quot;', '"')
6
+        .replaceAll('&apos;', "'")
7
+        .replaceAll('&amp;', '&');
8
+}
9
+
10
+function tagValue(xml, tag) {
11
+    const match = String(xml || '').match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'));
12
+    if (!match) return '';
13
+    const value = match[1].trim();
14
+    const cdataMatch = value.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/);
15
+    return decodeXml(cdataMatch ? cdataMatch[1] : value);
16
+}
17
+
18
+function parseObjectValue(value, fields) {
19
+    if (!value) return {};
20
+    try {
21
+        const result = JSON.parse(value);
22
+        if (result && typeof result === 'object') return result;
23
+    } catch {
24
+        // XML 对象结构继续按子标签解析。
25
+    }
26
+    const result = {};
27
+    for (const field of fields) {
28
+        const fieldValue = tagValue(value, field);
29
+        if (fieldValue !== '') result[field] = fieldValue;
30
+    }
31
+    return result;
32
+}
33
+
34
+export function parseVirtualPayNotifyXml(rawXml) {
35
+    const scalarFields = [
36
+        'ToUserName', 'FromUserName', 'CreateTime', 'MsgType', 'Event',
37
+        'OpenId', 'OutTradeNo', 'Env'
38
+    ];
39
+    const result = {};
40
+    for (const field of scalarFields) {
41
+        const value = tagValue(rawXml, field);
42
+        if (value !== '') result[field] = value;
43
+    }
44
+    result.WeChatPayInfo = parseObjectValue(tagValue(rawXml, 'WeChatPayInfo'), [
45
+        'MchOrderNo', 'TransactionId', 'PaidTime'
46
+    ]);
47
+    result.GoodsInfo = parseObjectValue(tagValue(rawXml, 'GoodsInfo'), [
48
+        'ProductId', 'Quantity', 'OrigPrice', 'ActualPrice', 'Attach'
49
+    ]);
50
+    return result;
51
+}

+ 2 - 0
src/api/web/webController.js

@@ -438,6 +438,8 @@ export async function GetUserFamily(ctx) {
438 438
                 if (param.IsWeb == 1) {
439 439
                     obj.UserID = Encrypt("UserID=" + userID + "&time=" + new Date().getTime(), config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
440 440
                     obj.UserID = obj.UserID.replace("+", "@@@");
441
+                    obj.UserID = obj.UserID.replace("+", "@@@");
442
+                    obj.UserID = obj.UserID.replace("+", "@@@");
441 443
                     obj.UserID = urlpath + "miaoguologin?param=" + obj.UserID;
442 444
                 } else {
443 445
                     obj.UserID = userID;

+ 3 - 0
src/app.js

@@ -25,6 +25,7 @@ import enumerationRouter from './api/enumeration/routes.js';
25 25
 import noticeRouter from './api/notice/routes.js';
26 26
 import activityRouter from './api/activity/routes.js';
27 27
 import payRouter from './api/pay/routes.js';
28
+import virtualPayRouter from './api/virtualPay/routes.js';
28 29
 import parentsHelperRouter from './api/parentsHelper/routes.js';
29 30
 import shiZiLiangRouter from './api/shiZiLiang/routes.js';
30 31
 import areaRouter from './api/area/routes.js';
@@ -135,6 +136,8 @@ app.use(activityRouter.routes());
135 136
 app.use(activityRouter.allowedMethods());
136 137
 app.use(payRouter.routes());
137 138
 app.use(payRouter.allowedMethods());
139
+app.use(virtualPayRouter.routes());
140
+app.use(virtualPayRouter.allowedMethods());
138 141
 app.use(parentsHelperRouter.routes());
139 142
 app.use(parentsHelperRouter.allowedMethods());
140 143
 app.use(shiZiLiangRouter.routes());

+ 24 - 0
src/config/index.js

@@ -10,6 +10,17 @@ const toNumber = (value, fallback) => {
10 10
     const number = Number(value);
11 11
     return Number.isFinite(number) ? number : fallback;
12 12
 };
13
+const parseJsonArray = (value, name) => {
14
+    if (!value) return [];
15
+    try {
16
+        const result = JSON.parse(value);
17
+        if (!Array.isArray(result))
18
+            throw new Error('must be a JSON array');
19
+        return result;
20
+    } catch (error) {
21
+        throw new Error(`${name} 配置无效: ${error.message}`);
22
+    }
23
+};
13 24
 
14 25
 // 公共数据库配置
15 26
 const commonConfig = {
@@ -32,6 +43,15 @@ const commonConfig = {
32 43
         volcesMaxRetries: toNumber(process.env.VOLCES_AI_MAX_RETRIES, 2),
33 44
         volcesTimeoutMs: toNumber(process.env.VOLCES_AI_TIMEOUT_MS, 90000)
34 45
     },
46
+    virtualPay: {
47
+        offerId: process.env.WX_VIRTUAL_PAY_OFFER_ID || '1450627649',
48
+        productionAppKey: process.env.WX_VIRTUAL_PAY_APP_KEY || 'hQKJbsRUAMwtmjocFjzh6c32xxaFz11l',
49
+        sandboxAppKey: process.env.WX_VIRTUAL_PAY_SANDBOX_APP_KEY || '1FKhOKbfCkkmMr3A1lYGdjCUaLYJSfD4',
50
+        env: toNumber(process.env.WX_VIRTUAL_PAY_ENV, runtimeEnv === 'production' ? 0 : 1),
51
+        products: parseJsonArray(process.env.WX_VIRTUAL_PAY_PRODUCTS_JSON, 'WX_VIRTUAL_PAY_PRODUCTS_JSON'),
52
+        messageToken: process.env.WX_MIAOGUO_MESSAGE_TOKEN || '',
53
+        requestTimeoutMs: toNumber(process.env.WX_VIRTUAL_PAY_TIMEOUT_MS, 10000)
54
+    },
35 55
     CDNUrl: 'https://cdn.example.com/',
36 56
     database: {
37 57
         multipleStatements: true,
@@ -195,6 +215,10 @@ const config = {
195 215
     database: {
196 216
         ...commonConfig.database,
197 217
         ...(envConfig.database || {})
218
+    },
219
+    virtualPay: {
220
+        ...commonConfig.virtualPay,
221
+        ...(envConfig.virtualPay || {})
198 222
     }
199 223
 };
200 224
 

+ 1 - 0
src/middleware/xmlBodyParser.js

@@ -31,6 +31,7 @@ export default function xmlBodyParser() {
31 31
         const rawBody = await readRawBody(ctx.req);
32 32
         ctx.request.body = {
33 33
             xml: parseSimpleXml(rawBody),
34
+            rawBody,
34 35
         };
35 36
         await next();
36 37
     };

+ 213 - 0
test/virtualPayFulfillment.test.js

@@ -0,0 +1,213 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import { fulfillOrder, MiaoguoVirtualPayNotify500 } from '../src/api/virtualPay/virtualPayController.js';
4
+
5
+function orderXml({ productId = '166_199', env = 0, goodsPrice = 19900, detail = {} } = {}) {
6
+    return `<xml>`
7
+        + `<detail><![CDATA[${JSON.stringify(detail)}]]></detail>`
8
+        + `<virtual_pay><![CDATA[${JSON.stringify({ productId, env, goodsPrice })}]]></virtual_pay>`
9
+        + `</xml>`;
10
+}
11
+
12
+function createFakeConnection({ order, user }) {
13
+    const state = {
14
+        began: 0,
15
+        committed: 0,
16
+        rolledBack: 0,
17
+        released: 0,
18
+        queries: []
19
+    };
20
+    const connection = {
21
+        async beginTransaction() { state.began += 1; },
22
+        async commit() { state.committed += 1; },
23
+        async rollback() { state.rolledBack += 1; },
24
+        release() { state.released += 1; },
25
+        async query(sql, params) {
26
+            state.queries.push({ sql, params });
27
+            if (sql.includes('FROM ProductPayInfo')) return [[order]];
28
+            if (sql.includes('FROM MiaoguoWXUsers')) return [[user]];
29
+            return [{ affectedRows: 1 }];
30
+        }
31
+    };
32
+    return { connection, state };
33
+}
34
+
35
+function dependencies(connection, postCalls = []) {
36
+    return {
37
+        getConnection: async () => connection,
38
+        schedulePostCommit: callback => callback(),
39
+        runPostPayActions: async payload => { postCalls.push(payload); }
40
+    };
41
+}
42
+
43
+function paidMemberOrder(overrides = {}) {
44
+    return {
45
+        ID: 101,
46
+        TradeNo: 'VP_TEST_199',
47
+        PayType: 7,
48
+        OpenID: 'openid-test',
49
+        Money: 19900,
50
+        Status: 0,
51
+        XMLPre: orderXml({ detail: { EndTime: '2027-08-27 23:59:59' } }),
52
+        ...overrides
53
+    };
54
+}
55
+
56
+const memberUser = {
57
+    UserID: 1,
58
+    OpenID: 'openid-test',
59
+    IsMember: 1,
60
+    ProductServiceTime: '2026-08-27 23:59:59'
61
+};
62
+
63
+test('支付成功后在一个事务中更新会员、优惠券和订单', async () => {
64
+    const { connection, state } = createFakeConnection({
65
+        order: paidMemberOrder(),
66
+        user: memberUser
67
+    });
68
+    const postCalls = [];
69
+
70
+    const result = await fulfillOrder('VP_TEST_199', {
71
+        openid: 'openid-test',
72
+        env: 0,
73
+        productId: '166_199',
74
+        quantity: 1,
75
+        originalPrice: 19900,
76
+        actualPrice: 19900,
77
+        paidTime: 1787846400,
78
+        raw: { source: 'test' }
79
+    }, dependencies(connection, postCalls));
80
+    await new Promise(resolve => setImmediate(resolve));
81
+
82
+    assert.equal(result.alreadyFulfilled, false);
83
+    assert.equal(state.began, 1);
84
+    assert.equal(state.committed, 1);
85
+    assert.equal(state.rolledBack, 0);
86
+    assert.equal(state.released, 1);
87
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoWXUsers SET')));
88
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoCoupon SET IsUse=1')));
89
+    assert.ok(state.queries.some(item => item.sql.includes('SET PayEndTime=') && item.sql.includes('Status=1')));
90
+    assert.equal(postCalls.length, 1);
91
+});
92
+
93
+test('测试用户的 1 元购买执行与正式购买相同的会员完成逻辑', async () => {
94
+    const testOrder = paidMemberOrder({
95
+        TradeNo: 'VP_TEST_BUY_1',
96
+        Money: 100,
97
+        XMLPre: orderXml({
98
+            productId: '166_1',
99
+            goodsPrice: 100,
100
+            detail: { EndTime: '2027-08-27 23:59:59' }
101
+        })
102
+    });
103
+    const { connection, state } = createFakeConnection({ order: testOrder, user: memberUser });
104
+
105
+    const result = await fulfillOrder('VP_TEST_BUY_1', {
106
+        openid: 'openid-test',
107
+        env: 0,
108
+        productId: '166_1',
109
+        originalPrice: 100,
110
+        actualPrice: 100
111
+    }, dependencies(connection));
112
+
113
+    assert.equal(result.memberUpdate.paidMember, true);
114
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoCoupon SET IsUse=1')));
115
+    assert.ok(state.queries.some(item => item.sql.includes('Status=1')));
116
+});
117
+
118
+test('重复发货通知不会重复增加会员有效期', async () => {
119
+    const { connection, state } = createFakeConnection({
120
+        order: paidMemberOrder({ Status: 1 }),
121
+        user: memberUser
122
+    });
123
+
124
+    const result = await fulfillOrder('VP_TEST_199', {}, dependencies(connection));
125
+
126
+    assert.equal(result.alreadyFulfilled, true);
127
+    assert.equal(state.committed, 1);
128
+    assert.equal(state.rolledBack, 0);
129
+    assert.equal(state.queries.filter(item => item.sql.includes('UPDATE ')).length, 0);
130
+});
131
+
132
+test('微信实付金额不一致时回滚且不发放权益', async () => {
133
+    const { connection, state } = createFakeConnection({
134
+        order: paidMemberOrder(),
135
+        user: memberUser
136
+    });
137
+
138
+    await assert.rejects(
139
+        fulfillOrder('VP_TEST_199', {
140
+            openid: 'openid-test',
141
+            env: 0,
142
+            productId: '166_199',
143
+            orderFee: 100
144
+        }, dependencies(connection)),
145
+        /金额与本地订单不一致/
146
+    );
147
+
148
+    assert.equal(state.committed, 0);
149
+    assert.equal(state.rolledBack, 1);
150
+    assert.equal(state.released, 1);
151
+    assert.equal(state.queries.filter(item => item.sql.includes('UPDATE ')).length, 0);
152
+});
153
+
154
+test('1 元试用只记录申请和支付订单,不开通付费会员', async () => {
155
+    const trialOrder = paidMemberOrder({
156
+        TradeNo: 'VP_TEST_1',
157
+        PayType: 9,
158
+        Money: 100,
159
+        XMLPre: orderXml({ productId: '166_1', goodsPrice: 100, detail: { PayType: 9 } })
160
+    });
161
+    const { connection, state } = createFakeConnection({ order: trialOrder, user: memberUser });
162
+
163
+    const result = await fulfillOrder('VP_TEST_1', {
164
+        openid: 'openid-test',
165
+        env: 0,
166
+        productId: '166_1',
167
+        originalPrice: 100,
168
+        actualPrice: 100
169
+    }, dependencies(connection));
170
+
171
+    assert.equal(result.memberUpdate.paidMember, false);
172
+    assert.equal(result.memberUpdate.IsApply, 1);
173
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoWXUsers SET')));
174
+    assert.equal(state.queries.some(item => item.sql.includes('UPDATE MiaoguoCoupon')), false);
175
+    assert.ok(state.queries.some(item => item.sql.includes('Status=1')));
176
+});
177
+
178
+test('可在本地模拟微信发货通知并进入统一完成函数', async () => {
179
+    const calls = [];
180
+    const ctx = {
181
+        query: {},
182
+        request: {
183
+            headers: { 'content-type': 'application/json' },
184
+            body: {
185
+                Event: 'xpay_goods_deliver_notify',
186
+                OpenId: 'openid-test',
187
+                OutTradeNo: 'VP_NOTIFY_TEST',
188
+                Env: 0,
189
+                GoodsInfo: {
190
+                    ProductId: '166_199',
191
+                    Quantity: 1,
192
+                    OrigPrice: 19900,
193
+                    ActualPrice: 19900
194
+                },
195
+                WeChatPayInfo: {
196
+                    TransactionId: 'wx-test-transaction',
197
+                    PaidTime: 1787846400
198
+                }
199
+            }
200
+        }
201
+    };
202
+
203
+    await MiaoguoVirtualPayNotify500(ctx, {
204
+        isValidMessageSignature: () => true,
205
+        fulfillOrder: async (tradeNo, payment) => { calls.push({ tradeNo, payment }); }
206
+    });
207
+
208
+    assert.deepEqual(ctx.body, { ErrCode: 0, ErrMsg: 'success' });
209
+    assert.equal(calls.length, 1);
210
+    assert.equal(calls[0].tradeNo, 'VP_NOTIFY_TEST');
211
+    assert.equal(calls[0].payment.productId, '166_199');
212
+    assert.equal(calls[0].payment.actualPrice, 19900);
213
+});

+ 60 - 0
test/virtualPayProduct.test.js

@@ -0,0 +1,60 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import {
4
+    assertAllowedMiaoguoPrice,
5
+    buildWechatProductId,
6
+    isMiaoguoTestUser,
7
+    resolveConfiguredProduct
8
+} from '../src/api/virtualPay/virtualPayProduct.js';
9
+
10
+test('按内部产品编号和实际金额生成微信道具 ID', () => {
11
+    assert.equal(buildWechatProductId(166, 100), '166_1');
12
+    assert.equal(buildWechatProductId(166, 19900), '166_199');
13
+    assert.equal(buildWechatProductId(166, 25800), '166_258');
14
+    assert.equal(buildWechatProductId(205, 19900), '205_199');
15
+});
16
+
17
+test('秒过只接受已发布的三个业务价位', () => {
18
+    assert.doesNotThrow(() => assertAllowedMiaoguoPrice(9, 100));
19
+    assert.doesNotThrow(() => assertAllowedMiaoguoPrice(7, 19900));
20
+    assert.doesNotThrow(() => assertAllowedMiaoguoPrice(7, 25800));
21
+    assert.throws(() => assertAllowedMiaoguoPrice(7, 100, 8), /暂不支持/);
22
+    assert.throws(() => assertAllowedMiaoguoPrice(9, 19900), /暂不支持/);
23
+});
24
+
25
+test('仅服务端确认的测试用户可用 1 元测试购买', () => {
26
+    assert.equal(isMiaoguoTestUser(1), true);
27
+    assert.equal(isMiaoguoTestUser(7), true);
28
+    assert.equal(isMiaoguoTestUser(8), false);
29
+    assert.equal(isMiaoguoTestUser(3089), true);
30
+    assert.doesNotThrow(() => assertAllowedMiaoguoPrice(7, 100, 1));
31
+    assert.doesNotThrow(() => assertAllowedMiaoguoPrice(7, 100, 3089));
32
+});
33
+
34
+test('没有显式配置时使用道具命名约定', () => {
35
+    const product = resolveConfiguredProduct({
36
+        products: [],
37
+        payType: 7,
38
+        price: 19900,
39
+        env: 0,
40
+        internalProductId: 166
41
+    });
42
+    assert.equal(product.productId, '166_199');
43
+    assert.equal(product.resolvedGoodsPrice, 19900);
44
+});
45
+
46
+test('显式商品配置仍可覆盖默认命名规则', () => {
47
+    const product = resolveConfiguredProduct({
48
+        products: [{ productId: 'special_offer', payType: 7, price: 19900, goodsPrice: 25800, env: 0 }],
49
+        payType: 7,
50
+        price: 19900,
51
+        env: 0,
52
+        internalProductId: 166
53
+    });
54
+    assert.equal(product.productId, 'special_offer');
55
+    assert.equal(product.resolvedGoodsPrice, 25800);
56
+});
57
+
58
+test('默认道具命名拒绝非整数元金额', () => {
59
+    assert.throws(() => buildWechatProductId(166, 19950), /整数元/);
60
+});

+ 25 - 0
test/virtualPaySign.test.js

@@ -0,0 +1,25 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import { calcPaySig, calcUserSignature, moneyToFen } from '../src/api/virtualPay/virtualPaySign.js';
4
+
5
+test('微信文档中的支付签名示例', () => {
6
+    const body = '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}';
7
+    assert.equal(
8
+        calcPaySig('/xpay/query_user_balance', body, '12345'),
9
+        'c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5'
10
+    );
11
+});
12
+
13
+test('微信文档中的用户态签名示例', () => {
14
+    const body = '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}';
15
+    assert.equal(
16
+        calcUserSignature(body, '9hAb/NEYUlkaMBEsmFgzig=='),
17
+        '089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7'
18
+    );
19
+});
20
+
21
+test('金额按旧支付表的分单位转换', () => {
22
+    assert.equal(moneyToFen('199'), 19900);
23
+    assert.equal(moneyToFen('1.00'), 100);
24
+    assert.throws(() => moneyToFen('0.001'));
25
+});

+ 34 - 0
test/virtualPayXml.test.js

@@ -0,0 +1,34 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import { parseVirtualPayNotifyXml } from '../src/api/virtualPay/virtualPayXml.js';
4
+
5
+test('解析 XML 内嵌对象形式的发货通知', () => {
6
+    const xml = `<xml>
7
+        <Event><![CDATA[xpay_goods_deliver_notify]]></Event>
8
+        <OpenId><![CDATA[test-openid]]></OpenId>
9
+        <OutTradeNo>202608260001</OutTradeNo>
10
+        <Env>1</Env>
11
+        <WeChatPayInfo><TransactionId><![CDATA[wx-transaction]]></TransactionId><PaidTime>1770000000</PaidTime></WeChatPayInfo>
12
+        <GoodsInfo><ProductId><![CDATA[miaoguo-year]]></ProductId><Quantity>1</Quantity><OrigPrice>36500</OrigPrice><ActualPrice>19900</ActualPrice></GoodsInfo>
13
+    </xml>`;
14
+    assert.deepEqual(parseVirtualPayNotifyXml(xml), {
15
+        Event: 'xpay_goods_deliver_notify',
16
+        OpenId: 'test-openid',
17
+        OutTradeNo: '202608260001',
18
+        Env: '1',
19
+        WeChatPayInfo: { TransactionId: 'wx-transaction', PaidTime: '1770000000' },
20
+        GoodsInfo: { ProductId: 'miaoguo-year', Quantity: '1', OrigPrice: '36500', ActualPrice: '19900' }
21
+    });
22
+});
23
+
24
+test('解析 XML 内 JSON 形式的发货通知对象', () => {
25
+    const xml = `<xml>
26
+        <Event>xpay_goods_deliver_notify</Event>
27
+        <GoodsInfo><![CDATA[{"ProductId":"miaoguo-year","Quantity":1,"OrigPrice":36500,"ActualPrice":19900}]]></GoodsInfo>
28
+        <WeChatPayInfo><![CDATA[{"TransactionId":"wx-transaction"}]]></WeChatPayInfo>
29
+    </xml>`;
30
+    const result = parseVirtualPayNotifyXml(xml);
31
+    assert.equal(result.GoodsInfo.ProductId, 'miaoguo-year');
32
+    assert.equal(result.GoodsInfo.ActualPrice, 19900);
33
+    assert.equal(result.WeChatPayInfo.TransactionId, 'wx-transaction');
34
+});

File diff suppressed because it is too large
+ 230 - 0
秒过分数线数据导入/北京版秒过分数线可行性报告.md