trustbase-seller-backend 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payments/wechat/profitsharing.js +57 -3
- package/routes/agent-pay.js +99 -13
- package/routes/order.js +3 -18
- package/routes/sp-applyment.js +9 -3
- package/routes/sp-commission.js +16 -1
- package/routes/sp-notify.js +86 -4
- package/test-agent-pay-lifecycle.js +186 -0
- package/test-agent-pay-wechat.js +31 -0
- package/test-sp-applyment.js +40 -5
- package/test-sp-gateway.js +74 -0
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 且 ≤ 交易金额本身; 护栏在 mock 模式同样生效 (先于一切网络调用)。
|
|
15
15
|
*/
|
|
16
16
|
const agentPay = require("../../routes/agent-pay");
|
|
17
|
+
const crypto = require("crypto");
|
|
17
18
|
|
|
18
19
|
const MAX_SHARE_BP = 3000; // 单笔分账 ≤ 交易金额 30% (硬顶)
|
|
19
20
|
|
|
@@ -51,6 +52,8 @@ async function wxCall(method, urlPath, bodyObj) {
|
|
|
51
52
|
"Accept": "application/json",
|
|
52
53
|
// 微信 APIv3 拒 undici 默认 Accept-Language: * (2026-09-25 实测 PARAM_ERROR) — 与 agent-pay wxApiCall 对齐
|
|
53
54
|
"Accept-Language": "zh-CN",
|
|
55
|
+
// 分账等接口要求微信支付公钥 ID/平台证书序列号头 (2026-09-26 实测 PARAM_ERROR); SP 模式用 SP 公钥 ID
|
|
56
|
+
"Wechatpay-Serial": process.env.WXPAY_SP_PUBLIC_KEY_ID || process.env.WXPAY_PUBLIC_KEY_ID || c.spSerial || c.serial || "",
|
|
54
57
|
"User-Agent": "trustbase-seller-backend/1.0",
|
|
55
58
|
},
|
|
56
59
|
body: method === "GET" ? undefined : bodyStr,
|
|
@@ -78,7 +81,8 @@ function assertShareAmount(tradeAmount, shareAmount) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** 添加分账接收方 (服务商自己 mchid 或指定商户号)
|
|
81
|
-
* receivers: [{account, type?, name?, relation_type?, sub_mchid?}]
|
|
84
|
+
* receivers: [{account, type?, name?, relation_type?, sub_mchid?}]
|
|
85
|
+
* 2026-09-26 实测: name 是敏感字段, 真调必须微信支付公钥 RSA-OAEP 加密 (否则 PARAM_ERROR "是否为加密后的密文") */
|
|
82
86
|
async function addReceivers(receivers) {
|
|
83
87
|
const c = wxCfg();
|
|
84
88
|
const list = Array.isArray(receivers) ? receivers : [receivers];
|
|
@@ -92,7 +96,7 @@ async function addReceivers(receivers) {
|
|
|
92
96
|
account: String(rcv.account),
|
|
93
97
|
relation_type: rcv.relation_type || "SERVICE_PROVIDER",
|
|
94
98
|
};
|
|
95
|
-
if (rcv.name) body.name = String(rcv.name); // MERCHANT_ID 真调时微信要求 name
|
|
99
|
+
if (rcv.name) body.name = c.enabled ? encryptReceiverName(String(rcv.name)) : String(rcv.name); // MERCHANT_ID 真调时微信要求 name (加密)
|
|
96
100
|
if (c.spMchid) {
|
|
97
101
|
body.sp_mchid = c.spMchid;
|
|
98
102
|
if (rcv.sub_mchid) body.sub_mchid = String(rcv.sub_mchid);
|
|
@@ -103,6 +107,17 @@ async function addReceivers(receivers) {
|
|
|
103
107
|
return results.length === 1 ? results[0] : results;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/** 接收方名称加密: 微信支付公钥 RSA-OAEP-SHA256 → base64 (与进件敏感字段同口径) */
|
|
111
|
+
function encryptReceiverName(name) {
|
|
112
|
+
const p = process.env.WXPAY_SP_PUBLIC_KEY_PATH || process.env.WXPAY_PUBLIC_KEY_PATH;
|
|
113
|
+
if (!p) throw new Error("WXPAY_(SP_)PUBLIC_KEY_PATH 未配置, 接收方名称无法加密");
|
|
114
|
+
const pem = require("fs").readFileSync(p, "utf8");
|
|
115
|
+
return crypto.publicEncrypt(
|
|
116
|
+
{ key: pem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
|
|
117
|
+
Buffer.from(name, "utf8")
|
|
118
|
+
).toString("base64");
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
/** 请求分账
|
|
107
122
|
* params: {outTradeNo(分账单号 out_order_no, 幂等键), transactionId(微信支付单号),
|
|
108
123
|
* tradeAmount(交易金额分, 护栏基准), subMchid?, unfreezeUnsplit?,
|
|
@@ -147,4 +162,43 @@ async function queryProfitsharing(outOrderNo, opts) {
|
|
|
147
162
|
return r.mock ? { mock: true, out_order_no: String(outOrderNo) } : r;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
|
|
165
|
+
/** M3-1: 分账接收方注册台账 (幂等标记; 已注册不重复调微信 — 重复调会报 "接收方已存在" 类错误) */
|
|
166
|
+
function ensurePsReceiverTable(db) {
|
|
167
|
+
db.exec(`
|
|
168
|
+
CREATE TABLE IF NOT EXISTS sp_ps_receivers (
|
|
169
|
+
account TEXT PRIMARY KEY, -- 接收方商户号 (SP 自身 mchid)
|
|
170
|
+
registered_at INTEGER NOT NULL,
|
|
171
|
+
mock INTEGER NOT NULL DEFAULT 0
|
|
172
|
+
);
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** M3-1: 注册默认分账接收方 (SP 自身 mchid, type MERCHANT_ID)。
|
|
177
|
+
* 调用时机: SP 服务启动 (createSpNotifyRouter 挂载) + 进件 FINISHED 钩子 (sp-applyment)。
|
|
178
|
+
* 幂等: 台账有记录 → {registered:false, reason:'already'} 不调微信; mock 模式同样落台账。 */
|
|
179
|
+
async function registerDefaultReceivers(db) {
|
|
180
|
+
ensurePsReceiverTable(db);
|
|
181
|
+
const c = wxCfg();
|
|
182
|
+
const account = c.spMchid || c.mchid;
|
|
183
|
+
if (!account) {
|
|
184
|
+
console.warn("[profitsharing] 未配 WXPAY_SP_MCHID/WXPAY_MCHID, 跳过接收方注册");
|
|
185
|
+
return { registered: false, reason: "no_mchid" };
|
|
186
|
+
}
|
|
187
|
+
const row = db.prepare("SELECT account, mock FROM sp_ps_receivers WHERE account = ?").get(account);
|
|
188
|
+
// mock 落行不挡真调: mock=1 且当前真调模式 → 重新真调注册 (2026-09-26 实测: mock 期落的行把真调永久挡死)
|
|
189
|
+
const realMode = /^(1|true|yes|on)$/i.test(process.env.WXPAY_PROFITSHARING_ENABLED || "");
|
|
190
|
+
if (row && !(row.mock && realMode)) return { registered: false, reason: "already" };
|
|
191
|
+
const r = await addReceivers([{ account, type: "MERCHANT_ID", name: process.env.SP_NAME || "服务商" }]);
|
|
192
|
+
const one = Array.isArray(r) ? r[0] : r;
|
|
193
|
+
const ok = !!(one && (one.mock || one.status === 200));
|
|
194
|
+
if (ok) {
|
|
195
|
+
db.prepare("INSERT INTO sp_ps_receivers (account, registered_at, mock) VALUES (?,?,?) ON CONFLICT(account) DO UPDATE SET registered_at=excluded.registered_at, mock=excluded.mock")
|
|
196
|
+
.run(account, Math.floor(Date.now() / 1000), one.mock ? 1 : 0);
|
|
197
|
+
console.log(`[profitsharing] 分账接收方已注册 account=${account} mock=${!!one.mock}`);
|
|
198
|
+
return { registered: true, mock: !!one.mock };
|
|
199
|
+
}
|
|
200
|
+
console.warn(`[profitsharing] 接收方注册未成功 account=${account}: ${JSON.stringify(one && one.json || one).slice(0, 200)}`);
|
|
201
|
+
return { registered: false, reason: "wx_error", detail: one && one.json };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { addReceivers, requestProfitsharing, queryProfitsharing, assertShareAmount, MAX_SHARE_BP, registerDefaultReceivers, ensurePsReceiverTable };
|
package/routes/agent-pay.js
CHANGED
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
* 职责:
|
|
6
6
|
* POST /api/agent/pay/session E1 支付会话 (幂等/服务端报价/软持有/双token/PSP下单)
|
|
7
7
|
* POST /api/agent/pay/notify/wechat E2 微信回调 (APIv3 验签+AES-GCM 解密+查重+金额核对+落账链)
|
|
8
|
+
* M4: TRANSACTION.REFUND 退款事件 → REFUNDED
|
|
8
9
|
* POST /api/agent/pay/mock-pay mock 沙箱 (仅 AGENT_PAY_MODE=mock, 模拟PSP回调)
|
|
9
|
-
* GET /api/agent/order/status 状态查询 (session权威 +
|
|
10
|
+
* GET /api/agent/order/status 状态查询 (session权威 + 链事实镜像; M4: DISPUTED/REFUNDED 不再误映射 PAID, rail 真实值)
|
|
11
|
+
* POST /api/agent/order/confirm M4 买家确认收货 (DELIVERED→CONFIRMED, status_token 即买家凭证)
|
|
12
|
+
* POST /api/agent/internal/logistics M4 物流推送 (HMAC 共享密钥头 x-logistics-key 鉴权, env LOGISTICS_HMAC_KEY;
|
|
13
|
+
* 状态机单向 PAID/ON_CHAIN→SHIPPED→DELIVERED, timeline 追加不覆盖)
|
|
14
|
+
* M4 状态机: ON_CHAIN 由 markOrderOnChain() 回写 (链上建单需买家 tct1, 匿名法币单留 PAID 不强上链);
|
|
15
|
+
* status_token 丢失暂无法自助找回 (🔵 后续), 需人工。
|
|
10
16
|
* 对账轮询 E3: reconcileSweep() 由外层定时调用
|
|
11
17
|
*
|
|
12
18
|
* 设计依据: prd-docs/开发文档/trustbase-agent-commerce-协议方案-2026-09-20.md
|
|
@@ -384,7 +390,9 @@ function createAgentPayRouter(deps) {
|
|
|
384
390
|
const acceptExpired = prevState === "EXPIRED" && (Date.now() / 1000) < sessionRow.expires_at + GRACE_AFTER_EXPIRY_MS / 1000;
|
|
385
391
|
db.prepare("UPDATE agent_pay_sessions SET state='PAID', psp_txn_id=?, updated_at=? WHERE id=?").run(txnId, now, sessionRow.id);
|
|
386
392
|
console.log(`[agent-pay] PAID ${sessionRow.order_ref} txn=${txnId} rail=${rail}${acceptExpired ? " (expired+grace accepted)" : ""}`);
|
|
387
|
-
//
|
|
393
|
+
// M4: PAID → ON_CHAIN 占位 — 链上建单需买家 tct1 地址, 匿名法币单不强上链
|
|
394
|
+
// (链上订单是增强不是阻塞)。当前会话不存买家地址 (agent_addr 是归因哈希),
|
|
395
|
+
// 链上建单接线后由 markOrderOnChain(db, orderRef, chainOrderId) 回写 state/chain_order_id。
|
|
388
396
|
return { ok: true, state: "PAID", grace: acceptExpired };
|
|
389
397
|
}
|
|
390
398
|
|
|
@@ -427,6 +435,21 @@ function createAgentPayRouter(deps) {
|
|
|
427
435
|
return res.status(400).json({ code: "FAIL", message: "no resource" });
|
|
428
436
|
}
|
|
429
437
|
const plain = wxDecryptNotifyResource(resource);
|
|
438
|
+
// M4: 退款事件 (TRANSACTION.REFUND) — resource 是退款单 (refund_status), 不是支付单
|
|
439
|
+
if (body.event_type === "TRANSACTION.REFUND") {
|
|
440
|
+
const outRef = plain.out_trade_no;
|
|
441
|
+
const rRow = outRef && db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(outRef);
|
|
442
|
+
if (!rRow) return res.status(404).json({ code: "FAIL", message: "NOT_FOUND" });
|
|
443
|
+
if (plain.refund_status === "SUCCESS") {
|
|
444
|
+
if (rRow.state !== "REFUNDED") {
|
|
445
|
+
db.prepare("UPDATE agent_pay_sessions SET state='REFUNDED', updated_at=? WHERE id=?").run(nowSec(), rRow.id);
|
|
446
|
+
console.log(`[agent-pay] REFUNDED ${outRef} refund_id=${plain.refund_id || ""}`);
|
|
447
|
+
}
|
|
448
|
+
return res.status(200).json({ code: "SUCCESS", refunded: true });
|
|
449
|
+
}
|
|
450
|
+
// 退款处理中/异常: 确认收到不改状态
|
|
451
|
+
return res.status(200).json({ code: "SUCCESS", ignored: plain.refund_status || "REFUND_PROCESSING" });
|
|
452
|
+
}
|
|
430
453
|
const out = plain.out_trade_no;
|
|
431
454
|
const row = out && db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(out);
|
|
432
455
|
if (!row) return res.status(404).json({ code: "FAIL", message: "NOT_FOUND" });
|
|
@@ -454,7 +477,9 @@ function createAgentPayRouter(deps) {
|
|
|
454
477
|
res.json(r.error ? r : { ok: true, state: r.state, grace: !!r.grace });
|
|
455
478
|
});
|
|
456
479
|
|
|
457
|
-
/** 状态查询 (E4 后端部分: session 权威 + 链事实镜像)
|
|
480
|
+
/** 状态查询 (E4 后端部分: session 权威 + 链事实镜像)
|
|
481
|
+
* M4 修正: payment.state 不再把 DISPUTED/REFUNDED 误映射成 PAID; rail 取真实支付轨
|
|
482
|
+
* (psp_txns 最新一条的 rail, 未支付回落 MODE)。 */
|
|
458
483
|
router.get("/order/status", (req, res) => {
|
|
459
484
|
const q = req.query || {};
|
|
460
485
|
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(q.order_ref);
|
|
@@ -463,10 +488,16 @@ function createAgentPayRouter(deps) {
|
|
|
463
488
|
const chain = row.chain_order_id
|
|
464
489
|
? db.prepare("SELECT txhash FROM chain_write_log WHERE order_id = ?").get(row.chain_order_id)
|
|
465
490
|
: null;
|
|
491
|
+
// payment.state 映射: 已支付族 → PAID; 其余原样 (UNPAID/DISPUTED/REFUNDED/CLOSED)
|
|
492
|
+
const PAID_STATES = ["PAID", "ON_CHAIN", "SHIPPED", "DELIVERED", "CONFIRMED"];
|
|
493
|
+
const payState = PAID_STATES.includes(row.state) ? "PAID"
|
|
494
|
+
: (row.state === "CREATED" || row.state === "EXPIRED" ? "UNPAID" : row.state);
|
|
495
|
+
// rail 真实值: psp_txns 最新一条; 未支付 → 当前 MODE
|
|
496
|
+
const txn = db.prepare("SELECT rail FROM psp_txns WHERE session_id = ? ORDER BY created_at DESC LIMIT 1").get(row.id);
|
|
466
497
|
res.json({
|
|
467
498
|
order_ref: row.order_ref, chain_order_id: row.chain_order_id || null,
|
|
468
499
|
state: row.state,
|
|
469
|
-
payment: { state:
|
|
500
|
+
payment: { state: payState, rail: txn ? txn.rail : MODE, txn_id: row.psp_txn_id || null },
|
|
470
501
|
amount: { currency: row.currency, total: row.amount_total },
|
|
471
502
|
agent_addr: row.agent_addr || null,
|
|
472
503
|
chain_tx: chain ? chain.txhash : null,
|
|
@@ -481,28 +512,72 @@ function createAgentPayRouter(deps) {
|
|
|
481
512
|
res.json({ ok: !r.error, ...r });
|
|
482
513
|
});
|
|
483
514
|
|
|
484
|
-
/** E4': 物流推送接收 (ShopXO 发货 hook →
|
|
515
|
+
/** E4': 物流推送接收 (ShopXO 发货 hook → 这里)
|
|
516
|
+
* M4 (2026-10-03): ① 鉴权修复 — env LOGISTICS_HMAC_KEY 共享密钥头 (x-logistics-key)
|
|
517
|
+
* 比对, 未配置 env 全拒 (PoC 裸奔是审查 🔴); ② 状态机单向 — PAID/ON_CHAIN→SHIPPED,
|
|
518
|
+
* SHIPPED→SHIPPED(更新)/DELIVERED, DELIVERED→DELIVERED(幂等), 回退 409;
|
|
519
|
+
* ③ timeline 追加不覆盖。 */
|
|
520
|
+
function logisticsAuthed(req) {
|
|
521
|
+
const key = process.env.LOGISTICS_HMAC_KEY || "";
|
|
522
|
+
if (!key) return false; // 未配置 = 全拒 (宁可全拒不留后门)
|
|
523
|
+
const got = String(req.headers["x-logistics-key"] || "");
|
|
524
|
+
if (!got || got.length !== key.length) return false;
|
|
525
|
+
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(key));
|
|
526
|
+
}
|
|
485
527
|
router.post("/internal/logistics", (req, res) => {
|
|
486
528
|
try {
|
|
529
|
+
if (!logisticsAuthed(req)) {
|
|
530
|
+
return err(res, 401, "LOGISTICS_UNAUTHORIZED", { hint: "x-logistics-key 头缺失或错误 (env LOGISTICS_HMAC_KEY)" });
|
|
531
|
+
}
|
|
487
532
|
const b = req.body || {};
|
|
488
533
|
if (!b.order_ref) return err(res, 400, "order_ref 必填 (E6 后自动写入 ShopXO 订单备注, 当前由插件穿参)");
|
|
489
534
|
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(b.order_ref);
|
|
490
535
|
if (!row) return err(res, 404, "NOT_FOUND");
|
|
491
536
|
const now = nowSec();
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
537
|
+
const cur = row.state;
|
|
538
|
+
const target = String(b.logistics_state || "SHIPPED") === "DELIVERED" ? "DELIVERED" : "SHIPPED";
|
|
539
|
+
// 单向状态机 (回退拒):
|
|
540
|
+
const allowed =
|
|
541
|
+
((cur === "PAID" || cur === "ON_CHAIN") && target === "SHIPPED") ||
|
|
542
|
+
(cur === "SHIPPED" && (target === "SHIPPED" || target === "DELIVERED")) ||
|
|
543
|
+
(cur === "DELIVERED" && target === "DELIVERED");
|
|
544
|
+
if (!allowed) {
|
|
545
|
+
return err(res, 409, "STATE_INVALID", { current: cur, target, hint: "状态机单向: PAID/ON_CHAIN→SHIPPED→DELIVERED, 不允许回退" });
|
|
495
546
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
547
|
+
// timeline 追加不覆盖 (M4 修复: 旧实现整体覆盖丢历史)
|
|
548
|
+
const timeline = JSON.parse(row.timeline || "[]").concat(Array.isArray(b.timeline) ? b.timeline : []);
|
|
549
|
+
db.prepare("UPDATE agent_pay_sessions SET state=?, carrier=?, tracking_no=?, logistics_state=?, timeline=?, shopxo_order_no_ref=?, updated_at=? WHERE id=?")
|
|
550
|
+
.run(target, String(b.carrier || row.carrier || ""), String(b.tracking_no || row.tracking_no || ""),
|
|
551
|
+
target, JSON.stringify(timeline), String(b.shopxo_order_no || row.shopxo_order_no_ref || ""), now, row.id);
|
|
552
|
+
console.log(`[agent-pay] ${target} ${b.order_ref} carrier=${b.carrier} tracking=${b.tracking_no}`);
|
|
553
|
+
res.json({ ok: true, state: target });
|
|
500
554
|
} catch (e) {
|
|
501
555
|
console.error("[agent-pay] logistics error:", e && e.message);
|
|
502
556
|
err(res, 500, "internal");
|
|
503
557
|
}
|
|
504
558
|
});
|
|
505
559
|
|
|
560
|
+
/** M4: 买家确认收货 (DELIVERED → CONFIRMED) — status_token 即买家凭证 (买家在卖家后端
|
|
561
|
+
* 无账号体系, status_token 是签收时签发的唯一凭证; 丢失暂无法自助找回, 需人工 (🔵 后续)) */
|
|
562
|
+
router.post("/order/confirm", (req, res) => {
|
|
563
|
+
try {
|
|
564
|
+
const b = req.body || {};
|
|
565
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(b.order_ref);
|
|
566
|
+
if (!row) return err(res, 404, "NOT_FOUND");
|
|
567
|
+
if (sha256(String(b.token || "")) !== row.status_token_hash) return err(res, 401, "TOKEN_INVALID");
|
|
568
|
+
if (row.state === "CONFIRMED") return res.json({ ok: true, state: "CONFIRMED", idempotent: true });
|
|
569
|
+
if (row.state !== "DELIVERED") {
|
|
570
|
+
return err(res, 409, "STATE_INVALID", { current: row.state, expect: "DELIVERED", hint: "买家确认收货仅允许 DELIVERED → CONFIRMED" });
|
|
571
|
+
}
|
|
572
|
+
db.prepare("UPDATE agent_pay_sessions SET state='CONFIRMED', updated_at=? WHERE id=?").run(nowSec(), row.id);
|
|
573
|
+
console.log(`[agent-pay] CONFIRMED ${row.order_ref} (买家确认收货)`);
|
|
574
|
+
res.json({ ok: true, state: "CONFIRMED" });
|
|
575
|
+
} catch (e) {
|
|
576
|
+
console.error("[agent-pay] confirm error:", e && e.message);
|
|
577
|
+
err(res, 500, "internal");
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
506
581
|
/** E-ONBOARD: 商家入驻提交 */
|
|
507
582
|
router.post("/onboard/submit", (req, res) => {
|
|
508
583
|
try {
|
|
@@ -563,4 +638,15 @@ function startReconcile(db) {
|
|
|
563
638
|
return timer;
|
|
564
639
|
}
|
|
565
640
|
|
|
566
|
-
|
|
641
|
+
/** M4: PAID → ON_CHAIN 回写 (链上建单成功后由后续批次调用; 仅 PAID 可转, 幂等键 chain_order_id) */
|
|
642
|
+
function markOrderOnChain(db, orderRef, chainOrderId) {
|
|
643
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
|
|
644
|
+
if (!row) return { ok: false, error: "NOT_FOUND" };
|
|
645
|
+
if (row.state === "ON_CHAIN") return { ok: true, state: "ON_CHAIN", idempotent: true };
|
|
646
|
+
if (row.state !== "PAID") return { ok: false, error: "STATE_INVALID", current: row.state };
|
|
647
|
+
db.prepare("UPDATE agent_pay_sessions SET state='ON_CHAIN', chain_order_id=?, updated_at=? WHERE id=?")
|
|
648
|
+
.run(String(chainOrderId), nowSec(), row.id);
|
|
649
|
+
return { ok: true, state: "ON_CHAIN" };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
module.exports = { createAgentPayRouter, startReconcile, runReconcileOnce, markOrderOnChain, wxSignAuth, wxAesGcmDecrypt, wxVerifyNotifySignature, wxDecryptNotifyResource, wxNativeOrder, wxQueryOrder, refreshPlatformCerts };
|
package/routes/order.js
CHANGED
|
@@ -219,18 +219,11 @@ function createOrderRoutes(authPlugin, deps) {
|
|
|
219
219
|
/**
|
|
220
220
|
* 获取订单详情
|
|
221
221
|
* GET /api/order/:orderId
|
|
222
|
+
* M4 (2026-10-03): 公开只读 — 纯 LCD 链上数据透传 (订单内容本就链上公开),
|
|
223
|
+
* 买家在卖家后端无凭证, 之前 401 是误伤。写操作 (confirm/cancel/dispute) 鉴权不变。
|
|
222
224
|
*/
|
|
223
225
|
router.get('/:orderId', async (req, res) => {
|
|
224
226
|
try {
|
|
225
|
-
const authHeader = req.headers.authorization;
|
|
226
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
227
|
-
return res.status(401).json({ error: 'Missing authorization header' });
|
|
228
|
-
}
|
|
229
|
-
const token = authHeader.substring(7);
|
|
230
|
-
const tokenData = await authPlugin.verifyToken(token);
|
|
231
|
-
if (!tokenData) {
|
|
232
|
-
return res.status(401).json({ error: 'Invalid token' });
|
|
233
|
-
}
|
|
234
227
|
const { orderId } = req.params;
|
|
235
228
|
const response = await fetch(`${lcdUrl}/oxiaom/trustchain/order/v1/orders/${orderId}`);
|
|
236
229
|
if (!response.ok) {
|
|
@@ -247,18 +240,10 @@ function createOrderRoutes(authPlugin, deps) {
|
|
|
247
240
|
/**
|
|
248
241
|
* 获取买家订单列表
|
|
249
242
|
* GET /api/order/buyer/:address
|
|
243
|
+
* M4: 公开只读 (链上公开数据透传, 无卖家敏感信息)
|
|
250
244
|
*/
|
|
251
245
|
router.get('/buyer/:address', async (req, res) => {
|
|
252
246
|
try {
|
|
253
|
-
const authHeader = req.headers.authorization;
|
|
254
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
255
|
-
return res.status(401).json({ error: 'Missing authorization header' });
|
|
256
|
-
}
|
|
257
|
-
const token = authHeader.substring(7);
|
|
258
|
-
const tokenData = await authPlugin.verifyToken(token);
|
|
259
|
-
if (!tokenData) {
|
|
260
|
-
return res.status(401).json({ error: 'Invalid token' });
|
|
261
|
-
}
|
|
262
247
|
const { address } = req.params;
|
|
263
248
|
const response = await fetch(`${lcdUrl}/oxiaom/trustchain/order/v1/buyers/${address}/orders`);
|
|
264
249
|
if (!response.ok) {
|
package/routes/sp-applyment.js
CHANGED
|
@@ -46,6 +46,8 @@ const crypto = require("crypto");
|
|
|
46
46
|
const fs = require("fs");
|
|
47
47
|
const agentPay = require("./agent-pay");
|
|
48
48
|
const chainWrites = require("./chain-writes");
|
|
49
|
+
const spCommission = require("./sp-commission");
|
|
50
|
+
const profitsharing = require("../payments/wechat/profitsharing");
|
|
49
51
|
|
|
50
52
|
const HttpError = chainWrites.HttpError;
|
|
51
53
|
|
|
@@ -630,10 +632,10 @@ function createSpApplymentRouter(deps) {
|
|
|
630
632
|
}
|
|
631
633
|
|
|
632
634
|
/** GET /info — 服务商公开名片 (2026-10-03 xiaomu: 卖家入驻页要显示服务商是谁)
|
|
633
|
-
* 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息
|
|
635
|
+
* 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息
|
|
636
|
+
* M3 🟡-1: 公示比例读 sp_commission_rates 默认行 (__default__) 优先, env 只作首次种子 */
|
|
634
637
|
router.get("/info", (req, res) => {
|
|
635
|
-
const
|
|
636
|
-
const bp = Number.isInteger(rateBp) && rateBp >= 0 ? rateBp : 100;
|
|
638
|
+
const bp = spCommission.getDefaultRateBp(db);
|
|
637
639
|
res.json({
|
|
638
640
|
name: process.env.SP_NAME || "TrustBase 官方服务商",
|
|
639
641
|
desc: process.env.SP_DESC || "",
|
|
@@ -719,12 +721,14 @@ function createSpApplymentRouter(deps) {
|
|
|
719
721
|
if (r.status !== 200 || !(r.json.applyment_id != null)) return wxError(res, r, "提交进件失败");
|
|
720
722
|
const applymentId = String(r.json.applyment_id);
|
|
721
723
|
// 台账快照: 敏感字段脱敏 (身份证/卡号只留后 4 位; already_encrypted 时已是密文, 脱敏无妨)
|
|
724
|
+
// M3 🟡-1: 进件时刻的公示抽佣比例快照进 subject_json (事后对账依据)
|
|
722
725
|
const snapshot = {
|
|
723
726
|
...f,
|
|
724
727
|
legal_id_number: maskTail(f.legal_id_number),
|
|
725
728
|
account_number: maskTail(f.account_number),
|
|
726
729
|
business_code: undefined,
|
|
727
730
|
already_encrypted: undefined,
|
|
731
|
+
commission_bp_at_apply: spCommission.getDefaultRateBp(db),
|
|
728
732
|
};
|
|
729
733
|
const now = nowSec();
|
|
730
734
|
// upsert: 驳回重提时更新已有行 (同 business_code 覆盖), 否则插新行
|
|
@@ -791,6 +795,8 @@ function createSpApplymentRouter(deps) {
|
|
|
791
795
|
ON CONFLICT(sub_mchid) DO UPDATE SET seller_address=excluded.seller_address, shop_name=excluded.shop_name`)
|
|
792
796
|
.run(String(j.sub_mchid), row.tb_address || "", String(snap.merchant_shortname || ""), now);
|
|
793
797
|
console.log(`[sp-applyment] FINISHED 钩子: sub_mchid=${j.sub_mchid} 已登记回调路由 seller=${(row.tb_address || "").slice(0, 14)}…`);
|
|
798
|
+
// M3-1: 进件通过时也确认分账接收方已注册 (启动时注册失败的兜底; 台账幂等)
|
|
799
|
+
await profitsharing.registerDefaultReceivers(db);
|
|
794
800
|
if (row.tb_address) {
|
|
795
801
|
const g = await notifyOracleGrant(deps, { tb_address: row.tb_address, license_number: snap.license_number });
|
|
796
802
|
if (g.ok) {
|
package/routes/sp-commission.js
CHANGED
|
@@ -35,6 +35,21 @@ CREATE TABLE IF NOT EXISTS sp_commission_rates (
|
|
|
35
35
|
/** rate_bp → 人性化百分比 ("1.50%") */
|
|
36
36
|
function ratePct(rateBp) { return (rateBp / 100).toFixed(2) + "%"; }
|
|
37
37
|
|
|
38
|
+
/** M3 🟡-1: 公示默认抽佣比例 (sub_mchid='__default__' 特殊行) — 读表优先,
|
|
39
|
+
* env SP_DEFAULT_COMMISSION_BP 只作首次种子 (表里没有时落一行, 之后 PUT 覆盖生效) */
|
|
40
|
+
const DEFAULT_ROW_KEY = "__default__";
|
|
41
|
+
function getDefaultRateBp(db) {
|
|
42
|
+
ensureCommissionTables(db);
|
|
43
|
+
const row = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(DEFAULT_ROW_KEY);
|
|
44
|
+
if (row) return row.rate_bp;
|
|
45
|
+
let seed = Number(process.env.SP_DEFAULT_COMMISSION_BP || 100);
|
|
46
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > MAX_RATE_BP) seed = 100;
|
|
47
|
+
const now = nowSec();
|
|
48
|
+
db.prepare("INSERT OR IGNORE INTO sp_commission_rates (sub_mchid, provider, rate_bp, updated_by, created_at, updated_at) VALUES (?,?,?,?,?,?)")
|
|
49
|
+
.run(DEFAULT_ROW_KEY, "", seed, "env-seed", now, now);
|
|
50
|
+
return seed;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
function createSpCommissionRouter(deps) {
|
|
39
54
|
const router = express.Router();
|
|
40
55
|
const { db } = deps;
|
|
@@ -101,4 +116,4 @@ function createSpCommissionRouter(deps) {
|
|
|
101
116
|
return router;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP };
|
|
119
|
+
module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP, getDefaultRateBp, DEFAULT_ROW_KEY };
|
package/routes/sp-notify.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* 6. 上链 MsgSubmitPaymentFact (失败 warn 不阻塞, chain_txhash 留空等 retryPending 补)
|
|
13
13
|
* 7. 转发商户 webhook (3s 超时, 结果记 forward_status)
|
|
14
14
|
* 8. 按登记的抽佣比例触发微信分账 (默认 mock, WXPAY_PROFITSHARING_ENABLED=true 才真调)
|
|
15
|
+
* 9. M3 (2026-10-03): 分账台账列 (ps_*) + 触发落行 pending→done/mock/failed + 5min 补扫
|
|
16
|
+
* (retryProfitsharingSweep 同步微信真实状态/同号重发) + 启动/进件时注册分账接收方
|
|
17
|
+
* (profitsharing.registerDefaultReceivers, 台账幂等)
|
|
15
18
|
*
|
|
16
19
|
* 与直连模式 (agent-pay E2) 的差异: 回调只推到服务商, 服务商验签解密后按 sub_mchid
|
|
17
20
|
* 分发 —— 上链提交支付事实 + 转发/落账给对应商户。
|
|
@@ -51,6 +54,16 @@ CREATE TABLE IF NOT EXISTS sp_sub_merchants (
|
|
|
51
54
|
created_at INTEGER NOT NULL
|
|
52
55
|
);
|
|
53
56
|
`);
|
|
57
|
+
// M3-2: 分账台账列 (老库兼容: PRAGMA 检查后 ALTER)
|
|
58
|
+
const cols = db.prepare("PRAGMA table_info(sp_payment_facts)").all().map((c) => c.name);
|
|
59
|
+
for (const [col, ddl] of [
|
|
60
|
+
["ps_out_order_no", "ALTER TABLE sp_payment_facts ADD COLUMN ps_out_order_no TEXT DEFAULT ''"],
|
|
61
|
+
["ps_status", "ALTER TABLE sp_payment_facts ADD COLUMN ps_status TEXT DEFAULT ''"], // ''/pending/mock/done/failed
|
|
62
|
+
["ps_amount", "ALTER TABLE sp_payment_facts ADD COLUMN ps_amount INTEGER"],
|
|
63
|
+
["ps_at", "ALTER TABLE sp_payment_facts ADD COLUMN ps_at INTEGER"],
|
|
64
|
+
]) {
|
|
65
|
+
if (!cols.includes(col)) db.exec(ddl);
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** 上链 MsgSubmitPaymentFact (真实通道; 异常上抛, 由调用方降级为 warn 不阻塞)
|
|
@@ -102,12 +115,73 @@ async function retryPending(db, opts) {
|
|
|
102
115
|
return { scanned: rows.length, retried, failed };
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
/** M3-2: 分账补扫 — 扫 ps_status='pending'/'failed' 的台账行:
|
|
119
|
+
* 先 queryProfitsharing 同步微信侧真实状态 (FINISHED → done); 仍 failed 的用同 out_order_no
|
|
120
|
+
* 重发一笔 (微信侧幂等)。mock 模式: pending → mock, failed → 重发 mock 后记 mock。 */
|
|
121
|
+
async function retryProfitsharingSweep(db, opts) {
|
|
122
|
+
const rows = db.prepare(`SELECT txn_id, sub_mchid, amount, ps_out_order_no, ps_amount, ps_status
|
|
123
|
+
FROM sp_payment_facts WHERE ps_status IN ('pending','failed') AND ps_out_order_no <> '' LIMIT 20`).all();
|
|
124
|
+
let synced = 0, retried = 0, failed = 0;
|
|
125
|
+
const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
try {
|
|
128
|
+
const q = await profitsharing.queryProfitsharing(row.ps_out_order_no, { subMchid: row.sub_mchid });
|
|
129
|
+
if (q && q.mock) {
|
|
130
|
+
// mock: 无真实状态可同步; failed 行重发一笔 mock 分账
|
|
131
|
+
if (row.ps_status === "failed") {
|
|
132
|
+
await profitsharing.requestProfitsharing({
|
|
133
|
+
outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
|
|
134
|
+
subMchid: row.sub_mchid,
|
|
135
|
+
receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
|
|
136
|
+
});
|
|
137
|
+
retried++;
|
|
138
|
+
} else {
|
|
139
|
+
synced++;
|
|
140
|
+
}
|
|
141
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='mock', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (q && q.status === 200 && q.json) {
|
|
145
|
+
const st = String(q.json.state || "");
|
|
146
|
+
if (/FINISHED|SUCCESS/i.test(st)) {
|
|
147
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='done', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
|
|
148
|
+
synced++;
|
|
149
|
+
} else if (row.ps_status === "failed") {
|
|
150
|
+
// 微信侧未成功 → 同 out_order_no 重发 (幂等)
|
|
151
|
+
const r2 = await profitsharing.requestProfitsharing({
|
|
152
|
+
outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
|
|
153
|
+
subMchid: row.sub_mchid,
|
|
154
|
+
receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
|
|
155
|
+
});
|
|
156
|
+
const ok = r2 && r2.status === 200;
|
|
157
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(ok ? "pending" : "failed", nowSec(), row.txn_id);
|
|
158
|
+
if (ok) retried++; else failed++;
|
|
159
|
+
}
|
|
160
|
+
// pending 且微信侧还在处理中: 保持 pending 下轮再扫
|
|
161
|
+
} else {
|
|
162
|
+
failed++;
|
|
163
|
+
console.warn(`[sp-notify] 分账补扫查询未成功 out_order_no=${row.ps_out_order_no} status=${q && q.status}`);
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
failed++;
|
|
167
|
+
console.warn(`[sp-notify] 分账补扫失败 txn=${row.txn_id}:`, e && e.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { scanned: rows.length, synced, retried, failed };
|
|
171
|
+
}
|
|
172
|
+
|
|
105
173
|
function createSpNotifyRouter(deps) {
|
|
106
174
|
const router = express.Router();
|
|
107
175
|
const { db } = deps;
|
|
108
176
|
ensureTables(db);
|
|
109
177
|
spCommission.ensureCommissionTables(db); // 分账触发要查抽佣表
|
|
110
178
|
const injectedSendTx = deps.sendTx || null; // 测试注入 (绕过真实链)
|
|
179
|
+
// M3-1: 启动即注册分账接收方 (SP 自身 mchid; 幂等, 台账标记, mock 也落行)
|
|
180
|
+
profitsharing.registerDefaultReceivers(db)
|
|
181
|
+
.catch((e) => console.warn("[sp-notify] 分账接收方注册失败 (进件 FINISHED 钩子会再试):", e && e.message));
|
|
182
|
+
// M3-2: 分账补扫定时器 (5min; 扫 ps_status pending/failed 同步微信真实状态/重发)
|
|
183
|
+
const psTimer = setInterval(() => { retryProfitsharingSweep(db).catch((e) => console.warn("[sp-notify] 分账补扫异常:", e && e.message)); }, 5 * 60 * 1000);
|
|
184
|
+
psTimer.unref && psTimer.unref();
|
|
111
185
|
|
|
112
186
|
/** 上链统一入口: 禁用开关 > 测试注入 > 真实通道 */
|
|
113
187
|
async function sendFact(fact) {
|
|
@@ -116,7 +190,8 @@ function createSpNotifyRouter(deps) {
|
|
|
116
190
|
return submitPaymentFactToChain(fact);
|
|
117
191
|
}
|
|
118
192
|
|
|
119
|
-
/** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调)
|
|
193
|
+
/** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调)
|
|
194
|
+
* M3-2: 台账落行 pending → 微信/mock 返回后 done/mock/failed; failed/pending 由 retryProfitsharingSweep 补扫 */
|
|
120
195
|
function maybeTriggerProfitsharing(f) {
|
|
121
196
|
try {
|
|
122
197
|
const rate = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(f.subMchid);
|
|
@@ -124,15 +199,22 @@ function createSpNotifyRouter(deps) {
|
|
|
124
199
|
const commission = Math.floor(f.amount * rate.rate_bp / 10000);
|
|
125
200
|
if (!(commission > 0)) return;
|
|
126
201
|
const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
|
|
202
|
+
const outOrderNo = "ps-" + f.txnId; // 分账单号 (微信侧 out_order_no, 幂等键)
|
|
203
|
+
// 台账先落 pending (即使后续调用失败也留痕可补扫)
|
|
204
|
+
db.prepare("UPDATE sp_payment_facts SET ps_out_order_no=?, ps_amount=?, ps_status='pending', ps_at=? WHERE txn_id=?")
|
|
205
|
+
.run(outOrderNo, commission, nowSec(), f.txnId);
|
|
127
206
|
profitsharing.requestProfitsharing({
|
|
128
|
-
outTradeNo:
|
|
207
|
+
outTradeNo: outOrderNo,
|
|
129
208
|
transactionId: f.txnId,
|
|
130
209
|
tradeAmount: f.amount,
|
|
131
210
|
subMchid: f.subMchid,
|
|
132
211
|
receivers: [{ account: receiverAccount, amount: commission, description: "服务商抽佣" }],
|
|
133
212
|
}).then((r) => {
|
|
134
|
-
|
|
213
|
+
const st = (r && r.mock) ? "mock" : (r && r.status === 200 ? "done" : "failed");
|
|
214
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(st, nowSec(), f.txnId);
|
|
215
|
+
console.log(`[sp-notify] 分账触发 txn=${f.txnId} commission=${commission} ps_status=${st} mock=${!!(r && r.mock)}`);
|
|
135
216
|
}).catch((e) => {
|
|
217
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='failed', ps_at=? WHERE txn_id=?").run(nowSec(), f.txnId);
|
|
136
218
|
console.warn(`[sp-notify] 分账触发失败 txn=${f.txnId}:`, e && e.message);
|
|
137
219
|
});
|
|
138
220
|
} catch (e) {
|
|
@@ -249,4 +331,4 @@ function createSpNotifyRouter(deps) {
|
|
|
249
331
|
return router;
|
|
250
332
|
}
|
|
251
333
|
|
|
252
|
-
module.exports = { createSpNotifyRouter, ensureTables, retryPending, submitPaymentFactToChain };
|
|
334
|
+
module.exports = { createSpNotifyRouter, ensureTables, retryPending, retryProfitsharingSweep, submitPaymentFactToChain };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-agent-pay-lifecycle.js — M4 买家维度: agent-pay 状态机断头路修复 + order.js 买家只读面公开化
|
|
4
|
+
* node --test test-agent-pay-lifecycle.js
|
|
5
|
+
*
|
|
6
|
+
* 覆盖:
|
|
7
|
+
* 状态机全链 PAID→SHIPPED→DELIVERED→CONFIRMED (logistics HMAC + confirm status_token)
|
|
8
|
+
* 回退拒 (DELIVERED→SHIPPED 409) + timeline 追加不覆盖 + logistics 无 key/错 key 401
|
|
9
|
+
* 状态查询: rail 真实值 (psp_txns) + DISPUTED/REFUNDED 不再误映射 PAID
|
|
10
|
+
* markOrderOnChain: PAID→ON_CHAIN 回写 chain_order_id (占位函数, 后续批次触发)
|
|
11
|
+
* order.js: 买家只读面 (单查/买家列表) 无 token 200; 写操作 (confirm/cancel/dispute) 仍 401
|
|
12
|
+
* 不碰真微信/真链 (打桩 LCD)。
|
|
13
|
+
*/
|
|
14
|
+
const test = require("node:test");
|
|
15
|
+
const assert = require("node:assert");
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
const http = require("http");
|
|
18
|
+
const express = require("express");
|
|
19
|
+
const Database = require("better-sqlite3");
|
|
20
|
+
|
|
21
|
+
const agentPay = require("./routes/agent-pay");
|
|
22
|
+
const { createOrderRoutes } = require("./routes/order");
|
|
23
|
+
|
|
24
|
+
function nowSec() { return Math.floor(Date.now() / 1000); }
|
|
25
|
+
function sha256(s) { return crypto.createHash("sha256").update(String(s)).digest("hex"); }
|
|
26
|
+
|
|
27
|
+
/** 起 agent-pay 服务 (mock 模式) */
|
|
28
|
+
async function startAgentPay() {
|
|
29
|
+
const db = new Database(":memory:");
|
|
30
|
+
const app = express();
|
|
31
|
+
app.use(express.json());
|
|
32
|
+
app.use("/api/agent", agentPay.createAgentPayRouter({ db }));
|
|
33
|
+
const srv = app.listen(0);
|
|
34
|
+
await new Promise((r) => srv.once("listening", r));
|
|
35
|
+
return { db, srv, base: `http://127.0.0.1:${srv.address().port}/api/agent` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function insertPaidSession(db, orderRef, statusToken) {
|
|
39
|
+
db.prepare(`INSERT INTO agent_pay_sessions
|
|
40
|
+
(client_session_id, sku_id, qty, amount_total, currency, agent_addr, order_ref, pay_token_hash, status_token_hash, state, psp_code_url, callback_url, expires_at, created_at, updated_at)
|
|
41
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
42
|
+
.run("cs-" + orderRef, "sku-1", 1, 100, "CNY", "", orderRef, sha256("pt-" + orderRef), sha256(statusToken), "PAID", "", "", nowSec() + 900, nowSec(), nowSec());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LKEY = "test-logistics-hmac-key";
|
|
46
|
+
|
|
47
|
+
test("M4 状态机全链: PAID→SHIPPED→DELIVERED→CONFIRMED + 回退拒 + timeline 追加 + HMAC 鉴权", async () => {
|
|
48
|
+
process.env.LOGISTICS_HMAC_KEY = LKEY;
|
|
49
|
+
const { db, srv, base } = await startAgentPay();
|
|
50
|
+
try {
|
|
51
|
+
insertPaidSession(db, "tb-lc-1", "st-lc-1");
|
|
52
|
+
const H = { "Content-Type": "application/json", "x-logistics-key": LKEY };
|
|
53
|
+
// 无 key → 401; 错 key → 401
|
|
54
|
+
let r = await fetch(base + "/internal/logistics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_ref: "tb-lc-1" }) });
|
|
55
|
+
assert.strictEqual(r.status, 401, "无 key 拒");
|
|
56
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: { "Content-Type": "application/json", "x-logistics-key": "wrong" }, body: JSON.stringify({ order_ref: "tb-lc-1" }) });
|
|
57
|
+
assert.strictEqual(r.status, 401, "错 key 拒");
|
|
58
|
+
// PAID → SHIPPED
|
|
59
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: H, body: JSON.stringify({ order_ref: "tb-lc-1", carrier: "顺丰", tracking_no: "SF123", timeline: [{ t: "揽收" }] }) });
|
|
60
|
+
assert.strictEqual((await r.json()).state, "SHIPPED");
|
|
61
|
+
// SHIPPED → SHIPPED 更新, timeline 追加不覆盖
|
|
62
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: H, body: JSON.stringify({ order_ref: "tb-lc-1", timeline: [{ t: "运输中" }] }) });
|
|
63
|
+
assert.strictEqual((await r.json()).state, "SHIPPED");
|
|
64
|
+
let row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref='tb-lc-1'").get();
|
|
65
|
+
assert.strictEqual(JSON.parse(row.timeline).length, 2, "timeline 追加不覆盖");
|
|
66
|
+
assert.strictEqual(row.carrier, "顺丰", "carrier 保留 (未传不清空)");
|
|
67
|
+
// SHIPPED → DELIVERED
|
|
68
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: H, body: JSON.stringify({ order_ref: "tb-lc-1", logistics_state: "DELIVERED", timeline: [{ t: "签收" }] }) });
|
|
69
|
+
assert.strictEqual((await r.json()).state, "DELIVERED");
|
|
70
|
+
// 回退 DELIVERED → SHIPPED → 409
|
|
71
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: H, body: JSON.stringify({ order_ref: "tb-lc-1", logistics_state: "SHIPPED" }) });
|
|
72
|
+
assert.strictEqual(r.status, 409);
|
|
73
|
+
let ej = await r.json();
|
|
74
|
+
assert.strictEqual(ej.current, "DELIVERED", "回退被拒 (STATE_INVALID 详情带 current)");
|
|
75
|
+
assert.strictEqual(ej.target, "SHIPPED");
|
|
76
|
+
// DELIVERED → CONFIRMED (买家确认收货, status_token 凭证)
|
|
77
|
+
r = await fetch(base + "/order/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_ref: "tb-lc-1", token: "wrong-token" }) });
|
|
78
|
+
assert.strictEqual(r.status, 401, "错 status_token 拒");
|
|
79
|
+
r = await fetch(base + "/order/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_ref: "tb-lc-1", token: "st-lc-1" }) });
|
|
80
|
+
assert.strictEqual((await r.json()).state, "CONFIRMED");
|
|
81
|
+
// 幂等
|
|
82
|
+
r = await fetch(base + "/order/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_ref: "tb-lc-1", token: "st-lc-1" }) });
|
|
83
|
+
assert.strictEqual((await r.json()).idempotent, true);
|
|
84
|
+
// 终态后物流回退仍拒
|
|
85
|
+
r = await fetch(base + "/internal/logistics", { method: "POST", headers: H, body: JSON.stringify({ order_ref: "tb-lc-1", logistics_state: "SHIPPED" }) });
|
|
86
|
+
assert.strictEqual(r.status, 409);
|
|
87
|
+
// 状态查询: 全链后 payment.state=PAID + rail 真实
|
|
88
|
+
const st = await (await fetch(base + "/order/status?order_ref=tb-lc-1&token=st-lc-1")).json();
|
|
89
|
+
assert.strictEqual(st.state, "CONFIRMED");
|
|
90
|
+
assert.strictEqual(st.payment.state, "PAID");
|
|
91
|
+
assert.strictEqual(st.logistics.state, "DELIVERED");
|
|
92
|
+
assert.strictEqual(st.logistics.timeline.length, 3);
|
|
93
|
+
} finally { srv.close(); delete process.env.LOGISTICS_HMAC_KEY; }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("M4: logistics 未配置 LOGISTICS_HMAC_KEY → 全拒 401 (宁可全拒不留后门)", async () => {
|
|
97
|
+
delete process.env.LOGISTICS_HMAC_KEY;
|
|
98
|
+
const { srv, base } = await startAgentPay();
|
|
99
|
+
try {
|
|
100
|
+
const r = await fetch(base + "/internal/logistics", { method: "POST", headers: { "Content-Type": "application/json", "x-logistics-key": "any" }, body: JSON.stringify({ order_ref: "x" }) });
|
|
101
|
+
assert.strictEqual(r.status, 401);
|
|
102
|
+
} finally { srv.close(); }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("M4: 状态查询 payment.state 映射修正 (DISPUTED/REFUNDED 不再是 PAID) + rail 真实值", async () => {
|
|
106
|
+
const { db, srv, base } = await startAgentPay();
|
|
107
|
+
try {
|
|
108
|
+
// DISPUTED (金额不一致落账队列)
|
|
109
|
+
insertPaidSession(db, "tb-st-1", "st-1");
|
|
110
|
+
db.prepare("UPDATE agent_pay_sessions SET state='DISPUTED' WHERE order_ref='tb-st-1'").run();
|
|
111
|
+
db.prepare("INSERT INTO psp_txns (txn_id, session_id, rail, amount, raw, created_at) VALUES ('tx-st-1', (SELECT id FROM agent_pay_sessions WHERE order_ref='tb-st-1'), 'wechat_native', 100, '', ?)").run(nowSec());
|
|
112
|
+
let st = await (await fetch(base + "/order/status?order_ref=tb-st-1&token=st-1")).json();
|
|
113
|
+
assert.strictEqual(st.payment.state, "DISPUTED", "DISPUTED 不映射 PAID");
|
|
114
|
+
assert.strictEqual(st.payment.rail, "wechat_native", "rail 取 psp_txns 真实轨 (不是硬编码 mock)");
|
|
115
|
+
// REFUNDED
|
|
116
|
+
insertPaidSession(db, "tb-st-2", "st-2");
|
|
117
|
+
db.prepare("UPDATE agent_pay_sessions SET state='REFUNDED' WHERE order_ref='tb-st-2'").run();
|
|
118
|
+
st = await (await fetch(base + "/order/status?order_ref=tb-st-2&token=st-2")).json();
|
|
119
|
+
assert.strictEqual(st.payment.state, "REFUNDED");
|
|
120
|
+
// CREATED → UNPAID, rail 回落 MODE (mock)
|
|
121
|
+
insertPaidSession(db, "tb-st-3", "st-3");
|
|
122
|
+
db.prepare("UPDATE agent_pay_sessions SET state='CREATED' WHERE order_ref='tb-st-3'").run();
|
|
123
|
+
st = await (await fetch(base + "/order/status?order_ref=tb-st-3&token=st-3")).json();
|
|
124
|
+
assert.strictEqual(st.payment.state, "UNPAID");
|
|
125
|
+
assert.strictEqual(st.payment.rail, "mock");
|
|
126
|
+
} finally { srv.close(); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("M4: markOrderOnChain — PAID→ON_CHAIN 回写 chain_order_id (占位函数, 后续批次触发)", async () => {
|
|
130
|
+
const { db, srv } = await startAgentPay();
|
|
131
|
+
try {
|
|
132
|
+
insertPaidSession(db, "tb-oc-1", "st-oc");
|
|
133
|
+
const r = agentPay.markOrderOnChain(db, "tb-oc-1", "chain-order-42");
|
|
134
|
+
assert.strictEqual(r.ok, true);
|
|
135
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref='tb-oc-1'").get();
|
|
136
|
+
assert.strictEqual(row.state, "ON_CHAIN");
|
|
137
|
+
assert.strictEqual(row.chain_order_id, "chain-order-42");
|
|
138
|
+
// 幂等 + 非法态
|
|
139
|
+
assert.strictEqual(agentPay.markOrderOnChain(db, "tb-oc-1", "chain-order-42").idempotent, true);
|
|
140
|
+
db.prepare("UPDATE agent_pay_sessions SET state='CONFIRMED' WHERE order_ref='tb-oc-1'").run();
|
|
141
|
+
assert.strictEqual(agentPay.markOrderOnChain(db, "tb-oc-1", "x").error, "STATE_INVALID");
|
|
142
|
+
assert.strictEqual(agentPay.markOrderOnChain(db, "tb-nope", "x").error, "NOT_FOUND");
|
|
143
|
+
} finally { srv.close(); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("M4: order.js 买家只读面公开 (单查/买家列表无 token 200), 写操作仍 401", async () => {
|
|
147
|
+
// 打桩 LCD (链上公开数据)
|
|
148
|
+
const stub = http.createServer((req, res) => {
|
|
149
|
+
res.setHeader("content-type", "application/json");
|
|
150
|
+
if (req.url.startsWith("/oxiaom/trustchain/order/v1/orders/")) {
|
|
151
|
+
return res.end(JSON.stringify({ order: { order_id: "7", buyer: "tct1buyer", seller: "tct1seller", status: "ORDER_STATUS_PAID" } }));
|
|
152
|
+
}
|
|
153
|
+
if (req.url.startsWith("/oxiaom/trustchain/order/v1/buyers/")) {
|
|
154
|
+
return res.end(JSON.stringify({ orders: [{ order_id: "7" }] }));
|
|
155
|
+
}
|
|
156
|
+
res.writeHead(404); res.end("{}");
|
|
157
|
+
});
|
|
158
|
+
await new Promise((r) => stub.listen(0, "127.0.0.1", r));
|
|
159
|
+
const savedLcd = process.env.LCD_URL;
|
|
160
|
+
process.env.LCD_URL = `http://127.0.0.1:${stub.address().port}`;
|
|
161
|
+
const db = new Database(":memory:");
|
|
162
|
+
const app = express();
|
|
163
|
+
app.use(express.json());
|
|
164
|
+
const authPlugin = { verifyToken: async () => null }; // 写操作鉴权桩: 任何 token 都无效 → 写操作恒 401
|
|
165
|
+
app.use("/api/order", createOrderRoutes(authPlugin, { db }));
|
|
166
|
+
const srv = app.listen(0);
|
|
167
|
+
await new Promise((r) => srv.once("listening", r));
|
|
168
|
+
const base = `http://127.0.0.1:${srv.address().port}/api/order`;
|
|
169
|
+
try {
|
|
170
|
+
// 买家只读面: 无 token 可查
|
|
171
|
+
let r = await fetch(base + "/7");
|
|
172
|
+
assert.strictEqual(r.status, 200, "单查公开");
|
|
173
|
+
assert.strictEqual((await r.json()).order.order_id, "7");
|
|
174
|
+
r = await fetch(base + "/buyer/tct1buyer");
|
|
175
|
+
assert.strictEqual(r.status, 200, "买家列表公开");
|
|
176
|
+
assert.strictEqual((await r.json()).orders.length, 1);
|
|
177
|
+
// 写操作仍 401 (无 token)
|
|
178
|
+
for (const p of ["/confirm", "/cancel", "/dispute"]) {
|
|
179
|
+
const w = await fetch(base + p, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|
180
|
+
assert.strictEqual(w.status, 401, p + " 写操作保持鉴权");
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
srv.close(); stub.close();
|
|
184
|
+
if (savedLcd === undefined) delete process.env.LCD_URL; else process.env.LCD_URL = savedLcd;
|
|
185
|
+
}
|
|
186
|
+
});
|
package/test-agent-pay-wechat.js
CHANGED
|
@@ -258,3 +258,34 @@ test("E3 回归 (2026-09-25 实测 bug): EXPIRED 单过期 <24h 仍被查单,
|
|
|
258
258
|
assert.ok(!refs.includes("tb-rec-2"), "过期 25h 的 EXPIRED 单不进查单范围");
|
|
259
259
|
delete process.env.AGENT_PAY_MODE;
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
test("M4: E2 退款事件 TRANSACTION.REFUND → REFUNDED (幂等 + 处理中忽略)", async () => {
|
|
263
|
+
const { db, srv, base } = await startServer();
|
|
264
|
+
try {
|
|
265
|
+
insertSession(db, "tb-refund-1", 100);
|
|
266
|
+
db.prepare("UPDATE agent_pay_sessions SET state='PAID' WHERE order_ref='tb-refund-1'").run();
|
|
267
|
+
const body = JSON.stringify({
|
|
268
|
+
id: "EV-R1", event_type: "TRANSACTION.REFUND", resource_type: "encrypt-resource",
|
|
269
|
+
resource: wxEncryptResource({ out_trade_no: "tb-refund-1", refund_status: "SUCCESS", refund_id: "RF-1" }),
|
|
270
|
+
});
|
|
271
|
+
const r = await fetch(base + "/pay/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
272
|
+
assert.strictEqual(r.status, 200);
|
|
273
|
+
assert.strictEqual((await r.json()).refunded, true);
|
|
274
|
+
assert.strictEqual(db.prepare("SELECT state FROM agent_pay_sessions WHERE order_ref='tb-refund-1'").get().state, "REFUNDED");
|
|
275
|
+
// 重放幂等: 仍 200 refunded, 状态保持
|
|
276
|
+
const r2 = await fetch(base + "/pay/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
277
|
+
assert.strictEqual(r2.status, 200);
|
|
278
|
+
assert.strictEqual(db.prepare("SELECT state FROM agent_pay_sessions WHERE order_ref='tb-refund-1'").get().state, "REFUNDED");
|
|
279
|
+
// 退款处理中 → ignored 不改状态
|
|
280
|
+
insertSession(db, "tb-refund-2", 100);
|
|
281
|
+
db.prepare("UPDATE agent_pay_sessions SET state='SHIPPED' WHERE order_ref='tb-refund-2'").run();
|
|
282
|
+
const body2 = JSON.stringify({
|
|
283
|
+
id: "EV-R2", event_type: "TRANSACTION.REFUND", resource_type: "encrypt-resource",
|
|
284
|
+
resource: wxEncryptResource({ out_trade_no: "tb-refund-2", refund_status: "PROCESSING", refund_id: "RF-2" }),
|
|
285
|
+
});
|
|
286
|
+
const r3 = await fetch(base + "/pay/notify/wechat", { method: "POST", headers: wxSignHeaders(body2), body: body2 });
|
|
287
|
+
assert.strictEqual(r3.status, 200);
|
|
288
|
+
assert.ok((await r3.json()).ignored, "处理中不落 REFUNDED");
|
|
289
|
+
assert.strictEqual(db.prepare("SELECT state FROM agent_pay_sessions WHERE order_ref='tb-refund-2'").get().state, "SHIPPED");
|
|
290
|
+
} finally { srv.close(); }
|
|
291
|
+
});
|
package/test-sp-applyment.js
CHANGED
|
@@ -154,6 +154,8 @@ test("submit mock: 落台账 + 敏感字段加密 (非明文且可还原) + 重
|
|
|
154
154
|
assert.ok(!row.subject_json.includes("440300199001011234"), "快照不含明文身份证");
|
|
155
155
|
assert.ok(!row.subject_json.includes("6225880212345678"), "快照不含明文卡号");
|
|
156
156
|
assert.ok(row.subject_json.includes("1234"), "脱敏后留后4位");
|
|
157
|
+
// M3 🟡-1: 进件时刻公示比例快照
|
|
158
|
+
assert.ok(JSON.parse(row.subject_json).commission_bp_at_apply >= 0, "快照含 commission_bp_at_apply");
|
|
157
159
|
|
|
158
160
|
// 敏感字段加密断言: 用同一入口 buildApplymentBody 组装, 密文非明文且测试私钥可还原
|
|
159
161
|
const pem = fs.readFileSync(pubKeyPath, "utf8");
|
|
@@ -230,24 +232,57 @@ test("GET /api/sp/info 公开名片: 无 admin key 200 + env 覆盖生效", asyn
|
|
|
230
232
|
assert.strictEqual(j.commission_rate_bp, 100, "默认 1%");
|
|
231
233
|
assert.strictEqual(j.rate_pct, "1.00%");
|
|
232
234
|
assert.ok(j.gateway.includes("127.0.0.1"), "gateway 从 req host 推断");
|
|
233
|
-
// env
|
|
235
|
+
// env 覆盖: 文本类即时生效; SP_DEFAULT_COMMISSION_BP 只作首次种子 (M3 🟡-1),
|
|
236
|
+
// 当前库已被首次调用种子为 100 → 需新库验证种子语义
|
|
234
237
|
process.env.SP_NAME = "无锡小播鼠网络科技";
|
|
235
238
|
process.env.SP_DESC = "TrustBase 官方支付服务商";
|
|
236
239
|
process.env.SP_OFFICIAL_URL = "https://trustbase.example.com";
|
|
237
240
|
process.env.SP_CONTACT = "微信: xiaomu";
|
|
238
|
-
process.env.SP_DEFAULT_COMMISSION_BP = "150";
|
|
239
241
|
process.env.SP_CHAIN_ADDR = "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7";
|
|
240
242
|
r = await fetch(base + "/api/sp/info");
|
|
241
243
|
j = await r.json();
|
|
242
244
|
assert.strictEqual(j.name, "无锡小播鼠网络科技");
|
|
243
245
|
assert.strictEqual(j.contact, "微信: xiaomu");
|
|
244
|
-
assert.strictEqual(j.commission_rate_bp,
|
|
245
|
-
assert.strictEqual(j.rate_pct, "1.50%");
|
|
246
|
+
assert.strictEqual(j.commission_rate_bp, 100, "种子已落表, env 改不动公示比例 (读表优先)");
|
|
246
247
|
assert.strictEqual(j.chain_address, "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7");
|
|
247
248
|
} finally {
|
|
248
249
|
srv.close();
|
|
249
250
|
delete process.env.SP_NAME; delete process.env.SP_DESC; delete process.env.SP_OFFICIAL_URL;
|
|
250
|
-
delete process.env.SP_CONTACT; delete process.env.
|
|
251
|
+
delete process.env.SP_CONTACT; delete process.env.SP_CHAIN_ADDR;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("M3 🟡-1: /info 公示比例 env 首种子 + 表行优先 (PUT __default__ 覆盖生效)", async () => {
|
|
256
|
+
// 新库 + env 种子 250 → 首次读 2.50% 且落表; 之后 admin PUT __default__=200 → 2.00% (env 不再生效)
|
|
257
|
+
process.env.SP_DEFAULT_COMMISSION_BP = "250";
|
|
258
|
+
const spCommission = require("./routes/sp-commission");
|
|
259
|
+
const db = new Database(":memory:");
|
|
260
|
+
const app = express();
|
|
261
|
+
app.use(express.json());
|
|
262
|
+
app.use("/api/sp", spApplyment.createSpApplymentRouter({ db }));
|
|
263
|
+
app.use("/api/sp", spCommission.createSpCommissionRouter({ db }));
|
|
264
|
+
const srv = app.listen(0);
|
|
265
|
+
await new Promise((r) => srv.once("listening", r));
|
|
266
|
+
const base = `http://127.0.0.1:${srv.address().port}`;
|
|
267
|
+
try {
|
|
268
|
+
let j = await (await fetch(base + "/api/sp/info")).json();
|
|
269
|
+
assert.strictEqual(j.commission_rate_bp, 250);
|
|
270
|
+
assert.strictEqual(j.rate_pct, "2.50%");
|
|
271
|
+
const seedRow = db.prepare("SELECT * FROM sp_commission_rates WHERE sub_mchid='__default__'").get();
|
|
272
|
+
assert.ok(seedRow, "env 首种子落表");
|
|
273
|
+
assert.strictEqual(seedRow.rate_bp, 250);
|
|
274
|
+
// admin 改公示比例 → 读表优先
|
|
275
|
+
const put = await fetch(base + "/api/sp/commission-rate", {
|
|
276
|
+
method: "PUT", headers: { "Content-Type": "application/json", "x-sp-admin-key": ADMIN_KEY },
|
|
277
|
+
body: JSON.stringify({ sub_mchid: "__default__", rate_bp: 200 }),
|
|
278
|
+
});
|
|
279
|
+
assert.strictEqual(put.status, 200, JSON.stringify(await put.json()));
|
|
280
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
281
|
+
assert.strictEqual(j.commission_rate_bp, 200, "PUT __default__ 覆盖生效");
|
|
282
|
+
assert.strictEqual(j.rate_pct, "2.00%");
|
|
283
|
+
} finally {
|
|
284
|
+
srv.close();
|
|
285
|
+
delete process.env.SP_DEFAULT_COMMISSION_BP;
|
|
251
286
|
}
|
|
252
287
|
});
|
|
253
288
|
|
package/test-sp-gateway.js
CHANGED
|
@@ -359,3 +359,77 @@ test("retryPending: 上链失败台账留空 → 扫出重试补 txhash", async
|
|
|
359
359
|
process.env.SP_CHAIN_DISABLED = "1";
|
|
360
360
|
}
|
|
361
361
|
});
|
|
362
|
+
|
|
363
|
+
test("M3-1: 分账接收方注册幂等 (台账标记, 已注册不重复调)", async () => {
|
|
364
|
+
const db = new Database(":memory:");
|
|
365
|
+
spNotify.ensureTables(db);
|
|
366
|
+
// 首次注册 (mock 模式, WXPAY_SP_MCHID=1000686974 已在 env)
|
|
367
|
+
const r1 = await profitsharing.registerDefaultReceivers(db);
|
|
368
|
+
assert.strictEqual(r1.registered, true);
|
|
369
|
+
assert.strictEqual(r1.mock, true);
|
|
370
|
+
// 台账落行
|
|
371
|
+
const row = db.prepare("SELECT * FROM sp_ps_receivers WHERE account='1000686974'").get();
|
|
372
|
+
assert.ok(row, "接收方台账落行");
|
|
373
|
+
assert.strictEqual(row.mock, 1);
|
|
374
|
+
// 第二次 → 幂等跳过
|
|
375
|
+
const r2 = await profitsharing.registerDefaultReceivers(db);
|
|
376
|
+
assert.strictEqual(r2.registered, false);
|
|
377
|
+
assert.strictEqual(r2.reason, "already");
|
|
378
|
+
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_ps_receivers").get().n, 1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("M3-2: 分账台账落行 (pending→mock) + 补扫重试 failed", async () => {
|
|
382
|
+
const { db, srv, merchantSrv, base } = await startServers();
|
|
383
|
+
try {
|
|
384
|
+
insertMerchant(db, SUB_MCHID, "");
|
|
385
|
+
// 登记抽佣 1% (100bp)
|
|
386
|
+
await fetch(base + "/commission-rate", {
|
|
387
|
+
method: "PUT", headers: { "content-type": "application/json", "x-sp-admin-key": ADMIN_KEY },
|
|
388
|
+
body: JSON.stringify({ sub_mchid: SUB_MCHID, rate_bp: 100 }),
|
|
389
|
+
});
|
|
390
|
+
const body = makeNotifyBody({
|
|
391
|
+
out_trade_no: "tb-ps-1", transaction_id: "4200009999000100",
|
|
392
|
+
trade_state: "SUCCESS", amount: { total: 10000, currency: "CNY" }, sub_mchid: SUB_MCHID,
|
|
393
|
+
});
|
|
394
|
+
const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
395
|
+
assert.strictEqual(r.status, 200);
|
|
396
|
+
// 分账 fire-and-forget → 等台账 ps_status 落定 (mock 秒回)
|
|
397
|
+
let row = null;
|
|
398
|
+
for (let i = 0; i < 50; i++) {
|
|
399
|
+
row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
|
|
400
|
+
if (row && row.ps_status && row.ps_status !== "pending") break;
|
|
401
|
+
await new Promise((rs) => setTimeout(rs, 20));
|
|
402
|
+
}
|
|
403
|
+
assert.strictEqual(row.ps_out_order_no, "ps-4200009999000100", "分账单号落台账");
|
|
404
|
+
assert.strictEqual(row.ps_amount, 100, "1% 抽佣 = 100 分");
|
|
405
|
+
assert.strictEqual(row.ps_status, "mock", "mock 模式落 mock");
|
|
406
|
+
assert.ok(row.ps_at > 0);
|
|
407
|
+
// 手动置 failed → 补扫重发 (mock) → 回 mock
|
|
408
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='failed' WHERE txn_id=?").run("4200009999000100");
|
|
409
|
+
const res = await spNotify.retryProfitsharingSweep(db);
|
|
410
|
+
assert.strictEqual(res.scanned, 1);
|
|
411
|
+
assert.strictEqual(res.retried, 1, "failed 行重发一笔");
|
|
412
|
+
const row2 = db.prepare("SELECT ps_status FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
|
|
413
|
+
assert.strictEqual(row2.ps_status, "mock");
|
|
414
|
+
// 再扫: 无 pending/failed
|
|
415
|
+
const res2 = await spNotify.retryProfitsharingSweep(db);
|
|
416
|
+
assert.strictEqual(res2.scanned, 0);
|
|
417
|
+
} finally { srv.close(); merchantSrv.close(); }
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("M3-2: 无抽佣登记的商户回调 → 分账列留空 (不触发)", async () => {
|
|
421
|
+
const { db, srv, merchantSrv, base } = await startServers();
|
|
422
|
+
try {
|
|
423
|
+
insertMerchant(db, SUB_MCHID, "");
|
|
424
|
+
const body = makeNotifyBody({
|
|
425
|
+
out_trade_no: "tb-ps-2", transaction_id: "4200009999000101",
|
|
426
|
+
trade_state: "SUCCESS", amount: { total: 500 }, sub_mchid: SUB_MCHID,
|
|
427
|
+
});
|
|
428
|
+
const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
429
|
+
assert.strictEqual(r.status, 200);
|
|
430
|
+
await new Promise((rs) => setTimeout(rs, 100));
|
|
431
|
+
const row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000101");
|
|
432
|
+
assert.strictEqual(row.ps_status, "", "未登记抽佣不触发分账");
|
|
433
|
+
assert.strictEqual(row.ps_out_order_no, "");
|
|
434
|
+
} finally { srv.close(); merchantSrv.close(); }
|
|
435
|
+
});
|