trustbase-seller-backend 0.2.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -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?,
@@ -169,13 +184,15 @@ async function registerDefaultReceivers(db) {
169
184
  console.warn("[profitsharing] 未配 WXPAY_SP_MCHID/WXPAY_MCHID, 跳过接收方注册");
170
185
  return { registered: false, reason: "no_mchid" };
171
186
  }
172
- const row = db.prepare("SELECT account FROM sp_ps_receivers WHERE account = ?").get(account);
173
- if (row) return { registered: false, reason: "already" };
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" };
174
191
  const r = await addReceivers([{ account, type: "MERCHANT_ID", name: process.env.SP_NAME || "服务商" }]);
175
192
  const one = Array.isArray(r) ? r[0] : r;
176
193
  const ok = !!(one && (one.mock || one.status === 200));
177
194
  if (ok) {
178
- db.prepare("INSERT OR IGNORE INTO sp_ps_receivers (account, registered_at, mock) VALUES (?,?,?)")
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")
179
196
  .run(account, Math.floor(Date.now() / 1000), one.mock ? 1 : 0);
180
197
  console.log(`[profitsharing] 分账接收方已注册 account=${account} mock=${!!one.mock}`);
181
198
  return { registered: true, mock: !!one.mock };
@@ -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
- // 触发既有链同步与 C5 申报 → 由插件 hook 侧/后续 E6 接线; 此处先置状态。
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: row.state === "CREATED" || row.state === "EXPIRED" ? "UNPAID" : "PAID", rail: "mock", txn_id: row.psp_txn_id || null },
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 → 这里; PoC LAN 无鉴权, order_ref 为主键) */
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
- // 状态机: 仅 PAID/ON_CHAIN 可转 SHIPPED
493
- if (!["PAID", "ON_CHAIN", "SHIPPED", "DELIVERED"].includes(row.state)) {
494
- return err(res, 409, "STATE_INVALID", { current: row.state });
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
- db.prepare("UPDATE agent_pay_sessions SET state='SHIPPED', carrier=?, tracking_no=?, logistics_state=?, timeline=?, shopxo_order_no_ref=?, updated_at=? WHERE id=?")
497
- .run(String(b.carrier || ""), String(b.tracking_no || ""), String(b.logistics_state || "SHIPPED"), JSON.stringify(b.timeline || []), String(b.shopxo_order_no || ""), now, row.id);
498
- console.log(`[agent-pay] SHIPPED ${b.order_ref} carrier=${b.carrier} tracking=${b.tracking_no}`);
499
- res.json({ ok: true, state: "SHIPPED" });
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
- module.exports = { createAgentPayRouter, startReconcile, runReconcileOnce, wxSignAuth, wxAesGcmDecrypt, wxVerifyNotifySignature, wxDecryptNotifyResource, wxNativeOrder, wxQueryOrder, refreshPlatformCerts };
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) {
@@ -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
+ });
@@ -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
+ });