Bladeren bron

虚拟支付网页版

chengjie 2 weken geleden
bovenliggende
commit
98ca3757e4

+ 61 - 0
doc/wechat_service_pay.md

@@ -0,0 +1,61 @@
1
+# 服务号网页 1 元支付
2
+
3
+本模块用于 `public/wcs/pay.html` 的微信公众号网页支付,与原有 `src/api/pay`
4
+并行。原 `ProductPayLoginWeb`、`ProductPayNotifyWebchatService` 等接口继续保留,网页入口
5
+改用 `src/api/wechatServicePay` 下的新接口。
6
+
7
+该页面在微信内置浏览器中使用普通 JSAPI 支付。Android 和 iOS 使用相同服务端接口,
8
+不使用小程序虚拟支付 `requestVirtualPayment`。
9
+
10
+## 兼容数据规则
11
+
12
+- 金额由服务端固定为 100 分,客户端不能指定价格。
13
+- `ProductPayInfo.ProductID=167`。
14
+- `PayType=8`,`BuyType=131`,订单初始 `Status=0`。
15
+- 支付成功后 `Status=1`,同时写入 `PayEndTime`、`XMLPay`、
16
+  `ProductServiceTime` 和服务号 `UserID`。
17
+- `WechatServiceWXUsers` 写入 `IsProbation=1` 和 `ProbationPayTime`。
18
+- 如果服务号用户的 UnionID 已关联 `MiaoguoWXUsers`,秒过有效期更新为支付时间后 16 天。
19
+- 微信重复通知或“通知与主动查单同时到达”不会重复发放权益,完成逻辑使用数据库事务、
20
+  行锁和 `Status=1` 幂等判断。
21
+
22
+## 新接口
23
+
24
+| 接口 | 方法 | 用途 |
25
+| --- | --- | --- |
26
+| `/api/MiaoguoWechatServicePayLogin500` | POST | OAuth code 换 OpenID、固定 1 元统一下单、返回 JSAPI 参数 |
27
+| `/api/MiaoguoWechatServicePayOrderStatus500` | POST | 主动向微信查单,必要时补发权益 |
28
+| `/api/MiaoguoWechatServicePayNotify500` | POST XML | 微信 APIv2 支付结果通知 |
29
+
30
+下单响应、查单响应和支付成功通知均校验 APIv2 签名、AppID 和商户号;完成订单前还会
31
+校验 OpenID、交易单号与 100 分金额。微信通知地址默认是:
32
+
33
+```text
34
+https://www.kylx365.com/api/MiaoguoWechatServicePayNotify500
35
+```
36
+
37
+如部署域名变化,可配置:
38
+
39
+```bash
40
+WX_WECHAT_SERVICE_PAY_NOTIFY_URL=https://域名/api/MiaoguoWechatServicePayNotify500
41
+WX_WECHAT_SERVICE_PAY_TIMEOUT_MS=10000
42
+```
43
+
44
+微信商户平台 APIv2 密钥仍读取现有 `config.wx.payapisecret`,服务号 AppID、AppSecret 和
45
+商户号也沿用现有配置,因此数据库及部署配置与旧模块兼容。
46
+
47
+## 支付完成确认
48
+
49
+网页不会再把微信前端 `getBrandWCPayRequest:ok` 当作最终成功。它会把服务端生成的
50
+`TradeNo` 提交到新查单接口,只有服务端向微信确认 `trade_state=SUCCESS` 且事务完成后
51
+才跳转成功页。支付按钮在整个流程中防重复点击;如果微信已受理但查单暂时失败,页面会
52
+保持禁用并提醒不要重复支付。
53
+
54
+本地自动测试不会访问微信或修改真实数据库:
55
+
56
+```bash
57
+npm run test:wechat-service-pay
58
+```
59
+
60
+测试包含固定服务端金额、APIv2 签名/XML、伪造通知拦截、事务完成、重复通知幂等、
61
+金额不一致回滚、主动查单补完成,以及网页支付后确认逻辑。

+ 1 - 0
package.json

@@ -18,6 +18,7 @@
18 18
     "test": "node --check src/app.js && node --test test/*.test.js",
19 19
     "test:virtual-pay": "node --test test/virtualPay*.test.js",
20 20
     "test:virtual-pay:completion": "node --test test/virtualPayFulfillment.test.js",
21
+    "test:wechat-service-pay": "node --test test/wechatPayV2.test.js test/wechatServicePay.test.js",
21 22
     "node": "node",
22 23
     "check-version": "node scripts/check-node-version.js"
23 24
   },

+ 233 - 112
public/wcs/pay.html

@@ -155,174 +155,295 @@
155 155
         .audio{
156 156
             display: none;
157 157
         }
158
+
159
+        .payStatus{
160
+            min-height: 20px;
161
+            margin-top: 7px;
162
+            color: #fff;
163
+            font-size: 14px;
164
+            z-index: 2;
165
+        }
166
+
167
+        .btnPayClass.isBusy{
168
+            opacity: 0.65;
169
+            pointer-events: none;
170
+        }
158 171
     </style>
159
-    <script src="js/jquery-1.6.4.min.js"></script>
160 172
     <script>
