trustbase-seller-backend 0.2.4 → 0.2.6
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 +26 -5
- package/routes/agent-pay.js +99 -13
- package/routes/onboard.html +126 -16
- package/routes/onboard.js +133 -2
- package/routes/order.js +3 -18
- package/test-agent-pay-lifecycle.js +186 -0
- package/test-agent-pay-wechat.js +31 -0
- package/test-onboard-backup.js +198 -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,21 @@ async function addReceivers(receivers) {
|
|
|
103
107
|
return results.length === 1 ? results[0] : results;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/** 接收方名称加密: 微信支付公钥 RSA-OAEP → base64。
|
|
111
|
+
* ⚠️ 2026-09-26 实测坑: 微信"OAEPWithSHA-256AndMGF1Padding"是 Java 语义 — MGF1 实际用 SHA-1。
|
|
112
|
+
* Node crypto.publicEncrypt 的 oaepHash 会把 OAEP+MGF1 都设成同一 hash → 微信"平台私钥解密失败"。
|
|
113
|
+
* 必须用 openssl 子进程分开指定 (rsa_oaep_md:sha256 + rsa_mgf1_md:sha1)。 */
|
|
114
|
+
function encryptReceiverName(name) {
|
|
115
|
+
const p = process.env.WXPAY_SP_PUBLIC_KEY_PATH || process.env.WXPAY_PUBLIC_KEY_PATH;
|
|
116
|
+
if (!p) throw new Error("WXPAY_(SP_)PUBLIC_KEY_PATH 未配置, 接收方名称无法加密");
|
|
117
|
+
const { execSync } = require("child_process");
|
|
118
|
+
const b64 = execSync(
|
|
119
|
+
`openssl pkeyutl -encrypt -pubin -inkey ${JSON.stringify(p)} -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha1 | base64 -w0`,
|
|
120
|
+
{ input: Buffer.from(name, "utf8"), maxBuffer: 1024 * 1024 }
|
|
121
|
+
).toString("utf8").trim();
|
|
122
|
+
return b64;
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
/** 请求分账
|
|
107
126
|
* params: {outTradeNo(分账单号 out_order_no, 幂等键), transactionId(微信支付单号),
|
|
108
127
|
* tradeAmount(交易金额分, 护栏基准), subMchid?, unfreezeUnsplit?,
|
|
@@ -169,13 +188,15 @@ async function registerDefaultReceivers(db) {
|
|
|
169
188
|
console.warn("[profitsharing] 未配 WXPAY_SP_MCHID/WXPAY_MCHID, 跳过接收方注册");
|
|
170
189
|
return { registered: false, reason: "no_mchid" };
|
|
171
190
|
}
|
|
172
|
-
const row = db.prepare("SELECT account FROM sp_ps_receivers WHERE account = ?").get(account);
|
|
173
|
-
|
|
191
|
+
const row = db.prepare("SELECT account, mock FROM sp_ps_receivers WHERE account = ?").get(account);
|
|
192
|
+
// mock 落行不挡真调: mock=1 且当前真调模式 → 重新真调注册 (2026-09-26 实测: mock 期落的行把真调永久挡死)
|
|
193
|
+
const realMode = /^(1|true|yes|on)$/i.test(process.env.WXPAY_PROFITSHARING_ENABLED || "");
|
|
194
|
+
if (row && !(row.mock && realMode)) return { registered: false, reason: "already" };
|
|
174
195
|
const r = await addReceivers([{ account, type: "MERCHANT_ID", name: process.env.SP_NAME || "服务商" }]);
|
|
175
196
|
const one = Array.isArray(r) ? r[0] : r;
|
|
176
197
|
const ok = !!(one && (one.mock || one.status === 200));
|
|
177
198
|
if (ok) {
|
|
178
|
-
db.prepare("INSERT
|
|
199
|
+
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")
|
|
179
200
|
.run(account, Math.floor(Date.now() / 1000), one.mock ? 1 : 0);
|
|
180
201
|
console.log(`[profitsharing] 分账接收方已注册 account=${account} mock=${!!one.mock}`);
|
|
181
202
|
return { registered: true, mock: !!one.mock };
|
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/onboard.html
CHANGED
|
@@ -122,7 +122,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
122
122
|
<body>
|
|
123
123
|
<div class="wrap">
|
|
124
124
|
<h1>TrustBase 商家入驻</h1>
|
|
125
|
-
<p class="sub"
|
|
125
|
+
<p class="sub">在你的电脑上完成入驻,安全词永不出门</p>
|
|
126
126
|
|
|
127
127
|
<div class="spcard" id="spcard" style="display:none">
|
|
128
128
|
<div class="spcard-h"><span>🏪</span><span id="sp-name" style="font-weight:600">服务商</span><span class="spbadge" id="sp-rate" style="display:none"></span></div>
|
|
@@ -134,14 +134,25 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
134
134
|
<h3>入驻进度 <span id="status-meta" style="font-size:12px;color:#8A857B;font-weight:400"></span><button class="sbtn noprint" style="float:right;margin-top:-2px" onclick="loadStatus()">刷新状态</button></h3>
|
|
135
135
|
<div class="step done" id="st-identity">
|
|
136
136
|
<span class="mark">✅</span>
|
|
137
|
-
<div class="body">① 卖家身份 <span id="p-addr" style="font-family:Consolas,monospace;font-size:12px"></span><div class="desc"
|
|
137
|
+
<div class="body">① 卖家身份 <span id="p-addr" style="font-family:Consolas,monospace;font-size:12px"></span><div class="desc">安全卡只保存在这台电脑</div></div>
|
|
138
138
|
</div>
|
|
139
139
|
<div class="step" id="st-backup">
|
|
140
140
|
<span class="mark">⬜</span>
|
|
141
|
-
<div class="body">②
|
|
142
|
-
<div class="
|
|
143
|
-
<div class="
|
|
144
|
-
<div class="hintbox" id="hint-
|
|
141
|
+
<div class="body">② 备份店铺安全卡
|
|
142
|
+
<div class="desc">安全卡 = 店铺的全部资产钥匙。丢了它且电脑坏了,谁也找不回 (服务商也不行)。请抄写或打印收好。</div>
|
|
143
|
+
<div class="ops"><button class="sbtn" onclick="reprintKey()">重新打印安全卡</button><button class="sbtn" onclick="var h=document.getElementById('hint-recover');h.style.display=h.style.display==='block'?'none':'block'">如何恢复店铺</button><button class="sbtn primary" onclick="startBackupVerify()">我已抄好, 验证备份</button></div>
|
|
144
|
+
<div class="hintbox" id="hint-reprint">出于安全,24 个安全词只在生成时在本页展示一次。<br>重新打印请在你的电脑上运行: <code>npx @trustbase/shopxo-init print-key</code></div>
|
|
145
|
+
<div class="hintbox" id="hint-recover">丢了/换电脑,在新机器的入驻页恢复店铺,两种方式:<br>① 扫码: 用手机相机扫安全卡上的二维码,屏幕上会显示 24 个安全词;<br>② 手填: 把安全卡上的 24 个安全词按顺序填到下面。<br>
|
|
146
|
+
<textarea id="restore-words" rows="3" placeholder="24 个安全词, 按顺序, 空格分隔" style="width:100%;margin-top:8px;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:14px"></textarea>
|
|
147
|
+
<button class="sbtn primary" style="margin-top:6px" onclick="restoreIdentity()">恢复店铺</button>
|
|
148
|
+
<div id="restore-msg" style="font-size:13px;margin-top:6px"></div>
|
|
149
|
+
</div>
|
|
150
|
+
<div id="backup-quiz" style="display:none;margin-top:10px;background:#FCFAF4;border:1px solid #EAE3D6;border-radius:12px;padding:14px 16px">
|
|
151
|
+
<div class="desc" style="margin-bottom:4px">确认你真的抄好了 — 请按安全卡上抄写的填写 (答错不会提示正确答案, 可重新抽题):</div>
|
|
152
|
+
<div id="backup-questions"></div>
|
|
153
|
+
<button class="sbtn primary" style="margin-top:10px" onclick="checkBackupVerify()">提交验证</button>
|
|
154
|
+
</div>
|
|
155
|
+
<div id="backup-result"></div>
|
|
145
156
|
</div>
|
|
146
157
|
</div>
|
|
147
158
|
<div class="step" id="st-verification">
|
|
@@ -315,16 +326,19 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
315
326
|
|
|
316
327
|
<div class="keybox" id="keybox">
|
|
317
328
|
<div id="print-area">
|
|
318
|
-
<h3
|
|
329
|
+
<h3>你的店铺安全卡 (这张卡 = 店铺的全部资产钥匙)</h3>
|
|
319
330
|
<div class="warn">
|
|
320
|
-
<b
|
|
321
|
-
|
|
331
|
+
<b>请立即用笔把 24 个安全词抄写到纸上, 抄两份, 分开放好 (防火防水)。</b><br>
|
|
332
|
+
不要拍照、不要截图、不要发到微信/网盘。谁拿到这 24 个词, 谁就能拿走店铺和里面的资产。
|
|
322
333
|
</div>
|
|
334
|
+
<div id="qr-slot" style="text-align:center;margin:10px 0"><img id="qr-img" alt="安全卡二维码" style="width:190px;height:190px;display:none;margin:0 auto;border:1px solid #EAE3D6;border-radius:8px"><div style="font-size:11px;color:#8A857B;margin-top:4px">换新电脑时, 手机相机扫这个码可读出 24 个安全词</div></div>
|
|
323
335
|
<div class="mn-grid" id="mn-grid"></div>
|
|
324
336
|
<div class="mn-addr" id="mn-addr"></div>
|
|
325
|
-
<div style="font-size:12px;color:#6B655B">丢了/换电脑:
|
|
337
|
+
<div style="font-size:12px;color:#6B655B">丢了/换电脑: 新机器上点第②步"如何恢复店铺", 扫码或手填 24 个安全词即可找回店铺。</div>
|
|
338
|
+
<div style="font-size:11px;color:#8A857B;margin-top:6px">打印日期: <span id="print-date"></span></div>
|
|
326
339
|
</div>
|
|
327
|
-
<button class="btn noprint" onclick="window.print()">🖨
|
|
340
|
+
<button class="btn noprint" onclick="window.print()">🖨 打印安全卡</button>
|
|
341
|
+
<button class="sbtn primary noprint" style="margin-left:8px" onclick="startBackupVerify()">我已抄好, 去验证 →</button>
|
|
328
342
|
<span class="noprint" style="font-size:12px;color:#8A857B;margin-left:10px">打印后请关闭本页; 正式打印通道: 安装器 print-key 命令</span>
|
|
329
343
|
</div>
|
|
330
344
|
</div>
|
|
@@ -386,7 +400,7 @@ function renderProgress(s){
|
|
|
386
400
|
if(meta){
|
|
387
401
|
var bits=[];
|
|
388
402
|
if(s.status_queried_at) bits.push('状态更新于 '+new Date(s.status_queried_at*1000).toLocaleTimeString()+' (每 15 秒自动刷新)');
|
|
389
|
-
if(s.sign_mode) bits.push('签名通道 '+s.sign_mode+(s.mnemonic_present?'':' ·
|
|
403
|
+
if(s.sign_mode) bits.push('签名通道 '+s.sign_mode+(s.mnemonic_present?'':' · 未找到本机安全卡'));
|
|
390
404
|
if(s.keyring_dir) bits.push('密钥目录 '+s.keyring_dir);
|
|
391
405
|
meta.textContent=bits.join(' · ');
|
|
392
406
|
}
|
|
@@ -399,8 +413,70 @@ function renderProgress(s){
|
|
|
399
413
|
} else if(g.status==='approved_pending_disburse'){
|
|
400
414
|
document.getElementById('grant-desc').textContent='认证已通过, 等待发放 (自动)';
|
|
401
415
|
}
|
|
402
|
-
// 当前可做项高亮
|
|
403
|
-
document.getElementById('st-backup').className='step current';
|
|
416
|
+
// 当前可做项高亮 (备份验证已过 → 持久打勾 (后端 tb_backup_ack 台账); 否则标当前可做)
|
|
417
|
+
if(s.backup_acked){ markStepDone('st-backup'); } else { document.getElementById('st-backup').className='step current'; }
|
|
418
|
+
}
|
|
419
|
+
// ---- 纸密钥备份回填验证 (2026-10-03, MetaMask 式抽词; 原则: mnemonic 不出本机) ----
|
|
420
|
+
// 首次创建 (助记词还在页面内存 _mn) → 纯前端比对, 不调网络;
|
|
421
|
+
// 页面刷新后 → POST /api/onboard/verify-backup 本机后端比对 (只送位置+词, 后端永不返回正确词)。
|
|
422
|
+
var _bqTries=0;
|
|
423
|
+
function startBackupVerify(){
|
|
424
|
+
// 随机抽 3 个词位 (1-24 不重复, 升序展示)
|
|
425
|
+
var pool=[]; for(var i=1;i<=24;i++) pool.push(i);
|
|
426
|
+
var pos=[];
|
|
427
|
+
while(pos.length<3){ pos.push(pool.splice(Math.floor(Math.random()*pool.length),1)[0]); }
|
|
428
|
+
pos.sort(function(a,b){return a-b;});
|
|
429
|
+
var q=document.getElementById('backup-questions');
|
|
430
|
+
q.innerHTML=pos.map(function(p){
|
|
431
|
+
return '<label style="display:block;font-size:13px;color:#6B655B;margin:8px 0 4px">请按安全卡上抄写的填写第 '+p+' 个词</label>'
|
|
432
|
+
+'<input class="bq-word" data-pos="'+p+'" placeholder="第 '+p+' 个词" autocapitalize="off" autocomplete="off" spellcheck="false" style="width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff">';
|
|
433
|
+
}).join('');
|
|
434
|
+
document.getElementById('backup-result').innerHTML='';
|
|
435
|
+
var box=document.getElementById('backup-quiz');
|
|
436
|
+
box.style.display='block';
|
|
437
|
+
box.scrollIntoView({behavior:'smooth',block:'center'});
|
|
438
|
+
}
|
|
439
|
+
async function checkBackupVerify(){
|
|
440
|
+
var inputs=document.querySelectorAll('.bq-word');
|
|
441
|
+
var positions=[], words=[];
|
|
442
|
+
inputs.forEach(function(el){ positions.push(Number(el.getAttribute('data-pos'))); words.push(el.value); });
|
|
443
|
+
if(words.some(function(w){return !w.trim();})) return backupResult(false,'三个词都要填');
|
|
444
|
+
// ① 内存路径: 助记词还在 (_mn) → 纯前端比对 (更快, 零网络)
|
|
445
|
+
if(_mn){
|
|
446
|
+
var real=_mn.split(/\s+/);
|
|
447
|
+
var wrong=[];
|
|
448
|
+
positions.forEach(function(p,i){ if(words[i].trim().toLowerCase()!==real[p-1].toLowerCase()) wrong.push(p); });
|
|
449
|
+
if(!wrong.length){ await persistBackupAck(); return backupOk(); }
|
|
450
|
+
return backupWrong(wrong);
|
|
451
|
+
}
|
|
452
|
+
// ② 后端路径: 刷新后内存没有 → 本机后端比对 (mnemonic 不出本机)
|
|
453
|
+
try{
|
|
454
|
+
var j=await readJson(await fetch('/api/onboard/verify-backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({positions:positions,words:words})}));
|
|
455
|
+
if(j.ok) return backupOk();
|
|
456
|
+
if(j.error==='rate_limited') return backupResult(false, j.message||'30 秒内只能验证一次, 请稍后再试');
|
|
457
|
+
if(j.wrong_positions&&j.wrong_positions.length) return backupWrong(j.wrong_positions);
|
|
458
|
+
backupResult(false, j.message||'验证失败, 请稍后再试');
|
|
459
|
+
}catch(e){ backupResult(false,'服务暂时不可用,请刷新重试 ('+e.message+')'); }
|
|
460
|
+
}
|
|
461
|
+
async function persistBackupAck(){
|
|
462
|
+
try{ await fetch('/api/onboard/backup-ack',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }catch(e){}
|
|
463
|
+
}
|
|
464
|
+
function backupOk(){
|
|
465
|
+
document.getElementById('backup-quiz').style.display='none';
|
|
466
|
+
markStepDone('st-backup'); // 步骤②打勾 (后端台账已持久化, 换浏览器仍绿)
|
|
467
|
+
backupResult(true,'✅ 备份已确认 — 你的店铺安全卡只有这一份, 请收好');
|
|
468
|
+
_bqTries=0;
|
|
469
|
+
}
|
|
470
|
+
function backupWrong(wrong){
|
|
471
|
+
_bqTries++;
|
|
472
|
+
var tip='⚠️ 第 '+wrong.join('、')+' 个词不对 — 请对照安全卡检查 (不会提示正确答案), 已重新抽题';
|
|
473
|
+
if(_bqTries>=3) tip='⚠️ 第 '+wrong.join('、')+' 个词不对; 已错 3 次, 建议点上方"重新打印安全卡"逐词对照后再试';
|
|
474
|
+
startBackupVerify(); // 重新抽题
|
|
475
|
+
backupResult(false, tip);
|
|
476
|
+
}
|
|
477
|
+
function backupResult(ok, text){
|
|
478
|
+
document.getElementById('backup-result').innerHTML=
|
|
479
|
+
'<div style="'+(ok?'background:#F0FDF4;border:1px solid #BBF7D0;color:#166534':'background:#FEF2F2;border:1px solid #FCA5A5;border-left:4px solid #DC2626;color:#991B1B')+';border-radius:10px;padding:10px 14px;font-size:13px;margin-top:8px;line-height:1.8">'+escHtml(text)+'</div>';
|
|
404
480
|
}
|
|
405
481
|
function reprintKey(){
|
|
406
482
|
if(_mn){
|
|
@@ -733,19 +809,53 @@ async function submit(){
|
|
|
733
809
|
document.getElementById('status').className='status-ok';
|
|
734
810
|
// 2026-09-26 资产安全: 助记词只在首次创建返回; 已有身份时 j.mnemonic 为空
|
|
735
811
|
document.getElementById('status').textContent=j.identity_created===false
|
|
736
|
-
? '✅ 卖家身份已存在 (
|
|
737
|
-
: '✅ 卖家身份已生成 (
|
|
812
|
+
? '✅ 卖家身份已存在 (安全词仅首次创建时展示过)'
|
|
813
|
+
: '✅ 卖家身份已生成 (安全卡已保存在本机)';
|
|
738
814
|
if(j.mnemonic){
|
|
739
815
|
_mn=j.mnemonic;
|
|
740
816
|
var words=j.mnemonic.split(/\s+/);
|
|
741
817
|
document.getElementById('mn-grid').innerHTML=words.map(function(w,i){return '<div class="mn-word"><span class="n">'+(i+1)+'</span>'+w+'</div>'}).join('');
|
|
742
818
|
document.getElementById('mn-addr').textContent=j.tb_address;
|
|
819
|
+
document.getElementById('print-date').textContent=new Date().toLocaleDateString('zh-CN');
|
|
743
820
|
document.getElementById('keybox').style.display='block';
|
|
821
|
+
loadBackupQr(); // 安全卡二维码 (本机 localhost 生成, 不走任何网络)
|
|
744
822
|
document.getElementById('keybox').scrollIntoView({behavior:'smooth'});
|
|
745
823
|
}
|
|
746
824
|
loadStatus(); // 刷新进度面板
|
|
747
825
|
}catch(e){alert('网络错误: '+e.message);btn.disabled=false}
|
|
748
826
|
}
|
|
827
|
+
// 安全卡二维码: 本机后端读 tb-keyring 出 dataURL (qrcode 包无浏览器 build, 且安全词不进 script 依赖链);
|
|
828
|
+
// 只在创建会话 (_mn 在内存) 拉取 — 刷新后不再展示 (与"安全词只展示一次"同口径)
|
|
829
|
+
async function loadBackupQr(){
|
|
830
|
+
if(!_mn) return;
|
|
831
|
+
try{
|
|
832
|
+
var j=await readJson(await fetch('/api/onboard/backup-qrcode'));
|
|
833
|
+
if(j&&j.ok&&j.data_url){
|
|
834
|
+
var img=document.getElementById('qr-img');
|
|
835
|
+
img.src=j.data_url; img.style.display='block';
|
|
836
|
+
}
|
|
837
|
+
}catch(e){ /* 二维码失败不挡安全卡展示 */ }
|
|
838
|
+
}
|
|
839
|
+
// 恢复店铺 (换新电脑): 手填 24 安全词 → 本机后端写入 tb-keyring + 派生地址
|
|
840
|
+
async function restoreIdentity(){
|
|
841
|
+
var ta=document.getElementById('restore-words');
|
|
842
|
+
var msg=document.getElementById('restore-msg');
|
|
843
|
+
var mn=(ta.value||'').trim();
|
|
844
|
+
if(!mn){ msg.style.color='#DC2626'; msg.textContent='请先填写 24 个安全词'; return; }
|
|
845
|
+
msg.style.color=''; msg.textContent='恢复中...';
|
|
846
|
+
try{
|
|
847
|
+
var j=await readJson(await fetch('/api/onboard/restore-identity',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mnemonic:mn})}));
|
|
848
|
+
if(j.ok){
|
|
849
|
+
msg.style.color='#16A34A';
|
|
850
|
+
msg.textContent='✅ 店铺已恢复 ('+j.tb_address+'), 正在刷新...';
|
|
851
|
+
ta.value=''; // 用完即清, 安全词不留页面
|
|
852
|
+
loadStatus();
|
|
853
|
+
}else{
|
|
854
|
+
msg.style.color='#DC2626';
|
|
855
|
+
msg.textContent=j.message||'恢复失败';
|
|
856
|
+
}
|
|
857
|
+
}catch(e){ msg.style.color='#DC2626'; msg.textContent='服务暂时不可用: '+e.message; }
|
|
858
|
+
}
|
|
749
859
|
</script>
|
|
750
860
|
<script>
|
|
751
861
|
/* ===== ③ 进件表单 美化增强 (2026-10-03) —— 纯追加脚本: 不修改/不覆盖任何现有函数,
|
package/routes/onboard.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
const fs = require("fs");
|
|
27
27
|
const path = require("path");
|
|
28
28
|
const crypto = require("crypto");
|
|
29
|
-
const { ensureSellerIdentity } = require("../core/seller-identity");
|
|
29
|
+
const { ensureSellerIdentity, readSellerIdentity } = require("../core/seller-identity");
|
|
30
30
|
const basePaths = require("../core/paths");
|
|
31
31
|
const { isTctAddress } = require("../core/address");
|
|
32
32
|
const chainWrites = require("./chain-writes");
|
|
@@ -163,10 +163,13 @@ function createOnboardRouter(deps) {
|
|
|
163
163
|
// 签名通道现状: 页面/客服诊断用 (只回路径与模式, 绝不回助记词/私钥)
|
|
164
164
|
let signMode = null;
|
|
165
165
|
try { signMode = chainWrites.signModeInfo(); } catch (_) { signMode = null; }
|
|
166
|
+
let backupOk = false;
|
|
167
|
+
try { backupOk = !!db.prepare("SELECT acked_at FROM tb_backup_ack WHERE id=1").get(); } catch (_) { backupOk = false; }
|
|
166
168
|
res.json({
|
|
167
169
|
ok: true,
|
|
168
170
|
identity_created: !!tbAddress,
|
|
169
171
|
tb_address: tbAddress,
|
|
172
|
+
backup_acked: backupOk, // 纸密钥备份验证标记 (tb_backup_ack 台账, 换浏览器仍绿)
|
|
170
173
|
shop_name: shop ? shop.merchant_shortname : null,
|
|
171
174
|
sub_mchid: (applyment && applyment.sub_mchid) || (shop ? shop.sub_mchid : "none"),
|
|
172
175
|
verification,
|
|
@@ -188,7 +191,7 @@ function createOnboardRouter(deps) {
|
|
|
188
191
|
shop_admin_user: (process.env.SHOP_ADMIN_USER || "admin").trim(), // 密码绝不进 API
|
|
189
192
|
steps: [
|
|
190
193
|
{ key: "identity", label: "卖家身份", done: !!tbAddress },
|
|
191
|
-
{ key: "backup", label: "
|
|
194
|
+
{ key: "backup", label: "备份店铺安全卡", done: backupOk },
|
|
192
195
|
{ key: "verification", label: "微信收款认证", done: applymentDone, kyc_approved: /approved/i.test(verification.status) },
|
|
193
196
|
{ key: "grant", label: "引导金 100 TCT", done: grant.status === "issued" },
|
|
194
197
|
{ key: "listing", label: "上架商品", done: false },
|
|
@@ -459,6 +462,134 @@ function createOnboardRouter(deps) {
|
|
|
459
462
|
}
|
|
460
463
|
});
|
|
461
464
|
|
|
465
|
+
// ---------- 纸密钥备份回填验证 (2026-10-03 xiaomu: MetaMask 式抽词验证) ----------
|
|
466
|
+
// 原则: mnemonic 永不出本机 — 页面只送 {positions, words}, 本机后端读 tb-keyring 比对,
|
|
467
|
+
// 永不返回正确词; 限流每 30s 一次 (防在线爆破, 虽然攻击面只是 localhost)。
|
|
468
|
+
// 验证通过 → tb_backup_ack 落标记 (sqlite 单行表), /onboard/status 读它让步骤②持久打勾。
|
|
469
|
+
function ensureBackupAckTable() {
|
|
470
|
+
db.prepare("CREATE TABLE IF NOT EXISTS tb_backup_ack (id INTEGER PRIMARY KEY CHECK(id=1), acked_at INTEGER NOT NULL)").run();
|
|
471
|
+
}
|
|
472
|
+
ensureBackupAckTable();
|
|
473
|
+
function backupAcked() {
|
|
474
|
+
try { return !!db.prepare("SELECT acked_at FROM tb_backup_ack WHERE id=1").get(); } catch (_) { return false; }
|
|
475
|
+
}
|
|
476
|
+
function markBackupAcked() {
|
|
477
|
+
ensureBackupAckTable();
|
|
478
|
+
db.prepare("INSERT INTO tb_backup_ack (id, acked_at) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET acked_at=excluded.acked_at")
|
|
479
|
+
.run(Math.floor(Date.now() / 1000));
|
|
480
|
+
}
|
|
481
|
+
const backupVerifyRate = new Map(); // ip → last attempt ms (单商户本机场景, 内存即可)
|
|
482
|
+
router.post("/verify-backup", (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
const b = req.body || {};
|
|
485
|
+
const positions = Array.isArray(b.positions) ? b.positions.map(Number) : [];
|
|
486
|
+
const words = Array.isArray(b.words) ? b.words.map((w) => String(w)) : [];
|
|
487
|
+
// 先参数校验 (烂请求不吃限流额度), 再限流, 最后读密钥
|
|
488
|
+
if (!positions.length || positions.length !== words.length || positions.length > 5) {
|
|
489
|
+
return res.status(400).json({ ok: false, error: "invalid_param", message: "positions/words 必填且等长 (≤5 个)" });
|
|
490
|
+
}
|
|
491
|
+
if (positions.some((p) => !Number.isInteger(p) || p < 1 || p > 24)) {
|
|
492
|
+
return res.status(400).json({ ok: false, error: "invalid_param", message: "positions 必须是 1-24 的整数" });
|
|
493
|
+
}
|
|
494
|
+
const ip = req.ip || "local";
|
|
495
|
+
const last = backupVerifyRate.get(ip) || 0;
|
|
496
|
+
if (Date.now() - last < 30000) {
|
|
497
|
+
return res.status(429).json({ ok: false, error: "rate_limited", message: "30 秒内只能验证一次, 请对照纸密钥稍后再试" });
|
|
498
|
+
}
|
|
499
|
+
backupVerifyRate.set(ip, Date.now());
|
|
500
|
+
let realWords;
|
|
501
|
+
try {
|
|
502
|
+
realWords = fs.readFileSync(basePaths.mnemonicFile(), "utf8").trim().split(/\s+/);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
return res.status(404).json({ ok: false, error: "no_key", message: "本机还没有卖家密钥 (先完成第①步生成身份)" });
|
|
505
|
+
}
|
|
506
|
+
if (realWords.length < 24) {
|
|
507
|
+
return res.status(500).json({ ok: false, error: "key_corrupt", message: "本机密钥文件异常, 请联系服务商" });
|
|
508
|
+
}
|
|
509
|
+
const wrong = [];
|
|
510
|
+
positions.forEach((p, i) => {
|
|
511
|
+
if (String(words[i] || "").trim().toLowerCase() !== realWords[p - 1].toLowerCase()) wrong.push(p);
|
|
512
|
+
});
|
|
513
|
+
if (wrong.length === 0) {
|
|
514
|
+
markBackupAcked();
|
|
515
|
+
console.log("[onboard] 纸密钥备份验证通过 (ack 落台账)");
|
|
516
|
+
return res.json({ ok: true });
|
|
517
|
+
}
|
|
518
|
+
// 不泄答案: 只说哪几个位置错了
|
|
519
|
+
return res.json({ ok: false, wrong_positions: wrong, message: "有词不对, 请对照纸上抄写检查 (不会提示正确答案)" });
|
|
520
|
+
} catch (e) {
|
|
521
|
+
console.error("[onboard] verify-backup error:", e && e.message);
|
|
522
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
/** 纯前端比对路径 (首次创建安全词还在页面内存, 未过后端验证) 的 ack 落账入口 — 不带任何密钥材料 */
|
|
526
|
+
router.post("/backup-ack", (req, res) => {
|
|
527
|
+
markBackupAcked();
|
|
528
|
+
res.json({ ok: true });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ---------- 安全卡二维码 + 恢复店铺 (2026-10-03 xiaomu: 安全卡打印版式升级) ----------
|
|
532
|
+
/** GET /backup-qrcode — 安全卡二维码 (内容 JSON {v:1,type:'tb-backup',mnemonic} 含 24 安全词)。
|
|
533
|
+
* 只在本机 localhost 生成/展示 (qrcode 包服务端 toDataURL; 该包无浏览器 build, 且安全词
|
|
534
|
+
* 不应进页面 script 依赖链)。页面仅在创建会话 (_mn 在内存) 时拉取展示。 */
|
|
535
|
+
router.get("/backup-qrcode", async (req, res) => {
|
|
536
|
+
try {
|
|
537
|
+
const identity = await readSellerIdentity(basePaths.baseDir()); // 只读不生成
|
|
538
|
+
if (!identity) {
|
|
539
|
+
return res.status(404).json({ ok: false, error: "no_key", message: "本机还没有店铺身份 (先完成第①步)" });
|
|
540
|
+
}
|
|
541
|
+
const { toDataURL } = require("qrcode");
|
|
542
|
+
const dataUrl = await toDataURL(JSON.stringify({ v: 1, type: "tb-backup", mnemonic: identity.mnemonic }), { margin: 1, width: 320 });
|
|
543
|
+
res.json({ ok: true, data_url: dataUrl });
|
|
544
|
+
} catch (e) {
|
|
545
|
+
console.error("[onboard] backup-qrcode error:", e && e.message);
|
|
546
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
/** POST /restore-identity {mnemonic} — 恢复店铺 (换新电脑): 24 安全词写 tb-keyring/mnemonic.txt
|
|
551
|
+
* (0600) + 派生地址。已有身份 → 409 (防覆盖); BIP39 校验位不过 → 400; 限流 30s。 */
|
|
552
|
+
const restoreRate = new Map();
|
|
553
|
+
router.post("/restore-identity", async (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
const mn = String((req.body || {}).mnemonic || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
556
|
+
const words = mn ? mn.split(" ") : [];
|
|
557
|
+
// 先校验 (烂请求不吃限流额度), 再限流, 最后写盘
|
|
558
|
+
if (words.length !== 24) {
|
|
559
|
+
return res.status(400).json({ ok: false, error: "invalid_mnemonic", message: "请填写完整的 24 个安全词 (空格分隔)" });
|
|
560
|
+
}
|
|
561
|
+
// 已有身份 → 拒 (防覆盖别人的店)
|
|
562
|
+
const existing = await readSellerIdentity(basePaths.baseDir());
|
|
563
|
+
if (existing) {
|
|
564
|
+
return res.status(409).json({ ok: false, error: "identity_exists", message: "这台电脑已有店铺身份 (" + existing.address + "), 不能覆盖。如需换店请先备份再联系服务商" });
|
|
565
|
+
}
|
|
566
|
+
// BIP39 校验位验证 (错词/乱序会拒)
|
|
567
|
+
try {
|
|
568
|
+
const { Bip39, EnglishMnemonic } = require("@cosmjs/crypto");
|
|
569
|
+
Bip39.decode(new EnglishMnemonic(mn));
|
|
570
|
+
} catch (e) {
|
|
571
|
+
return res.status(400).json({ ok: false, error: "invalid_mnemonic", message: "安全词校验不过 (有错词或顺序不对), 请对照安全卡逐词检查" });
|
|
572
|
+
}
|
|
573
|
+
const ip = req.ip || "local";
|
|
574
|
+
const last = restoreRate.get(ip) || 0;
|
|
575
|
+
if (Date.now() - last < 30000) {
|
|
576
|
+
return res.status(429).json({ ok: false, error: "rate_limited", message: "30 秒内只能恢复一次, 请稍后再试" });
|
|
577
|
+
}
|
|
578
|
+
restoreRate.set(ip, Date.now());
|
|
579
|
+
fs.mkdirSync(basePaths.keyringDir(), { recursive: true });
|
|
580
|
+
fs.writeFileSync(basePaths.mnemonicFile(), mn + "\n", { encoding: "utf8", mode: 0o600 });
|
|
581
|
+
// 删旧 address.txt 强制按新安全词重新派生 (restore 场景理论上是全新机器没有它,
|
|
582
|
+
// 但测试/异常残留时必须防"新词配旧地址"的错位身份)
|
|
583
|
+
try { fs.unlinkSync(path.join(basePaths.keyringDir(), "address.txt")); } catch (_) { /* 不存在 */ }
|
|
584
|
+
const identity = await ensureSellerIdentity(basePaths.baseDir()); // 读刚写的安全词 → 派生地址补写 address.txt
|
|
585
|
+
console.log("[onboard] restore-identity 恢复成功 addr:", identity.address);
|
|
586
|
+
res.json({ ok: true, tb_address: identity.address, message: "店铺已恢复, 刷新页面即可看到进度" });
|
|
587
|
+
} catch (e) {
|
|
588
|
+
console.error("[onboard] restore-identity error:", e && e.message);
|
|
589
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
462
593
|
// ---------- 微信特约商户进件 (Stage 2, 2026-10-03 架构纠偏: 卖家本机提交 → SP 网关 → 微信) ----------
|
|
463
594
|
const spApplyment = require("./sp-applyment");
|
|
464
595
|
/** 在卖家本机就要加密的敏感字段 (与 sp-applyment buildApplymentBody 加密清单一致;
|
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) {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-onboard-backup.js — 纸密钥备份回填验证 (MetaMask 式抽词) 离线单测 (2026-10-03)
|
|
4
|
+
* node --test test-onboard-backup.js
|
|
5
|
+
*
|
|
6
|
+
* 覆盖:
|
|
7
|
+
* POST /api/onboard/verify-backup — 正确词 ok+台账落 ack / 错词 wrong_positions (不泄答案)
|
|
8
|
+
* / 限流 30s / 参数校验 / 无密钥 404
|
|
9
|
+
* POST /api/onboard/backup-ack — 纯前端比对路径的 ack 落账
|
|
10
|
+
* GET /onboard/status — backup_acked + steps[1].done 持久化
|
|
11
|
+
* 安全断言: 任何响应体都不得包含真实助记词内容。
|
|
12
|
+
*/
|
|
13
|
+
const test = require("node:test");
|
|
14
|
+
const assert = require("node:assert");
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const express = require("express");
|
|
19
|
+
const Database = require("better-sqlite3");
|
|
20
|
+
|
|
21
|
+
// 测试助记词 (24 词, 落临时 tb-keyring — 隔离不碰真机)
|
|
22
|
+
// 必须用合法 BIP39 (EnglishMnemonic 会校验 checksum): 直接复用 seller-identity 测试向量
|
|
23
|
+
const VECTOR_MNEMONIC = "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone";
|
|
24
|
+
const WORDS = VECTOR_MNEMONIC.split(" ");
|
|
25
|
+
// 恢复测试用助记词: 测试运行时真生成 (合法 BIP39 24 词)
|
|
26
|
+
let RESTORE_MNEMONIC = "";
|
|
27
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "backup-verify-test-"));
|
|
28
|
+
fs.mkdirSync(path.join(tmpDir, "tb-keyring"), { recursive: true });
|
|
29
|
+
fs.writeFileSync(path.join(tmpDir, "tb-keyring", "mnemonic.txt"), WORDS.join(" "), { mode: 0o600 });
|
|
30
|
+
|
|
31
|
+
// env 必须在 require 前 (core/paths freeze)
|
|
32
|
+
process.env.TB_BASE_DIR = tmpDir;
|
|
33
|
+
|
|
34
|
+
const onboard = require("./routes/onboard");
|
|
35
|
+
|
|
36
|
+
async function startServer() {
|
|
37
|
+
const db = new Database(":memory:");
|
|
38
|
+
const app = express();
|
|
39
|
+
app.use(express.json());
|
|
40
|
+
const router = onboard.createOnboardRouter({ db });
|
|
41
|
+
app.use("/api/onboard", router);
|
|
42
|
+
app.use("/onboard", router);
|
|
43
|
+
const srv = app.listen(0);
|
|
44
|
+
await new Promise((r) => srv.once("listening", r));
|
|
45
|
+
return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const H = { "Content-Type": "application/json" };
|
|
49
|
+
|
|
50
|
+
test("verify-backup: 正确词 → ok + ack 落台账 + status 步骤② done 持久化", async () => {
|
|
51
|
+
const { srv, base } = await startServer();
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch(base + "/api/onboard/verify-backup", {
|
|
54
|
+
method: "POST", headers: H,
|
|
55
|
+
body: JSON.stringify({ positions: [3, 7, 24], words: ["theory", "betray", "stone"] }),
|
|
56
|
+
});
|
|
57
|
+
const j = await r.json();
|
|
58
|
+
assert.strictEqual(r.status, 200);
|
|
59
|
+
assert.strictEqual(j.ok, true, JSON.stringify(j));
|
|
60
|
+
// status: backup_acked + steps[1].done
|
|
61
|
+
const st = await (await fetch(base + "/onboard/status")).json();
|
|
62
|
+
assert.strictEqual(st.backup_acked, true);
|
|
63
|
+
assert.strictEqual(st.steps[1].key, "backup");
|
|
64
|
+
assert.strictEqual(st.steps[1].done, true, "步骤②持久打勾 (换浏览器仍绿)");
|
|
65
|
+
} finally { srv.close(); }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("verify-backup: 错词 → wrong_positions (不泄正确答案) + 大小写/空白容忍", async () => {
|
|
69
|
+
const { srv, base } = await startServer();
|
|
70
|
+
try {
|
|
71
|
+
// 第 3 词故意错 (charlie→wrong), 第 7 词大小写+空白混合也对 (容忍)
|
|
72
|
+
const r = await fetch(base + "/api/onboard/verify-backup", {
|
|
73
|
+
method: "POST", headers: H,
|
|
74
|
+
body: JSON.stringify({ positions: [3, 7, 11], words: ["wrongword", " BETRAY ", "Task"] }),
|
|
75
|
+
});
|
|
76
|
+
const j = await r.json();
|
|
77
|
+
assert.strictEqual(r.status, 200);
|
|
78
|
+
assert.strictEqual(j.ok, false);
|
|
79
|
+
assert.deepStrictEqual(j.wrong_positions, [3], "只报第 3 个错 (7/11 大小写空白容忍)");
|
|
80
|
+
// 不泄答案: 响应体不得含任何真实词
|
|
81
|
+
const raw = JSON.stringify(j);
|
|
82
|
+
for (const w of WORDS) assert.ok(!raw.includes(w), "响应泄露了真实词: " + w);
|
|
83
|
+
} finally { srv.close(); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("verify-backup: 限流 30s (同 IP 连续两次 → 第二次 429)", async () => {
|
|
87
|
+
const { srv, base } = await startServer();
|
|
88
|
+
try {
|
|
89
|
+
const body = JSON.stringify({ positions: [1, 2, 3], words: ["economy", "stock", "theory"] });
|
|
90
|
+
const r1 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
|
|
91
|
+
assert.strictEqual(r1.status, 200);
|
|
92
|
+
const r2 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
|
|
93
|
+
assert.strictEqual(r2.status, 429);
|
|
94
|
+
assert.strictEqual((await r2.json()).error, "rate_limited");
|
|
95
|
+
} finally { srv.close(); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("verify-backup: 参数校验 (空 positions / 越界位置 / 不等长 → 400)", async () => {
|
|
99
|
+
const { srv, base } = await startServer();
|
|
100
|
+
try {
|
|
101
|
+
for (const body of [
|
|
102
|
+
{ positions: [], words: [] },
|
|
103
|
+
{ positions: [0, 25], words: ["a", "b"] }, // 越界 (1-24)
|
|
104
|
+
{ positions: [1, 2], words: ["alpha"] }, // 不等长
|
|
105
|
+
]) {
|
|
106
|
+
const r = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body: JSON.stringify(body) });
|
|
107
|
+
assert.strictEqual(r.status, 400, JSON.stringify(body));
|
|
108
|
+
}
|
|
109
|
+
} finally { srv.close(); }
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("backup-ack: 纯前端比对路径落账 (不带密钥材料) + status done", async () => {
|
|
113
|
+
const { srv, base } = await startServer();
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetch(base + "/api/onboard/backup-ack", { method: "POST", headers: H, body: "{}" });
|
|
116
|
+
assert.strictEqual((await r.json()).ok, true);
|
|
117
|
+
const st = await (await fetch(base + "/onboard/status")).json();
|
|
118
|
+
assert.strictEqual(st.backup_acked, true);
|
|
119
|
+
assert.strictEqual(st.steps[1].done, true);
|
|
120
|
+
} finally { srv.close(); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("页面含备份验证 UI (抽题/提交/结果显示) + 内存路径分支", async () => {
|
|
124
|
+
const { srv, base } = await startServer();
|
|
125
|
+
try {
|
|
126
|
+
const html = await (await fetch(base + "/onboard/")).text();
|
|
127
|
+
assert.ok(html.includes("startBackupVerify"), "抽题入口");
|
|
128
|
+
assert.ok(html.includes("backup-quiz") && html.includes("bq-word"), "回填控件");
|
|
129
|
+
assert.ok(html.includes("/api/onboard/verify-backup"), "后端比对路径");
|
|
130
|
+
assert.ok(html.includes("persistBackupAck"), "内存路径 ack 落账");
|
|
131
|
+
assert.ok(html.includes("我已抄好"), "大白话文案");
|
|
132
|
+
} finally { srv.close(); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- 安全卡二维码 + 恢复店铺 (2026-10-03) ----
|
|
136
|
+
const KEYRING = path.join(tmpDir, "tb-keyring");
|
|
137
|
+
|
|
138
|
+
test("backup-qrcode: 本机出二维码 dataURL (安全词只在 localhost 流转)", async () => {
|
|
139
|
+
const { srv, base } = await startServer();
|
|
140
|
+
try {
|
|
141
|
+
const r = await fetch(base + "/api/onboard/backup-qrcode");
|
|
142
|
+
const j = await r.json();
|
|
143
|
+
assert.strictEqual(r.status, 200);
|
|
144
|
+
assert.ok(j.data_url.startsWith("data:image/png;base64,"), "QR dataURL");
|
|
145
|
+
assert.ok(j.data_url.length > 500, "QR 有内容");
|
|
146
|
+
} finally { srv.close(); }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("restore-identity: 写入+派生地址正确 (测试向量) / 已有身份 409 / 烂词 400", async () => {
|
|
150
|
+
const { srv, base } = await startServer();
|
|
151
|
+
const bak = path.join(KEYRING, "mnemonic.txt.bak");
|
|
152
|
+
try {
|
|
153
|
+
// 已有身份 (模块加载时写的向量助记词) → 409
|
|
154
|
+
let r = await fetch(base + "/api/onboard/restore-identity", { method: "POST", headers: H, body: JSON.stringify({ mnemonic: VECTOR_MNEMONIC }) });
|
|
155
|
+
assert.strictEqual(r.status, 409);
|
|
156
|
+
assert.strictEqual((await r.json()).error, "identity_exists");
|
|
157
|
+
// 词数不对 → 400 (词数校验先于身份检查)
|
|
158
|
+
r = await fetch(base + "/api/onboard/restore-identity", { method: "POST", headers: H, body: JSON.stringify({ mnemonic: "alpha bravo charlie" }) });
|
|
159
|
+
assert.strictEqual(r.status, 400);
|
|
160
|
+
// 挪走现有身份 → 24 个合法词但校验位不过 → 400 (abandon×24 校验位非法;
|
|
161
|
+
// 注意 abandon×23+art 恰是 BIP39 全零熵官方合法向量, 别拿它当反例)
|
|
162
|
+
fs.renameSync(path.join(KEYRING, "mnemonic.txt"), bak);
|
|
163
|
+
r = await fetch(base + "/api/onboard/restore-identity", { method: "POST", headers: H, body: JSON.stringify({ mnemonic: "abandon ".repeat(23) + "abandon" }) });
|
|
164
|
+
assert.strictEqual(r.status, 400);
|
|
165
|
+
// 合法助记词恢复 → 地址与独立派生一致
|
|
166
|
+
RESTORE_MNEMONIC = await require("./core/seller-identity").generateMnemonic();
|
|
167
|
+
r = await fetch(base + "/api/onboard/restore-identity", { method: "POST", headers: H, body: JSON.stringify({ mnemonic: RESTORE_MNEMONIC }) });
|
|
168
|
+
const j = await r.json();
|
|
169
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
170
|
+
const expectAddr = await require("./core/seller-identity").mnemonicToAddress(RESTORE_MNEMONIC);
|
|
171
|
+
assert.strictEqual(j.tb_address, expectAddr, "派生地址与独立派生一致");
|
|
172
|
+
assert.strictEqual(fs.readFileSync(path.join(KEYRING, "mnemonic.txt"), "utf8").trim(), RESTORE_MNEMONIC);
|
|
173
|
+
assert.strictEqual(fs.readFileSync(path.join(KEYRING, "address.txt"), "utf8").trim(), expectAddr, "address.txt 补写");
|
|
174
|
+
} finally {
|
|
175
|
+
// 还原原始测试密钥
|
|
176
|
+
try { fs.unlinkSync(path.join(KEYRING, "mnemonic.txt")); } catch (_) {}
|
|
177
|
+
try { fs.unlinkSync(path.join(KEYRING, "address.txt")); } catch (_) {}
|
|
178
|
+
if (fs.existsSync(bak)) fs.renameSync(bak, path.join(KEYRING, "mnemonic.txt"));
|
|
179
|
+
srv.close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("页面: 安全卡二维码容器 + 打印日期 + 恢复入口 + 打印样式", async () => {
|
|
184
|
+
const { srv, base } = await startServer();
|
|
185
|
+
try {
|
|
186
|
+
const html = await (await fetch(base + "/onboard/")).text();
|
|
187
|
+
assert.ok(html.includes("qr-slot") && html.includes("qr-img"), "二维码容器");
|
|
188
|
+
assert.ok(html.includes("print-date"), "打印日期");
|
|
189
|
+
assert.ok(html.includes("restore-words") && html.includes("/api/onboard/restore-identity"), "恢复入口");
|
|
190
|
+
assert.ok(html.includes("@media print"), "打印样式");
|
|
191
|
+
assert.ok(html.includes("backup-qrcode"), "QR 数据源");
|
|
192
|
+
// 术语红线: 商户可见文案不再有 助记词/纸密钥 (注释/代码除外)
|
|
193
|
+
const visible = html.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
194
|
+
assert.ok(!visible.includes("纸密钥"), "残留 纸密钥");
|
|
195
|
+
assert.ok(!visible.includes("助记词"), "残留 助记词");
|
|
196
|
+
assert.ok(html.includes("店铺安全卡") && html.includes("安全词"), "新口径");
|
|
197
|
+
} finally { srv.close(); }
|
|
198
|
+
});
|