161
-        var serverurl1='[支付链接]';
162
-        $(document).ready(function () {
163
-
164
-            var audio1=document.getElementById("audio1");
173
+        const createPayEndpoint = '[支付链接]';
174
+        const orderStatusEndpoint = '[查询链接]';
175
+        const assetRoot = 'https://miaguo-1253256735.file.myqcloud.com/web/';
176
+        let paymentBusy = false;
177
+        let activePayParameters = null;
178
+
179
+        function apiDataUrl(encryptedEndpoint) {
180
+            return '/apiData/' + encodeURIComponent(encryptedEndpoint);
181
+        }
165 182
 
166
-            $("#btnPay").click(function(){
167
-                var param={};
168
-                param.money=$(this).attr("title");
169
-                param.code=QueryString("code");
170
-                $.post("/apiData/"+serverurl1, param, function (data) {
171
-                    onBridgeReady(data);
172
-                });
173
-            });
183
+        function setPaymentState(busy, message) {
184
+            paymentBusy = busy;
185
+            const button = document.getElementById('btnPay');
186
+            const status = document.getElementById('payStatus');
187
+            button.classList.toggle('isBusy', busy);
188
+            button.setAttribute('aria-disabled', busy ? 'true' : 'false');
189
+            status.textContent = message || '';
190
+        }
174 191
 
175
-            $(".btnGoto").click(function(){
176
-                var url=$(this).attr("title");
177
-                window.open("/wcs/"+url+".html");
192
+        async function postApi(encryptedEndpoint, params) {
193
+            const response = await fetch(apiDataUrl(encryptedEndpoint), {
194
+                method: 'POST',
195
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
196
+                body: new URLSearchParams(params).toString(),
197
+                credentials: 'same-origin',
198
+                cache: 'no-store'
178 199
             });
200
+            const data = await response.json();
201
+            if (!response.ok || Number(data.errcode) !== 10000)
202
+                throw new Error(data.errStr || '服务端支付接口调用失败');
203
+            return data.result || {};
204
+        }
179 205
 
180
-            $(".btnPlay").click(function(){
181
-                var imgA="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zc_play.png";
182
-                var imgB="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zc_pause.png";
183
-
184
-                if ($(".btnPlay").attr("src")==imgA){
185
-                    audio1.play();
186
-                    $(".btnPlay").attr("src",imgB);
187
-                }
188
-                else{
189
-                    audio1.pause();
190
-                    $(".btnPlay").attr("src",imgA);
206
+        function invokeWechatPay(payParameters) {
207
+            return new Promise(function (resolve, reject) {
208
+                let settled = false;
209
+                const timeout = window.setTimeout(function () {
210
+                    if (!settled) {
211
+                        settled = true;
212
+                        reject(new Error('微信支付组件没有准备好,请在微信中重新打开本页'));
213
+                    }
214
+                }, 8000);
215
+
216
+                function invoke() {
217
+                    if (settled) return;
218
+                    if (typeof WeixinJSBridge === 'undefined') return;
219
+                    WeixinJSBridge.invoke('getBrandWCPayRequest', payParameters, function (result) {
220
+                        if (settled) return;
221
+                        settled = true;
222
+                        window.clearTimeout(timeout);
223
+                        resolve(result || {});
224
+                    });
191 225
                 }
192
-            });
193 226
 
194
-            $(".btnMore").click(function(){
195
-                $(".moreImg").css("display","block");
196
-                $(".btnMore").css("display","none");
227
+                if (typeof WeixinJSBridge === 'undefined') {
228
+                    document.addEventListener('WeixinJSBridgeReady', invoke, { once: true });
229
+                } else {
230
+                    invoke();
231
+                }
197 232
             });
233
+        }
198 234
 
199
-            $(".btnPayShow").click(function(){
200
-                $(".panelBottom").css("display","block");
235
+        function delay(milliseconds) {
236
+            return new Promise(function (resolve) {
237
+                window.setTimeout(resolve, milliseconds);
201 238
             });
239
+        }
202 240
 
203
-            $(".btnClose").click(function(e){
204
-                $(".panelBottom").css("display","none");
205
-            });
241
+        async function waitForPaidOrder(tradeNo, attempts) {
242
+            let lastError = null;
243
+            for (let index = 0; index < attempts; index += 1) {
244
+                try {
245
+                    const result = await postApi(orderStatusEndpoint, { TradeNo: tradeNo });
246
+                    if (Number(result.Status) === 1 || result.TradeState === 'SUCCESS') return true;
247
+                    if (['CLOSED', 'REVOKED', 'PAYERROR'].includes(result.TradeState))
248
+                        throw new Error('微信支付订单未完成(' + result.TradeState + ')');
249
+                } catch (error) {
250
+                    lastError = error;
251
+                }
252
+                if (index < attempts - 1) await delay(1500);
253
+            }
254
+            if (lastError) throw lastError;
255
+            return false;
256
+        }
206 257
 
258
+        async function startPayment(event) {
259
+            event.stopPropagation();
260
+            if (paymentBusy) return;
261
+            let bridgeAccepted = false;
262
+            if (!/MicroMessenger/i.test(navigator.userAgent)) {
263
+                window.alert('请在微信内打开本页面后支付');
264
+                return;
265
+            }
207 266
 
208
-            $("body").scroll(function(e){
209
-                console.log(e);
210
-                //getMoreFunction();
211
-            });
267
+            const code = new URLSearchParams(window.location.search).get('code');
268
+            if (!code) {
269
+                window.alert('微信授权信息已失效,请重新打开支付页面');
270
+                window.location.href = '/webpay';
271
+                return;
272
+            }
212 273
 
213
-        });
274
+            try {
275
+                let payParameters = activePayParameters;
276
+                if (!payParameters) {
277
+                    setPaymentState(true, '正在创建 1 元支付订单…');
278
+                    payParameters = await postApi(createPayEndpoint, { code: code });
279
+                    if (!payParameters.TradeNo || !payParameters.package || !payParameters.paySign)
280
+                        throw new Error('服务端返回的支付参数不完整');
281
+                    activePayParameters = payParameters;
282
+                }
214 283
 
215
-        function onBridgeReady(data){
216
-            console.log(data);
217
-            WeixinJSBridge.invoke(
218
-                    'getBrandWCPayRequest', data.result,
219
-                    function(res){
220
-                        if(res.err_msg === "get_brand_wcpay_request:ok" ){
221
-                            window.location.href = "https://www.kylx365.com/webpay?url=webpayok";
222
-                        }
223
-                        else {
224
-                            window.location.href = "https://www.kylx365.com/webpay";
225
-                        }
226
-                    });
227
-        }
284
+                setPaymentState(true, '请完成微信支付…');
285
+                const bridgeResult = await invokeWechatPay({
286
+                    appId: payParameters.appId,
287
+                    timeStamp: payParameters.timeStamp,
288
+                    nonceStr: payParameters.nonceStr,
289
+                    package: payParameters.package,
290
+                    signType: payParameters.signType,
291
+                    paySign: payParameters.paySign
292
+                });
293
+                const message = String(bridgeResult.err_msg || '');
294
+                bridgeAccepted = message === 'get_brand_wcpay_request:ok';
295
+                if (message === 'get_brand_wcpay_request:cancel') {
296
+                    setPaymentState(false, '您已取消支付,可以重新操作');
297
+                    return;
298
+                }
228 299
 
300
+                setPaymentState(true, '正在向微信确认支付结果…');
301
+                const paid = await waitForPaidOrder(payParameters.TradeNo,
302
+                    message === 'get_brand_wcpay_request:ok' ? 10 : 3);
303
+                if (paid) {
304
+                    setPaymentState(true, '支付成功,正在开通新手包…');
305
+                    window.location.href = 'https://www.kylx365.com/webpay?url=webpayok';
306
+                    return;
307
+                }
229 308
 
230
-        function QueryString(key) {
231
-            var paras = location.search;
232
-            if (paras) {
233
-                var arr = paras.substr(1).split("&"), data;
234
-                for (i in arr) {
235
-                    data = arr[i].split("=");
236
-                    if (data[0] == key) {
237
-                        return data[1].replace("__","=").replace("_","?");
238
-                    }
309
+                if (message === 'get_brand_wcpay_request:ok') {
310
+                    setPaymentState(true, '支付已受理,系统正在确认,请勿重复支付');
311
+                    return;
312
+                }
313
+                throw new Error(bridgeResult.err_desc || '支付未完成,请稍后重试');
314
+            } catch (error) {
315
+                if (bridgeAccepted) {
316
+                    setPaymentState(true, '支付已受理,系统正在确认,请勿重复支付');
317
+                    window.alert('微信支付已受理,但系统确认稍有延迟,请勿重复支付');
318
+                    return;
239 319
                 }
320
+                setPaymentState(false, error.message || '支付未完成,请稍后重试');
321
+                window.alert(error.message || '支付未完成,请稍后重试');
240 322
             }
241 323
         }
324
+
325
+        document.addEventListener('DOMContentLoaded', function () {
326
+            const audio = document.getElementById('audio1');
327
+            const playButton = document.querySelector('.btnPlay');
328
+            const panel = document.querySelector('.panelBottom');
329
+
330
+            document.getElementById('btnPay').addEventListener('click', startPayment);
331
+            document.querySelectorAll('.btnGoto').forEach(function (element) {
332
+                element.addEventListener('click', function () {
333
+                    window.location.href = '/wcs/' + element.getAttribute('title') + '.html';
334
+                });
335
+            });
336
+            playButton.addEventListener('click', function () {
337
+                if (audio.paused) {
338
+                    audio.play();
339
+                    playButton.src = assetRoot + 'bm_sy_banner-zc_pause.png';
340
+                } else {
341
+                    audio.pause();
342
+                    playButton.src = assetRoot + 'bm_sy_banner-zc_play.png';
343
+                }
344
+            });
345
+            audio.addEventListener('ended', function () {
346
+                playButton.src = assetRoot + 'bm_sy_banner-zc_play.png';
347
+            });
348
+            document.querySelector('.btnMore').addEventListener('click', function (event) {
349
+                document.querySelector('.moreImg').style.display = 'block';
350
+                event.currentTarget.style.display = 'none';
351
+            });
352
+            document.querySelectorAll('.btnPayShow').forEach(function (element) {
353
+                element.addEventListener('click', function () {
354
+                    panel.style.display = 'block';
355
+                });
356
+            });
357
+            document.querySelector('.btnClose').addEventListener('click', function (event) {
358
+                event.stopPropagation();
359
+                if (!paymentBusy) panel.style.display = 'none';
360
+            });
361
+        });
242 362
     </script>
243 363
 </head>
244 364
 <body class="container FlexColumn">
245
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_01.png" />
246
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_02.png" />
247
-<img class="img1 btnPayShow" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_03.png" />
365
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_01.png" />
366
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_02.png" />
367
+<img class="img1 btnPayShow" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_03.png" />
248 368
 
249
-<img class="img1 btnGoto" title="product" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-flashcardhistory.png" />
369
+<img class="img1 btnGoto" title="product" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-flashcardhistory.png" />
250 370
 
251
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_04.png" />
371
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_04.png" />
252 372
 
253
-<img class="img1 btnGoto" title="strategy" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-method.png" />
373
+<img class="img1 btnGoto" title="strategy" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-method.png" />
254 374
 
255
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_05.png" />
256
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_06.png" />
257
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_07.png" />
258
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_08.png" />
375
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_05.png" />
376
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_06.png" />
377
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_07.png" />
378
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_08.png" />
259 379
 
260
-<img class="img1 btnPlay" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zc_play.png" />
380
+<img class="img1 btnPlay" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zc_play.png" />
261 381
 
262
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_09.png" />
263
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_10.png" />
264
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_11.png" />
382
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_09.png" />
383
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_10.png" />
384
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_11.png" />
265 385
 
266
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_12.png" />
267
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_14.png" />
268
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_15.png" />
269
-<img class="img1 btnGoto" title="payPy03" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_16.png" />
270
-<img class="img1 btnGoto" title="payPy01" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_17.png" />
271
-<img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_18.png" />
272
-<img class="img1 btnGoto" title="payPy02" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_19.png" />
273
-<img class="img1 btnGoto" title="payPy04" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_20.png" />
386
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_12.png" />
387
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_14.png" />
388
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_15.png" />
389
+<img class="img1 btnGoto" title="payPy03" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_16.png" />
390
+<img class="img1 btnGoto" title="payPy01" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_17.png" />
391
+<img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_18.png" />
392
+<img class="img1 btnGoto" title="payPy02" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_19.png" />
393
+<img class="img1 btnGoto" title="payPy04" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_20.png" />
274 394
 
275 395
 <div class="btnMore">更多评语</div>
276 396
 <div id="moreImg" class="moreImg">
277
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_01.png" />
278
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_02.png" />
279
-    <img class="img1 btnGoto" title="payPy02" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_03.png" />
280
-    <img class="img1 btnGoto" title="payPy04" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_04.png" />
281
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_05.png" />
282
-    <img class="img1 btnGoto" title="payPy06" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_06.png" />
283
-    <img class="img1 btnGoto" title="payPy01" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_07.png" />
284
-    <img class="img1 btnGoto" title="payPy05" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_08.png" />
285
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_09.png" />
286
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_10.png" />
287
-    <img class="img1 btnGoto" title="payPy07" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_11.png" />
288
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_12.png" />
289
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_13.png" />
290
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_14.png" />
291
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_15.png" />
292
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_16.png" />
293
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_17.png" />
294
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_18.png" />
295
-    <img class="img1 btnGoto" title="payPy08" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_19.png" />
296
-    <img class="img1" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_20.png" />
397
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_01.png" />
398
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_02.png" />
399
+    <img class="img1 btnGoto" title="payPy02" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_03.png" />
400
+    <img class="img1 btnGoto" title="payPy04" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_04.png" />
401
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_05.png" />
402
+    <img class="img1 btnGoto" title="payPy06" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_06.png" />
403
+    <img class="img1 btnGoto" title="payPy01" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_07.png" />
404
+    <img class="img1 btnGoto" title="payPy05" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_08.png" />
405
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_09.png" />
406
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_10.png" />
407
+    <img class="img1 btnGoto" title="payPy07" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_11.png" />
408
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_12.png" />
409
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_13.png" />
410
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_14.png" />
411
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_15.png" />
412
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_16.png" />
413
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_17.png" />
414
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_18.png" />
415
+    <img class="img1 btnGoto" title="payPy08" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_19.png" />
416
+    <img class="img1" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_pyindex_20.png" />
297 417
 </div>
298 418
 
299 419
 <div class="btnPayShow btnPayStart FlexColumn">
300 420
     <div class="btnPayClass2 btnPayClass FlexColumn">
301
-        <img class="bm_sy_banner-bm" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-bm.png" />
421
+        <img class="bm_sy_banner-bm" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-bm.png" />
302 422
     </div>
303 423
 </div>
304 424
 <div class="panelBottom container FlexColumn">
305 425
     <div class="panelBottom1 FlexColumn">
306 426
         <div class="text1">欢迎您<br />报名秒过新手培训</div>
307 427
         <div class="text2">新手包仅售1元</div>
308
-        <div id="btnPay" class="btnPay btnPayClass FlexRow"  title="1.00">
309
-            <img class="bm_sy_banner-zf" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zf.png" />
428
+        <div id="btnPay" class="btnPay btnPayClass FlexRow" role="button" aria-disabled="false">
429
+            <img class="bm_sy_banner-zf" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_banner-zf.png" />
310 430
         </div>
311
-        <img class="bm_sy_bg" src="http://miaguo-1253256735.file.myqcloud.com/web/bm_sy_bg.png" />
431
+        <div id="payStatus" class="payStatus" aria-live="polite"></div>
432
+        <img class="bm_sy_bg" src="https://miaguo-1253256735.file.myqcloud.com/web/bm_sy_bg.png" />
312 433
         <div class="panelBottom11 FlexColumn">
313 434
             <div class="text3">7天手把手</div>
314 435
             <div class="text4">教您上手新方法</div>
315 436
         </div>
316 437
         <div class="btnClose">
317 438
 
318
-            <img class="practise_index_board_close" src="http://miaguo-1253256735.file.myqcloud.com/web/practise_index_board_close.png" />
439
+            <img class="practise_index_board_close" src="https://miaguo-1253256735.file.myqcloud.com/web/practise_index_board_close.png" />
319 440
         </div>
320 441
     </div>
321 442
 </div>
322 443
 
323 444
 <div style="height:50px"></div>
324
-<audio id="audio1" src="http://miaguo-1253256735.file.myqcloud.com/web/_lesson/nuannuanbazhici.m4a" class="audio" >
445
+<audio id="audio1" src="https://miaguo-1253256735.file.myqcloud.com/web/_lesson/nuannuanbazhici.m4a" class="audio" >
325 446
     Your browser does not support the audio element.
326 447
 </audio>
327 448
 </body>
328
-</html>
449
+</html>

+ 5 - 3
src/api/pay/productPayController.js

@@ -1234,9 +1234,11 @@ export async function WebPay(ctx) {
1234 1234
 
1235 1235
 export async function WebPayPage(ctx) {
1236 1236
     let html = fs.readFileSync('./public/wcs/pay.html').toString();
1237
-    const detail = JSON.stringify({ ProductID: 166 });
1238
-    const url = Encrypt('ProductPayLoginWeb?detail=' + detail, config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
1239
-    html = html.replace('[支付链接]', url);
1237
+    const payUrl = Encrypt('MiaoguoWechatServicePayLogin500', config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
1238
+    const statusUrl = Encrypt('MiaoguoWechatServicePayOrderStatus500', config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
1239
+    html = html.replace('[支付链接]', payUrl).replace('[查询链接]', statusUrl);
1240
+    ctx.type = 'html';
1241
+    ctx.set('Cache-Control', 'no-store');
1240 1242
     ctx.body = html;
1241 1243
 }
1242 1244
 

+ 10 - 0
src/api/wechatServicePay/routes.js

@@ -0,0 +1,10 @@
1
+import Router from '@koa/router';
2
+import * as controller from './wechatServicePayController.js';
3
+
4
+const router = new Router();
5
+
6
+router.post('/api/MiaoguoWechatServicePayLogin500', controller.MiaoguoWechatServicePayLogin500);
7
+router.post('/api/MiaoguoWechatServicePayOrderStatus500', controller.MiaoguoWechatServicePayOrderStatus500);
8
+router.post('/api/MiaoguoWechatServicePayNotify500', controller.MiaoguoWechatServicePayNotify500);
9
+
10
+export default router;

+ 76 - 0
src/api/wechatServicePay/wechatPayV2.js

@@ -0,0 +1,76 @@
1
+import crypto from 'node:crypto';
2
+
3
+function scalar(value) {
4
+    if (Array.isArray(value)) value = value[0];
5
+    if (value === undefined || value === null) return '';
6
+    return String(value);
7
+}
8
+
9
+export function normalizeV2Fields(input = {}) {
10
+    const result = {};
11
+    for (const [key, value] of Object.entries(input || {}))
12
+        result[key] = scalar(value);
13
+    return result;
14
+}
15
+
16
+export function buildV2Sign(input, apiKey) {
17
+    if (!apiKey) throw new Error('缺少微信支付 APIv2 密钥');
18
+    const params = normalizeV2Fields(input);
19
+    const signingText = Object.keys(params)
20
+        .filter(key => key !== 'sign' && params[key] !== '')
21
+        .sort()
22
+        .map(key => `${key}=${params[key]}`)
23
+        .join('&') + `&key=${apiKey}`;
24
+    return crypto.createHash('md5').update(signingText, 'utf8').digest('hex').toUpperCase();
25
+}
26
+
27
+export function verifyV2Sign(input, apiKey) {
28
+    const params = normalizeV2Fields(input);
29
+    const received = params.sign.toUpperCase();
30
+    if (!/^[A-F0-9]{32}$/.test(received)) return false;
31
+    const expected = buildV2Sign(params, apiKey);
32
+    return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
33
+}
34
+
35
+function cdata(value) {
36
+    return scalar(value).replaceAll(']]>', ']]]]><![CDATA[>');
37
+}
38
+
39
+export function buildV2Xml(input) {
40
+    const fields = Object.entries(input || {}).map(([key, value]) => {
41
+        if (!/^[A-Za-z0-9_]+$/.test(key)) throw new Error(`非法 XML 字段:${key}`);
42
+        return `<${key}><![CDATA[${cdata(value)}]]></${key}>`;
43
+    });
44
+    return `<xml>${fields.join('')}</xml>`;
45
+}
46
+
47
+export function parseV2Xml(rawXml) {
48
+    const result = {};
49
+    const xml = String(rawXml || '');
50
+    const root = xml.match(/<xml(?:\s[^>]*)?>([\s\S]*)<\/xml>/i);
51
+    const content = root ? root[1] : xml;
52
+    const pattern = /<([A-Za-z0-9_]+)>([\s\S]*?)<\/\1>/g;
53
+    let match;
54
+    while ((match = pattern.exec(content)) !== null) {
55
+        const rawValue = match[2];
56
+        const hasCdata = rawValue.includes('<![CDATA[');
57
+        const value = hasCdata
58
+            ? rawValue.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
59
+            : rawValue.trim()
60
+                .replaceAll('&lt;', '<')
61
+                .replaceAll('&gt;', '>')
62
+                .replaceAll('&quot;', '"')
63
+                .replaceAll('&apos;', "'")
64
+                .replaceAll('&amp;', '&');
65
+        result[match[1]] = value;
66
+    }
67
+    return result;
68
+}
69
+
70
+export function createNonce() {
71
+    return crypto.randomBytes(16).toString('hex');
72
+}
73
+
74
+export function createTradeNo() {
75
+    return `WSP${Date.now()}${crypto.randomInt(10000000, 100000000)}`;
76
+}

+ 403 - 0
src/api/wechatServicePay/wechatServicePayController.js

@@ -0,0 +1,403 @@
1
+import axios from 'axios';
2
+import moment from 'moment';
3
+import config from '../../config/index.js';
4
+import { getConnection, query } from '../../util/db.js';
5
+import {
6
+    buildV2Sign,
7
+    buildV2Xml,
8
+    createNonce,
9
+    createTradeNo,
10
+    normalizeV2Fields,
11
+    parseV2Xml,
12
+    verifyV2Sign
13
+} from './wechatPayV2.js';
14
+
15
+export const WECHAT_SERVICE_PRODUCT_ID = 167;
16
+export const WECHAT_SERVICE_PAY_TYPE = 8;
17
+export const WECHAT_SERVICE_BUY_TYPE = 131;
18
+export const WECHAT_SERVICE_TRIAL_AMOUNT_FEN = 100;
19
+const TRIAL_DAYS = 16;
20
+const ORDER_BODY = '秒过-试用(服务号)';
21
+const UNIFIED_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/unifiedorder';
22
+const ORDER_QUERY_URL = 'https://api.mch.weixin.qq.com/pay/orderquery';
23
+
24
+function dependenciesOf(value) {
25
+    return value && typeof value === 'object' ? value : {};
26
+}
27
+
28
+function sendSuccess(ctx, result) {
29
+    ctx.body = { errcode: 10000, result };
30
+}
31
+
32
+function sendFailure(ctx, error) {
33
+    const message = error instanceof Error ? error.message : String(error || '服务号支付处理失败');
34
+    ctx.body = { errcode: 101, errStr: message, result: { errorMessage: message } };
35
+}
36
+
37
+function assertPaymentConfig() {
38
+    if (!config.wx.wechatservice_appid || !config.wx.wechatservice_appsecret)
39
+        throw new Error('缺少服务号 AppID 或 AppSecret 配置');
40
+    if (!config.wx.mch_id || !config.wx.payapisecret)
41
+        throw new Error('缺少微信商户号或 APIv2 密钥配置');
42
+}
43
+
44
+function assertWechatIdentity(fields) {
45
+    if (fields.appid !== String(config.wx.wechatservice_appid))
46
+        throw new Error('微信支付 AppID 与服务号配置不一致');
47
+    if (fields.mch_id !== String(config.wx.mch_id))
48
+        throw new Error('微信支付商户号与本地配置不一致');
49
+}
50
+
51
+function assertSignedWechatResponse(fields) {
52
+    if (!verifyV2Sign(fields, config.wx.payapisecret))
53
+        throw new Error('微信支付响应签名校验失败');
54
+    assertWechatIdentity(fields);
55
+}
56
+
57
+function v2Error(fields, fallback) {
58
+    return fields.err_code_des || fields.return_msg || fields.err_code || fallback;
59
+}
60
+
61
+function requestIp(ctx) {
62
+    const forwarded = String(ctx.request.headers['x-forwarded-for'] || '').split(',')[0].trim();
63
+    const candidate = forwarded || ctx.req.socket?.remoteAddress || '';
64
+    const normalized = candidate.replace(/^::ffff:/, '');
65
+    return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalized) ? normalized : '127.0.0.1';
66
+}
67
+
68
+function parsePaidTime(value) {
69
+    const parsed = moment(String(value || ''), 'YYYYMMDDHHmmss', true);
70
+    return parsed.isValid() ? parsed : moment();
71
+}
72
+
73
+async function postWechatXml(httpClient, url, fields) {
74
+    const xml = buildV2Xml(fields);
75
+    const { data } = await httpClient.post(url, xml, {
76
+        headers: { 'Content-Type': 'text/xml; charset=utf-8' },
77
+        timeout: config.wechatServicePay.requestTimeoutMs,
78
+        responseType: 'text',
79
+        transformRequest: [value => value]
80
+    });
81
+    return { xml, fields: parseV2Xml(data) };
82
+}
83
+
84
+function buildJsapiParameters(prepayId) {
85
+    const result = {
86
+        appId: String(config.wx.wechatservice_appid),
87
+        timeStamp: String(Math.floor(Date.now() / 1000)),
88
+        nonceStr: createNonce(),
89
+        package: `prepay_id=${prepayId}`,
90
+        signType: 'MD5'
91
+    };
92
+    result.paySign = buildV2Sign(result, config.wx.payapisecret);
93
+    return result;
94
+}
95
+
96
+async function runPostPayMessages({ serviceUser }, dependencies = {}) {
97
+    const httpClient = dependencies.httpClient || axios;
98
+    const wait = dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms)));
99
+    for (const messageID of [4, 5]) {
100
+        await httpClient.get(`http://localhost:${config.port}/api/SendWXServiceMessage`, {
101
+            params: { UserID: serviceUser.UserID, MessageID: messageID },
102
+            timeout: config.wechatServicePay.requestTimeoutMs
103
+        });
104
+        if (messageID === 4) await wait(500);
105
+    }
106
+}
107
+
108
+export async function fulfillWechatServiceOrder(tradeNo, payment, dependencies = {}) {
109
+    dependencies = dependenciesOf(dependencies);
110
+    const connectionFactory = dependencies.getConnection || getConnection;
111
+    const postPayActions = dependencies.runPostPayActions || runPostPayMessages;
112
+    const schedulePostCommit = dependencies.schedulePostCommit || (callback => setTimeout(callback, 0));
113
+    const conn = await connectionFactory();
114
+    let postCommit = null;
115
+
116
+    try {
117
+        await conn.beginTransaction();
118
+        const [orders] = await conn.query(
119
+            'SELECT * FROM ProductPayInfo WHERE TradeNo=? AND ProductID=? FOR UPDATE',
120
+            [tradeNo, WECHAT_SERVICE_PRODUCT_ID]
121
+        );
122
+        if (orders.length !== 1) throw new Error('未找到唯一的服务号支付订单');
123
+
124
+        const order = orders[0];
125
+        if (Number(order.Status) === 1) {
126
+            await conn.commit();
127
+            return { alreadyFulfilled: true, order };
128
+        }
129
+        if (Number(order.PayType) !== WECHAT_SERVICE_PAY_TYPE
130
+            || Number(order.BuyType) !== WECHAT_SERVICE_BUY_TYPE)
131
+            throw new Error('本地订单类型不符合服务号 1 元试用规则');
132
+        if (Number(order.Money) !== WECHAT_SERVICE_TRIAL_AMOUNT_FEN)
133
+            throw new Error('本地服务号支付订单金额不是 1 元');
134
+        if (!payment.transactionId)
135
+            throw new Error('微信支付结果缺少交易单号');
136
+        if (payment.openid !== order.OpenID)
137
+            throw new Error('微信支付用户与本地订单不一致');
138
+        if (Number(payment.totalFee) !== Number(order.Money))
139
+            throw new Error('微信支付金额与本地订单不一致');
140
+
141
+        const [serviceUsers] = await conn.query(
142
+            'SELECT * FROM WechatServiceWXUsers WHERE OpenID=? FOR UPDATE',
143
+            [order.OpenID]
144
+        );
145
+        if (serviceUsers.length !== 1) throw new Error('未找到唯一的服务号用户');
146
+        const serviceUser = serviceUsers[0];
147
+
148
+        let miaoguoUsers = [];
149
+        if (serviceUser.UnionID) {
150
+            [miaoguoUsers] = await conn.query(
151
+                'SELECT * FROM MiaoguoWXUsers WHERE UnionID=? FOR UPDATE',
152
+                [serviceUser.UnionID]
153
+            );
154
+        }
155
+
156
+        const paidMoment = parsePaidTime(payment.timeEnd);
157
+        const paidAt = paidMoment.format('YYYY-MM-DD HH:mm:ss');
158
+        const trialEnd = paidMoment.clone().add(TRIAL_DAYS, 'days').format('YYYY-MM-DD HH:mm:ss');
159
+
160
+        await conn.query(
161
+            'UPDATE WechatServiceWXUsers SET IsProbation=1,ProbationPayTime=? WHERE OpenID=?',
162
+            [paidAt, order.OpenID]
163
+        );
164
+        if (serviceUser.UnionID && miaoguoUsers.length) {
165
+            await conn.query(
166
+                'UPDATE MiaoguoWXUsers SET ProductServiceTime=? WHERE UnionID=?',
167
+                [trialEnd, serviceUser.UnionID]
168
+            );
169
+        }
170
+        await conn.query(
171
+            `UPDATE ProductPayInfo
172
+             SET PayEndTime=?,XMLPay=?,Status=1,ProductServiceTime=?,UserID=?
173
+             WHERE ID=?`,
174
+            [paidAt, JSON.stringify(payment.raw || payment), trialEnd, serviceUser.UserID, order.ID]
175
+        );
176
+        await conn.commit();
177
+
178
+        postCommit = { order, serviceUser, miaoguoUsers, trialEnd };
179
+        return {
180
+            alreadyFulfilled: false,
181
+            order,
182
+            serviceUserID: serviceUser.UserID,
183
+            miaoguoUserIDs: miaoguoUsers.map(user => user.UserID),
184
+            trialEnd
185
+        };
186
+    } catch (error) {
187
+        await conn.rollback();
188
+        throw error;
189
+    } finally {
190
+        conn.release();
191
+        if (postCommit) {
192
+            schedulePostCommit(() => {
193
+                Promise.resolve(postPayActions(postCommit, dependencies)).catch(error =>
194
+                    console.error('Wechat service pay post action failed:', error.message)
195
+                );
196
+            });
197
+        }
198
+    }
199
+}
200
+
201
+export async function MiaoguoWechatServicePayLogin500(ctx, injected = {}) {
202
+    const dependencies = dependenciesOf(injected);
203
+    const httpClient = dependencies.httpClient || axios;
204
+    const dbQuery = dependencies.query || query;
205
+    const param = ctx.request.body || {};
206
+    const code = String(param.code || '').trim();
207
+
208
+    try {
209
+        assertPaymentConfig();
210
+        if (!code || code.length > 512) throw new Error('微信网页授权 code 无效,请刷新页面重试');
211
+
212
+        const { data: oauthData } = await httpClient.get('https://api.weixin.qq.com/sns/oauth2/access_token', {
213
+            params: {
214
+                appid: config.wx.wechatservice_appid,
215
+                secret: config.wx.wechatservice_appsecret,
216
+                code,
217
+                grant_type: 'authorization_code'
218
+            },
219
+            timeout: config.wechatServicePay.requestTimeoutMs
220
+        });
221
+        const oauth = typeof oauthData === 'string' ? JSON.parse(oauthData) : oauthData;
222
+        if (!oauth?.openid)
223
+            throw new Error(`微信网页授权失败${oauth?.errcode ? `(${oauth.errcode})` : ''}`);
224
+
225
+        const serviceUsers = await dbQuery(
226
+            'SELECT UserID,OpenID,Subscribe,IsProbation FROM WechatServiceWXUsers WHERE OpenID=?',
227
+            [oauth.openid]
228
+        );
229
+        if (serviceUsers.length !== 1 || Number(serviceUsers[0].Subscribe) !== 1)
230
+            throw new Error('未找到已关注的服务号用户,请先关注公众号后重试');
231
+        if (Number(serviceUsers[0].IsProbation) === 1)
232
+            throw new Error('您已经开通过新手包,无需重复支付');
233
+
234
+        const tradeNo = createTradeNo();
235
+        const payFields = {
236
+            appid: String(config.wx.wechatservice_appid),
237
+            mch_id: String(config.wx.mch_id),
238
+            nonce_str: createNonce(),
239
+            sign_type: 'MD5',
240
+            body: ORDER_BODY,
241
+            detail: JSON.stringify({ ProductID: 166 }),
242
+            attach: JSON.stringify({ ProductID: WECHAT_SERVICE_PRODUCT_ID, PayType: WECHAT_SERVICE_PAY_TYPE }),
243
+            device_info: 'WEB',
244
+            out_trade_no: tradeNo,
245
+            total_fee: String(WECHAT_SERVICE_TRIAL_AMOUNT_FEN),
246
+            spbill_create_ip: requestIp(ctx),
247
+            time_start: moment().format('YYYYMMDDHHmmss'),
248
+            time_expire: moment().add(15, 'minutes').format('YYYYMMDDHHmmss'),
249
+            notify_url: config.wechatServicePay.notifyUrl,
250
+            trade_type: 'JSAPI',
251
+            openid: oauth.openid,
252
+            fee_type: 'CNY',
253
+            limit_pay: 'no_credit'
254
+        };
255
+        payFields.sign = buildV2Sign(payFields, config.wx.payapisecret);
256
+
257
+        const unifiedOrder = await postWechatXml(httpClient, UNIFIED_ORDER_URL, payFields);
258
+        const response = normalizeV2Fields(unifiedOrder.fields);
259
+        if (!Object.keys(response).length) throw new Error('微信统一下单返回了空响应');
260
+        if (response.return_code !== 'SUCCESS')
261
+            throw new Error(`微信统一下单失败:${v2Error(response, '未知错误')}`);
262
+        assertSignedWechatResponse(response);
263
+        if (response.result_code !== 'SUCCESS' || !response.prepay_id)
264
+            throw new Error(`微信统一下单失败:${v2Error(response, '未知错误')}`);
265
+        if (response.trade_type && response.trade_type !== 'JSAPI')
266
+            throw new Error('微信统一下单结果不是 JSAPI 支付');
267
+
268
+        await dbQuery(
269
+            `INSERT INTO ProductPayInfo
270
+             (TradeNo,PayType,BuyType,CreateTime,OpenID,Body,Status,Money,XMLPre,ProductID,Remark)
271
+             VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
272
+            [
273
+                tradeNo,
274
+                WECHAT_SERVICE_PAY_TYPE,
275
+                WECHAT_SERVICE_BUY_TYPE,
276
+                new Date(),
277
+                oauth.openid,
278
+                ORDER_BODY,
279
+                0,
280
+                WECHAT_SERVICE_TRIAL_AMOUNT_FEN,
281
+                unifiedOrder.xml,
282
+                WECHAT_SERVICE_PRODUCT_ID,
283
+                'wechatServicePay-v2'
284
+            ]
285
+        );
286
+
287
+        sendSuccess(ctx, { ...buildJsapiParameters(response.prepay_id), TradeNo: tradeNo });
288
+    } catch (error) {
289
+        sendFailure(ctx, error);
290
+    }
291
+}
292
+
293
+async function queryWechatOrder(order, dependencies = {}) {
294
+    const httpClient = dependencies.httpClient || axios;
295
+    const fields = {
296
+        appid: String(config.wx.wechatservice_appid),
297
+        mch_id: String(config.wx.mch_id),
298
+        out_trade_no: String(order.TradeNo),
299
+        nonce_str: createNonce(),
300
+        sign_type: 'MD5'
301
+    };
302
+    fields.sign = buildV2Sign(fields, config.wx.payapisecret);
303
+    const response = normalizeV2Fields((await postWechatXml(httpClient, ORDER_QUERY_URL, fields)).fields);
304
+    if (!Object.keys(response).length) throw new Error('微信查单返回了空响应');
305
+    if (response.return_code !== 'SUCCESS')
306
+        throw new Error(`微信查单失败:${v2Error(response, '未知错误')}`);
307
+    assertSignedWechatResponse(response);
308
+    if (response.result_code !== 'SUCCESS')
309
+        throw new Error(`微信查单失败:${v2Error(response, '未知错误')}`);
310
+    if (response.out_trade_no !== String(order.TradeNo))
311
+        throw new Error('微信查单结果与本地订单号不一致');
312
+    if (response.trade_state === 'SUCCESS' && response.trade_type !== 'JSAPI')
313
+        throw new Error('微信查单结果不是 JSAPI 支付');
314
+    return response;
315
+}
316
+
317
+export async function MiaoguoWechatServicePayOrderStatus500(ctx, injected = {}) {
318
+    const dependencies = dependenciesOf(injected);
319
+    const dbQuery = dependencies.query || query;
320
+    const fulfill = dependencies.fulfillOrder || fulfillWechatServiceOrder;
321
+    const tradeNo = String(ctx.request.body?.TradeNo || '').trim();
322
+
323
+    try {
324
+        assertPaymentConfig();
325
+        if (!/^WSP[A-Za-z0-9_-]{10,29}$/.test(tradeNo)) throw new Error('服务号支付订单号无效');
326
+        const orders = await dbQuery(
327
+            'SELECT * FROM ProductPayInfo WHERE TradeNo=? AND ProductID=? LIMIT 2',
328
+            [tradeNo, WECHAT_SERVICE_PRODUCT_ID]
329
+        );
330
+        if (orders.length !== 1) throw new Error('未找到唯一的服务号支付订单');
331
+        const order = orders[0];
332
+        if (Number(order.Status) === 1) {
333
+            sendSuccess(ctx, { TradeNo: tradeNo, Status: 1, TradeState: 'SUCCESS' });
334
+            return;
335
+        }
336
+
337
+        const response = await queryWechatOrder(order, dependencies);
338
+        if (response.trade_state !== 'SUCCESS') {
339
+            sendSuccess(ctx, {
340
+                TradeNo: tradeNo,
341
+                Status: 0,
342
+                TradeState: response.trade_state || 'NOTPAY'
343
+            });
344
+            return;
345
+        }
346
+        const result = await fulfill(tradeNo, {
347
+            openid: response.openid,
348
+            totalFee: Number(response.total_fee),
349
+            transactionId: response.transaction_id,
350
+            timeEnd: response.time_end,
351
+            raw: response
352
+        }, dependencies);
353
+        sendSuccess(ctx, {
354
+            TradeNo: tradeNo,
355
+            Status: 1,
356
+            TradeState: 'SUCCESS',
357
+            alreadyFulfilled: result.alreadyFulfilled,
358
+            trialEnd: result.trialEnd
359
+        });
360
+    } catch (error) {
361
+        sendFailure(ctx, error);
362
+    }
363
+}
364
+
365
+function replyV2(ctx, returnCode, returnMessage) {
366
+    ctx.status = 200;
367
+    ctx.type = 'text/xml; charset=utf-8';
368
+    ctx.body = buildV2Xml({ return_code: returnCode, return_msg: returnMessage });
369
+}
370
+
371
+export async function MiaoguoWechatServicePayNotify500(ctx, injected = {}) {
372
+    const dependencies = dependenciesOf(injected);
373
+    const fulfill = dependencies.fulfillOrder || fulfillWechatServiceOrder;
374
+    const logger = dependencies.logger || console;
375
+    try {
376
+        assertPaymentConfig();
377
+        const body = ctx.request.body || {};
378
+        const callbackFields = body.rawBody ? parseV2Xml(body.rawBody) : (body.xml || body);
379
+        const fields = normalizeV2Fields(callbackFields);
380
+        if (!Object.keys(fields).length) throw new Error('微信支付通知为空');
381
+        if (fields.return_code !== 'SUCCESS' || fields.result_code !== 'SUCCESS')
382
+            throw new Error(`微信支付通知失败:${v2Error(fields, '未知错误')}`);
383
+        assertSignedWechatResponse(fields);
384
+        if (!fields.out_trade_no || !fields.openid || !fields.transaction_id)
385
+            throw new Error('微信支付通知缺少订单、用户或交易单号');
386
+        if (fields.trade_type && fields.trade_type !== 'JSAPI')
387
+            throw new Error('微信支付通知类型不是 JSAPI');
388
+        if (fields.fee_type && fields.fee_type !== 'CNY')
389
+            throw new Error('微信支付通知币种不是人民币');
390
+
391
+        await fulfill(fields.out_trade_no, {
392
+            openid: fields.openid,
393
+            totalFee: Number(fields.total_fee),
394
+            transactionId: fields.transaction_id,
395
+            timeEnd: fields.time_end,
396
+            raw: fields
397
+        }, dependencies);
398
+        replyV2(ctx, 'SUCCESS', 'OK');
399
+    } catch (error) {
400
+        logger.error('Wechat service pay notify failed:', error.message);
401
+        replyV2(ctx, 'FAIL', 'PAYMENT_VALIDATION_FAILED');
402
+    }
403
+}

+ 3 - 0
src/app.js

@@ -26,6 +26,7 @@ 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 28
 import virtualPayRouter from './api/virtualPay/routes.js';
29
+import wechatServicePayRouter from './api/wechatServicePay/routes.js';
29 30
 import parentsHelperRouter from './api/parentsHelper/routes.js';
30 31
 import shiZiLiangRouter from './api/shiZiLiang/routes.js';
31 32
 import areaRouter from './api/area/routes.js';
@@ -138,6 +139,8 @@ app.use(payRouter.routes());
138 139
 app.use(payRouter.allowedMethods());
139 140
 app.use(virtualPayRouter.routes());
140 141
 app.use(virtualPayRouter.allowedMethods());
142
+app.use(wechatServicePayRouter.routes());
143
+app.use(wechatServicePayRouter.allowedMethods());
141 144
 app.use(parentsHelperRouter.routes());
142 145
 app.use(parentsHelperRouter.allowedMethods());
143 146
 app.use(shiZiLiangRouter.routes());

+ 9 - 0
src/config/index.js

@@ -53,6 +53,11 @@ const commonConfig = {
53 53
         requestTimeoutMs: toNumber(process.env.WX_VIRTUAL_PAY_TIMEOUT_MS, 10000),
54 54
         postActionTimeoutMs: toNumber(process.env.WX_VIRTUAL_PAY_POST_ACTION_TIMEOUT_MS, 60000)
55 55
     },
56
+    wechatServicePay: {
57
+        notifyUrl: process.env.WX_WECHAT_SERVICE_PAY_NOTIFY_URL
58
+            || 'https://www.kylx365.com/api/MiaoguoWechatServicePayNotify500',
59
+        requestTimeoutMs: toNumber(process.env.WX_WECHAT_SERVICE_PAY_TIMEOUT_MS, 10000)
60
+    },
56 61
     CDNUrl: 'https://cdn.example.com/',
57 62
     database: {
58 63
         multipleStatements: true,
@@ -220,6 +225,10 @@ const config = {
220 225
     virtualPay: {
221 226
         ...commonConfig.virtualPay,
222 227
         ...(envConfig.virtualPay || {})
228
+    },
229
+    wechatServicePay: {
230
+        ...commonConfig.wechatServicePay,
231
+        ...(envConfig.wechatServicePay || {})
223 232
     }
224 233
 };
225 234
 

+ 57 - 0
test/wechatPayV2.test.js

@@ -0,0 +1,57 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import {
4
+    buildV2Sign,
5
+    buildV2Xml,
6
+    normalizeV2Fields,
7
+    parseV2Xml,
8
+    verifyV2Sign
9
+} from '../src/api/wechatServicePay/wechatPayV2.js';
10
+
11
+test('APIv2 签名与微信官方示例一致', () => {
12
+    const fields = {
13
+        appid: 'wxd930ea5d5a258f4f',
14
+        body: 'test',
15
+        device_info: '1000',
16
+        mch_id: '10000100',
17
+        nonce_str: 'ibuaiVcKdpRxkhJA'
18
+    };
19
+    assert.equal(
20
+        buildV2Sign(fields, '192006250b4c09247ec02edce69f6a2d'),
21
+        '9A0A8659F005D6984697E2CA0A9CF3B7'
22
+    );
23
+});
24
+
25
+test('APIv2 验签兼容旧 XML 解析器产生的单元素数组', () => {
26
+    const fields = {
27
+        appid: ['wx-test'],
28
+        mch_id: ['123456'],
29
+        nonce_str: ['nonce-test'],
30
+        result_code: ['SUCCESS']
31
+    };
32
+    fields.sign = [buildV2Sign(fields, 'test-key')];
33
+    assert.equal(verifyV2Sign(fields, 'test-key'), true);
34
+    assert.deepEqual(normalizeV2Fields(fields), {
35
+        appid: 'wx-test',
36
+        mch_id: '123456',
37
+        nonce_str: 'nonce-test',
38
+        result_code: 'SUCCESS',
39
+        sign: fields.sign[0]
40
+    });
41
+});
42
+
43
+test('APIv2 XML 可安全保留中文与特殊字符', () => {
44
+    const original = {
45
+        return_code: 'SUCCESS',
46
+        return_msg: '秒过 & 新手包 <1元>',
47
+        detail: '包含 ]]> 的内容'
48
+    };
49
+    assert.deepEqual(parseV2Xml(buildV2Xml(original)), original);
50
+});
51
+
52
+test('被修改的 APIv2 字段无法通过验签', () => {
53
+    const fields = { appid: 'wx-test', total_fee: '100', nonce_str: 'nonce-test' };
54
+    fields.sign = buildV2Sign(fields, 'test-key');
55
+    fields.total_fee = '1';
56
+    assert.equal(verifyV2Sign(fields, 'test-key'), false);
57
+});

+ 284 - 0
test/wechatServicePay.test.js

@@ -0,0 +1,284 @@
1
+import test from 'node:test';
2
+import assert from 'node:assert/strict';
3
+import fs from 'node:fs';
4
+import vm from 'node:vm';
5
+import config from '../src/config/index.js';
6
+import {
7
+    fulfillWechatServiceOrder,
8
+    MiaoguoWechatServicePayLogin500,
9
+    MiaoguoWechatServicePayNotify500,
10
+    MiaoguoWechatServicePayOrderStatus500
11
+} from '../src/api/wechatServicePay/wechatServicePayController.js';
12
+import { buildV2Sign, buildV2Xml, parseV2Xml } from '../src/api/wechatServicePay/wechatPayV2.js';
13
+
14
+function signed(fields) {
15
+    const result = { ...fields };
16
+    result.sign = buildV2Sign(result, config.wx.payapisecret);
17
+    return result;
18
+}
19
+
20
+function createConnection({ order, serviceUser, miaoguoUsers = [] }) {
21
+    const state = {
22
+        began: 0,
23
+        committed: 0,
24
+        rolledBack: 0,
25
+        released: 0,
26
+        queries: []
27
+    };
28
+    const connection = {
29
+        async beginTransaction() { state.began += 1; },
30
+        async commit() { state.committed += 1; },
31
+        async rollback() { state.rolledBack += 1; },
32
+        release() { state.released += 1; },
33
+        async query(sql, params) {
34
+            state.queries.push({ sql, params });
35
+            if (sql.includes('FROM ProductPayInfo')) return [[order]];
36
+            if (sql.includes('FROM WechatServiceWXUsers')) return [[serviceUser]];
37
+            if (sql.includes('FROM MiaoguoWXUsers')) return [miaoguoUsers];
38
+            return [{ affectedRows: 1 }];
39
+        }
40
+    };
41
+    return { connection, state };
42
+}
43
+
44
+function pendingOrder(overrides = {}) {
45
+    return {
46
+        ID: 501,
47
+        TradeNo: 'WSP178784640012345678',
48
+        ProductID: 167,
49
+        PayType: 8,
50
+        BuyType: 131,
51
+        OpenID: 'service-openid',
52
+        Money: 100,
53
+        Status: 0,
54
+        ...overrides
55
+    };
56
+}
57
+
58
+const serviceUser = {
59
+    UserID: 88,
60
+    OpenID: 'service-openid',
61
+    UnionID: 'union-test'
62
+};
63
+
64
+const paidResult = {
65
+    openid: 'service-openid',
66
+    totalFee: 100,
67
+    transactionId: '420000000020260828000000001',
68
+    timeEnd: '20260828120000',
69
+    raw: { source: 'test' }
70
+};
71
+
72
+test('服务号支付完成在一个事务中写完整订单并发放 16 天试用', async () => {
73
+    const { connection, state } = createConnection({
74
+        order: pendingOrder(),
75
+        serviceUser,
76
+        miaoguoUsers: [{ UserID: 1, UnionID: 'union-test' }]
77
+    });
78
+    const postActions = [];
79
+
80
+    const result = await fulfillWechatServiceOrder(pendingOrder().TradeNo, paidResult, {
81
+        getConnection: async () => connection,
82
+        schedulePostCommit: callback => callback(),
83
+        runPostPayActions: async payload => { postActions.push(payload); }
84
+    });
85
+    await new Promise(resolve => setImmediate(resolve));
86
+
87
+    assert.equal(result.alreadyFulfilled, false);
88
+    assert.equal(result.serviceUserID, 88);
89
+    assert.deepEqual(result.miaoguoUserIDs, [1]);
90
+    assert.equal(result.trialEnd, '2026-09-13 12:00:00');
91
+    assert.equal(state.committed, 1);
92
+    assert.equal(state.rolledBack, 0);
93
+    assert.equal(state.released, 1);
94
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE WechatServiceWXUsers SET IsProbation=1')));
95
+    assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoWXUsers SET ProductServiceTime=')));
96
+    const payUpdate = state.queries.find(item => item.sql.includes('UPDATE ProductPayInfo'));
97
+    assert.ok(payUpdate);
98
+    assert.match(payUpdate.sql, /Status=1/);
99
+    assert.equal(payUpdate.params[2], '2026-09-13 12:00:00');
100
+    assert.equal(payUpdate.params[3], 88);
101
+    assert.equal(payUpdate.params[4], 501);
102
+    assert.equal(postActions.length, 1);
103
+});
104
+
105
+test('重复支付通知不会重复延长试用权益', async () => {
106
+    const { connection, state } = createConnection({
107
+        order: pendingOrder({ Status: 1 }),
108
+        serviceUser
109
+    });
110
+    const result = await fulfillWechatServiceOrder(pendingOrder().TradeNo, paidResult, {
111
+        getConnection: async () => connection,
112
+        runPostPayActions: async () => assert.fail('重复通知不应触发支付后动作')
113
+    });
114
+
115
+    assert.equal(result.alreadyFulfilled, true);
116
+    assert.equal(state.committed, 1);
117
+    assert.equal(state.queries.some(item => item.sql.includes('UPDATE ')), false);
118
+});
119
+
120
+test('微信实付金额不等于 100 分时回滚且不发权益', async () => {
121
+    const { connection, state } = createConnection({ order: pendingOrder(), serviceUser });
122
+    await assert.rejects(
123
+        fulfillWechatServiceOrder(pendingOrder().TradeNo, { ...paidResult, totalFee: 1 }, {
124
+            getConnection: async () => connection
125
+        }),
126
+        /金额与本地订单不一致/
127
+    );
128
+    assert.equal(state.committed, 0);
129
+    assert.equal(state.rolledBack, 1);
130
+    assert.equal(state.queries.some(item => item.sql.includes('UPDATE ')), false);
131
+});
132
+
133
+test('下单忽略客户端 money,统一下单与数据库都固定为 100 分', async () => {
134
+    const databaseCalls = [];
135
+    const httpClient = {
136
+        async get() {
137
+            return { data: { openid: 'service-openid' } };
138
+        },
139
+        async post(_url, xml) {
140
+            const request = parseV2Xml(xml);
141
+            assert.equal(request.total_fee, '100');
142
+            assert.equal(request.trade_type, 'JSAPI');
143
+            assert.equal(request.openid, 'service-openid');
144
+            return {
145
+                data: buildV2Xml(signed({
146
+                    return_code: 'SUCCESS',
147
+                    result_code: 'SUCCESS',
148
+                    appid: String(config.wx.wechatservice_appid),
149
+                    mch_id: String(config.wx.mch_id),
150
+                    nonce_str: 'wechat-response-nonce',
151
+                    trade_type: 'JSAPI',
152
+                    prepay_id: 'wx-prepay-test'
153
+                }))
154
+            };
155
+        }
156
+    };
157
+    const ctx = {
158
+        req: { socket: { remoteAddress: '127.0.0.1' } },
159
+        request: {
160
+            headers: { 'x-forwarded-for': '203.0.113.8' },
161
+            body: { code: 'oauth-code', money: '0.01' }
162
+        }
163
+    };
164
+
165
+    await MiaoguoWechatServicePayLogin500(ctx, {
166
+        httpClient,
167
+        query: async (sql, params) => {
168
+            databaseCalls.push({ sql, params });
169
+            if (sql.includes('FROM WechatServiceWXUsers'))
170
+                return [{ UserID: 88, OpenID: 'service-openid', Subscribe: 1, IsProbation: 0 }];
171
+            return { insertId: 501 };
172
+        }
173
+    });
174
+
175
+    assert.equal(ctx.body.errcode, 10000);
176
+    assert.equal(ctx.body.result.package, 'prepay_id=wx-prepay-test');
177
+    assert.match(ctx.body.result.timeStamp, /^\d{10}$/);
178
+    const insert = databaseCalls.find(item => item.sql.includes('INSERT INTO ProductPayInfo'));
179
+    assert.ok(insert);
180
+    assert.equal(insert.params[1], 8);
181
+    assert.equal(insert.params[2], 131);
182
+    assert.equal(insert.params[7], 100);
183
+    assert.equal(insert.params[9], 167);
184
+});
185
+
186
+test('合法签名的微信通知进入统一完成函数并返回 SUCCESS', async () => {
187
+    const calls = [];
188
+    const fields = signed({
189
+        return_code: 'SUCCESS',
190
+        result_code: 'SUCCESS',
191
+        appid: String(config.wx.wechatservice_appid),
192
+        mch_id: String(config.wx.mch_id),
193
+        nonce_str: 'notify-nonce',
194
+        out_trade_no: pendingOrder().TradeNo,
195
+        openid: 'service-openid',
196
+        total_fee: '100',
197
+        fee_type: 'CNY',
198
+        trade_type: 'JSAPI',
199
+        transaction_id: paidResult.transactionId,
200
+        time_end: paidResult.timeEnd
201
+    });
202
+    const ctx = { request: { body: { xml: {}, rawBody: buildV2Xml(fields) } } };
203
+
204
+    await MiaoguoWechatServicePayNotify500(ctx, {
205
+        fulfillOrder: async (...args) => { calls.push(args); return { alreadyFulfilled: false }; }
206
+    });
207
+
208
+    assert.equal(calls.length, 1);
209
+    assert.equal(calls[0][0], pendingOrder().TradeNo);
210
+    assert.equal(calls[0][1].totalFee, 100);
211
+    assert.equal(parseV2Xml(ctx.body).return_code, 'SUCCESS');
212
+});
213
+
214
+test('伪造或被篡改的微信通知不会发放权益', async () => {
215
+    let fulfilled = false;
216
+    const fields = signed({
217
+        return_code: 'SUCCESS',
218
+        result_code: 'SUCCESS',
219
+        appid: String(config.wx.wechatservice_appid),
220
+        mch_id: String(config.wx.mch_id),
221
+        nonce_str: 'notify-nonce',
222
+        out_trade_no: pendingOrder().TradeNo,
223
+        openid: 'service-openid',
224
+        total_fee: '100',
225
+        transaction_id: paidResult.transactionId
226
+    });
227
+    fields.total_fee = '1';
228
+    const ctx = { request: { body: { xml: fields } } };
229
+
230
+    await MiaoguoWechatServicePayNotify500(ctx, {
231
+        fulfillOrder: async () => { fulfilled = true; },
232
+        logger: { error() {} }
233
+    });
234
+
235
+    assert.equal(fulfilled, false);
236
+    assert.equal(parseV2Xml(ctx.body).return_code, 'FAIL');
237
+});
238
+
239
+test('主动查单确认 SUCCESS 后补走统一完成函数', async () => {
240
+    const order = pendingOrder();
241
+    const fulfillCalls = [];
242
+    const response = signed({
243
+        return_code: 'SUCCESS',
244
+        result_code: 'SUCCESS',
245
+        appid: String(config.wx.wechatservice_appid),
246
+        mch_id: String(config.wx.mch_id),
247
+        nonce_str: 'query-response-nonce',
248
+        out_trade_no: order.TradeNo,
249
+        openid: order.OpenID,
250
+        total_fee: '100',
251
+        trade_type: 'JSAPI',
252
+        trade_state: 'SUCCESS',
253
+        transaction_id: paidResult.transactionId,
254
+        time_end: paidResult.timeEnd
255
+    });
256
+    const ctx = { request: { body: { TradeNo: order.TradeNo } } };
257
+
258
+    await MiaoguoWechatServicePayOrderStatus500(ctx, {
259
+        query: async () => [order],
260
+        httpClient: { async post() { return { data: buildV2Xml(response) }; } },
261
+        fulfillOrder: async (...args) => {
262
+            fulfillCalls.push(args);
263
+            return { alreadyFulfilled: false, trialEnd: '2026-09-13 12:00:00' };
264
+        }
265
+    });
266
+
267
+    assert.equal(ctx.body.errcode, 10000);
268
+    assert.equal(ctx.body.result.Status, 1);
269
+    assert.equal(fulfillCalls.length, 1);
270
+    assert.equal(fulfillCalls[0][1].totalFee, 100);
271
+});
272
+
273
+test('网页不再提交客户端金额,并在前端回调后主动查单', () => {
274
+    const html = fs.readFileSync(new URL('../public/wcs/pay.html', import.meta.url), 'utf8');
275
+    const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(match => match[1]);
276
+    assert.equal(html.includes('jquery-1.6.4'), false);
277
+    assert.equal(html.includes('param.money'), false);
278
+    assert.equal(html.includes('http://miaguo-1253256735'), false);
279
+    assert.match(html, /const createPayEndpoint = '\[支付链接\]'/);
280
+    assert.match(html, /const orderStatusEndpoint = '\[查询链接\]'/);
281
+    assert.match(html, /waitForPaidOrder\(payParameters\.TradeNo/);
282
+    assert.match(html, /getBrandWCPayRequest/);
283
+    for (const script of scripts) new vm.Script(script);
284
+});