trustbase-seller-backend 0.6.0 → 0.7.0

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.
@@ -88,6 +88,8 @@ function runStoreMigrations(db) {
88
88
  `ALTER TABLE store_products ADD COLUMN chain_listed_at INTEGER`,
89
89
  `ALTER TABLE store_products ADD COLUMN chain_tx_hash TEXT`,
90
90
  `ALTER TABLE store_products ADD COLUMN chain_stake_per_unit INTEGER`,
91
+ // A1 (2026-10-05): 商品类型驱动收货地址 — physical(默认) 实物 / virtual 虚拟
92
+ `ALTER TABLE store_products ADD COLUMN product_type TEXT NOT NULL DEFAULT 'physical'`,
91
93
  ];
92
94
  for (const stmt of alterStatements) {
93
95
  try {
@@ -165,7 +165,10 @@ function isTunnelBuyerRequest(req, clientIP) {
165
165
  const fromLoopback = clientIP === '127.0.0.1' || clientIP === '::1' || /^127\./.test(clientIP);
166
166
  if (!fromLoopback) return false;
167
167
  if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
168
- return TUNNEL_BUYER_PATHS.some(p => req.path.startsWith(p));
168
+ // 注意: 本中间件挂在 '/api' 上, req.path 是挂载后路径 ('/products'), req.originalUrl 才带 '/api'
169
+ // → 两种前缀都要判, 否则放行条件恒为假 (2026-10-03 .211 实测 401 才发现)
170
+ const paths = [String(req.path || ''), String(req.originalUrl || '').split('?')[0]];
171
+ return TUNNEL_BUYER_PATHS.some(p => paths.some(x => x === p || x.startsWith(p + '/') || x.startsWith(p)));
169
172
  }
170
173
 
171
174
  function requireAPIKey(skipLAN = true, exemptPaths = []) { return (req, res, next) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS agent_pay_sessions (
44
44
  pay_token_hash TEXT NOT NULL,
45
45
  status_token_hash TEXT NOT NULL,
46
46
  state TEXT NOT NULL DEFAULT 'CREATED', -- CREATED/PAID/ON_CHAIN/SHIPPED/DELIVERED/CONFIRMED/CLOSED/EXPIRED/REFUNDED/DISPUTED
47
+ sub_mchid TEXT DEFAULT '', -- Lane C: 本单所属特约商户 (多商户; 查单/对账按它选 partner 端点)
47
48
  psp_code_url TEXT DEFAULT '',
48
49
  psp_txn_id TEXT DEFAULT '',
49
50
  shopxo_order_no TEXT DEFAULT '',
@@ -62,9 +63,16 @@ CREATE TABLE IF NOT EXISTS psp_txns (
62
63
  created_at INTEGER NOT NULL
63
64
  );
64
65
  CREATE INDEX IF NOT EXISTS idx_aps_state ON agent_pay_sessions(state);
65
- `);
66
- // 2026-09-20 E4': 物流列 (老库兼容)
67
- for (const col of ["carrier TEXT DEFAULT ''", "tracking_no TEXT DEFAULT ''", "logistics_state TEXT DEFAULT ''", "timeline TEXT DEFAULT '[]'", "shopxo_order_no_ref TEXT DEFAULT ''"]) {
66
+ `);
67
+ // Lane C (2026-10-03): 旧库补列 — CREATE TABLE IF NOT EXISTS 不会给已有表加列
68
+ try {
69
+ const _c = db.prepare("PRAGMA table_info(agent_pay_sessions)").all().map((x) => x.name);
70
+ if (!_c.includes("sub_mchid")) db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN sub_mchid TEXT DEFAULT ''");
71
+ } catch (_) {}
72
+
73
+ // 2026-09-20 E4': 物流列 (老库兼容) + A1 收货地址/交付凭据列
74
+ for (const col of ["carrier TEXT DEFAULT ''", "tracking_no TEXT DEFAULT ''", "logistics_state TEXT DEFAULT ''", "timeline TEXT DEFAULT '[]'", "shopxo_order_no_ref TEXT DEFAULT ''",
75
+ "shipping_address TEXT DEFAULT ''", "delivery_credential TEXT DEFAULT ''"]) {
68
76
  try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
69
77
  }
70
78
  }
@@ -138,13 +146,21 @@ async function wxApiCall(method, urlPath, bodyObj, override) {
138
146
  // opts.settleInfo (2026-10-06 U7): 可选, 如 { profit_sharing: true } — 微信规则: 订单下单时
139
147
  // 不带 settle_info.profit_sharing 的分账请求会被拒; 默认不带 (向后兼容, 直连模式不用传)
140
148
  async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opts) {
149
+ // Lane C (2026-10-03): sub_mchid 按本单商家传入 (多商户), 未传才回落全局 env (单商户旧部署)
150
+ const subMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
151
+ if (opts && opts.subMchidSource && opts.subMchidSource !== "registry") {
152
+ console.warn(
153
+ `[agent-pay] 本单 sub_mchid 来源=${opts.subMchidSource} (非登记表), 使用 ${subMchid || "直连模式"}` +
154
+ (subMchid ? " — 多商户场景请核对该商户是否已进件登记" : "")
155
+ );
156
+ }
141
157
  // SP 模式: 用服务商身份签名 + 请求体带 sp_mchid + sub_mchid
142
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
158
+ const isSp = !!(WX.spMchid && subMchid);
143
159
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
144
160
  const body = {
145
161
  // 直连模式: appid = 商户公众号; SP 模式: 微信不认 appid, 要 sp_appid(服务商) + sub_appid(特约商户)
146
162
  ...(isSp
147
- ? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: process.env.WXPAY_SUB_MCHID, sub_appid: WX.subAppid || WX.appid }
163
+ ? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: subMchid, sub_appid: WX.subAppid || WX.appid }
148
164
  : { appid: WX.appid, mchid: WX.mchid }),
149
165
  description: String(description).slice(0, 127),
150
166
  out_trade_no: outTradeNo, time_expire: new Date(expireSec * 1000).toISOString(),
@@ -161,11 +177,13 @@ async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opt
161
177
  return r.json.code_url;
162
178
  }
163
179
  // 查单: trade_state ∈ {SUCCESS, NOTPAY, CLOSED, ...} (SP 模式走 partner endpoint + 双 mchid 查询串)
164
- async function wxQueryOrder(outTradeNo) {
165
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
180
+ async function wxQueryOrder(outTradeNo, opts) {
181
+ // Lane C: 本单商户号优先, 回落全局 env (多商户)
182
+ const qSubMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
183
+ const isSp = !!(WX.spMchid && qSubMchid);
166
184
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
167
185
  const endpoint = isSp
168
- ? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${process.env.WXPAY_SUB_MCHID}`
186
+ ? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${qSubMchid}`
169
187
  : `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${WX.mchid}`;
170
188
  const r = await wxApiCall("GET", endpoint, null, override);
171
189
  if (r.status !== 200) return { state: "UNKNOWN", raw: r.json };
@@ -210,7 +228,8 @@ function loadPlatformCertsFromDisk() {
210
228
 
211
229
  /** 拉取微信平台证书 (GET /v3/certificates, 密文用调用方 APIv3 key 解密) → 内存+磁盘缓存 */
212
230
  async function refreshPlatformCerts() {
213
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
231
+ // Lane C: SP 身份只判 sp_mchid 是否配置 — 不能依赖全局 sub_mchid (多商户部署常不设默认值)
232
+ const isSp = !!WX.spMchid;
214
233
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
215
234
  const r = await wxApiCall("GET", "/v3/certificates", null, override);
216
235
  if (r.status !== 200 || !Array.isArray(r.json && r.json.data)) {
@@ -260,7 +279,8 @@ function wxVerifyNotifySignature(serial, timestamp, nonce, rawBody, signature) {
260
279
 
261
280
  /** 回调资源解密 (SP 模式优先 sp APIv3 key) */
262
281
  function wxDecryptNotifyResource(resource) {
263
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
282
+ // Lane C: 回调是发给服务商的, 必须用服务商 apiv3 key — 不能依赖全局 sub_mchid
283
+ const isSp = !!WX.spMchid;
264
284
  const apiv3 = isSp && WX.spApiv3Key ? WX.spApiv3Key : WX.apiv3Key;
265
285
  const plain = wxAesGcmDecrypt(apiv3, resource.nonce, resource.associated_data, resource.ciphertext);
266
286
  return JSON.parse(plain.toString("utf8"));
@@ -624,7 +644,7 @@ async function runReconcileOnce(db) {
624
644
  scanned = pend.length;
625
645
  for (const row of pend) {
626
646
  try {
627
- const q = await wxQueryOrder(row.order_ref);
647
+ const q = await wxQueryOrder(row.order_ref, { subMchid: row.sub_mchid });
628
648
  if (q.state === "SUCCESS") {
629
649
  settlePayment(row, q.txnId || ("wxq-" + row.order_ref), "wechat_native", q.amount || row.amount_total, JSON.stringify(q.raw).slice(0, 2000));
630
650
  settled++;
@@ -31,6 +31,9 @@
31
31
  *
32
32
  * 下单要点:
33
33
  * - 价格权威 = 本地 DB (单价 × qty 算总分), 客户端传的任何金额字段一律忽略
34
+ * - A1 (2026-10-05 设计方案): product_type 驱动收货地址 — physical 强制 shipping_address
35
+ * (缺失 400 SHIPPING_ADDRESS_REQUIRED), virtual 免地址并生成 delivery_credential
36
+ * (license 格式 tb-license-{order_ref}-{random4}); 无 product_type 列/字段默认 physical
34
37
  * - qty 整数 >=1 且不超库存; 单价折算后 <= 0 分 → 409 (微信不支持 0 元单)
35
38
  * - 幂等两级: ① body.client_order_no → client_session_id='buyer:{card}:{no}' 同号返原单
36
39
  * ② 无 client_order_no → 同 buyer_card+product_id 5 分钟窗口内未支付未过期单直接复用
@@ -93,11 +96,19 @@ CREATE TABLE IF NOT EXISTS agent_pay_sessions (
93
96
  updated_at INTEGER NOT NULL
94
97
  );
95
98
  `);
96
- // 老库兼容: agent-pay 的物流列 + U7 分账标记列 (已存在则跳过)
99
+ // 老库兼容: agent-pay 的物流列 + Lane C 商户号列 + U7 分账标记列 + A1 收货地址/交付凭据列 (已存在则跳过)
97
100
  for (const col of ["carrier TEXT DEFAULT ''", "tracking_no TEXT DEFAULT ''", "logistics_state TEXT DEFAULT ''", "timeline TEXT DEFAULT '[]'", "shopxo_order_no_ref TEXT DEFAULT ''",
98
- "ps_flag INTEGER NOT NULL DEFAULT 0"]) { // U7: 1=下单带 settle_info.profit_sharing (rate>0 且 SP 模式)
101
+ "sub_mchid TEXT DEFAULT ''", // Lane C: 本单所属特约商户 (INSERT 引用; 0.4.3 教训: 写路径自建, 不依赖 agent-pay 先跑)
102
+ "ps_flag INTEGER NOT NULL DEFAULT 0", // U7: 1=下单带 settle_info.profit_sharing (rate>0 且 SP 模式)
103
+ "shipping_address TEXT DEFAULT ''", // A1: 实物订单收货地址 (虚拟单允许空)
104
+ "delivery_credential TEXT DEFAULT ''" // A1: 虚拟商品交付凭据 (license/兑换码; 实物单为空)
105
+ ]) {
99
106
  try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
100
107
  }
108
+ // A1 商品类型: store_products 补 product_type (写路径自建, 不依赖 store-migrate 先跑; 表不存在则跳过)
109
+ if (hasTable(db, "store_products")) {
110
+ try { db.exec("ALTER TABLE store_products ADD COLUMN product_type TEXT NOT NULL DEFAULT 'physical'"); } catch (e) { /* 已存在 */ }
111
+ }
101
112
  }
102
113
 
103
114
  function hasTable(db, name) {
@@ -139,13 +150,14 @@ const RATE_CACHE_MS = 60 * 1000; // U7: 费率网关查询 60s 内存缓
139
150
  function makeCommissionRateResolver(db, fetcher) {
140
151
  const cache = new Map(); // sub_mchid → {bp, expiresAt}
141
152
  const fetchFn = fetcher || globalThis.fetch;
142
- return async function resolveRateBp() {
153
+ return async function resolveRateBp(subMchidArg) {
143
154
  // ① env 显式覆盖 (最高优先, 不调网关不读表)
144
155
  if (process.env.SP_COMMISSION_BP !== undefined && process.env.SP_COMMISSION_BP !== "") {
145
156
  const v = Number(process.env.SP_COMMISSION_BP);
146
157
  return Number.isInteger(v) && v >= 0 ? v : 0;
147
158
  }
148
- const subMchid = String(process.env.WXPAY_SUB_MCHID || "");
159
+ // Lane C (2026-10-03): 按传入的商户号解析; 未传才回落全局 env (多商户)
160
+ const subMchid = String(subMchidArg || process.env.WXPAY_SUB_MCHID || "");
149
161
  if (!subMchid) return 0; // 直连模式不标记
150
162
  const hit = cache.get(subMchid);
151
163
  if (hit && Date.now() < hit.expiresAt) return hit.bp;
@@ -176,12 +188,48 @@ function makeCommissionRateResolver(db, fetcher) {
176
188
  };
177
189
  }
178
190
 
191
+ /** Lane C (2026-10-03): 按「商品卖家地址」解析特约商户号 —— 多商户的核心
192
+ * 数据源: sp_sub_merchants.seller_address → sub_mchid (进件时写入, 网关与商家机各一份)
193
+ * 兜底: 全局 env WXPAY_SUB_MCHID (单商户旧部署) —— **必须显式告警**, 不静默错分
194
+ * 返回 { subMchid, source: registry | env-fallback | none } */
195
+ function makeSubMchidResolver(db) {
196
+ const cache = new Map(); // seller_address → {subMchid, source}
197
+ return function resolveSubMchid(sellerAddress) {
198
+ const env = String(process.env.WXPAY_SUB_MCHID || "");
199
+ const key = String(sellerAddress || "");
200
+ if (!key) return { subMchid: env, source: env ? "env-fallback-no-seller" : "none" };
201
+ const hit = cache.get(key);
202
+ if (hit) return hit;
203
+ let out = { subMchid: env, source: env ? "env-fallback" : "none" };
204
+ try {
205
+ if (hasTable(db, "sp_sub_merchants")) {
206
+ const row = db.prepare(
207
+ "SELECT sub_mchid FROM sp_sub_merchants WHERE seller_address = ? AND status = 'active'"
208
+ ).get(key);
209
+ if (row && row.sub_mchid) out = { subMchid: String(row.sub_mchid), source: "registry" };
210
+ }
211
+ } catch (_) { /* 表缺失/异常 → 走兜底 */ }
212
+ if (out.source !== "registry") {
213
+ console.warn(
214
+ `[buyer-shop] 卖家 ${key.slice(0, 14)}… 未在 sp_sub_merchants 登记 → ` +
215
+ (out.subMchid
216
+ ? `回退全局 WXPAY_SUB_MCHID=${out.subMchid}(多商户场景可能分错商户!)`
217
+ : "无 sub_mchid(按直连模式,本单不分账)")
218
+ );
219
+ }
220
+ cache.set(key, out);
221
+ return out;
222
+ };
223
+ }
224
+
179
225
  function createBuyerShopRouter(deps) {
180
226
  const router = express.Router();
181
227
  const { db } = deps;
182
228
  ensureBuyerTables(db);
183
229
  const rateLimit = makeRateLimiter();
184
230
  const resolveRateBp = makeCommissionRateResolver(db, deps.fetcher);
231
+ // Lane C: 按商品卖家解析 sub_mchid (多商户); 表缺失时回落 env 并告警
232
+ const resolveSubMchid = makeSubMchidResolver(db);
185
233
 
186
234
  const MODE = process.env.AGENT_PAY_MODE || "mock"; // mock | wechat (同 agent-pay 口径)
187
235
  // 测试可注入假 wxNativeOrder; 生产默认 agent-pay 真实现 (SP 模式其内部自动切换)
@@ -210,6 +258,8 @@ function createBuyerShopRouter(deps) {
210
258
  price: fenToYuan(unitFenOf(row)),
211
259
  image_url: productCols.includes("image_url") ? String(row.image_url || "") : "",
212
260
  stock: Number(row.stock) || 0,
261
+ // A1: 商品类型 (physical=实物要收货地址 / virtual=虚拟免地址发凭据); 无该列的老库默认 physical
262
+ product_type: productCols.includes("product_type") ? String(row.product_type || "physical") : "physical",
213
263
  };
214
264
  }
215
265
 
@@ -241,6 +291,9 @@ function createBuyerShopRouter(deps) {
241
291
  quantity: row.qty,
242
292
  tx_hash: txHash,
243
293
  code_url: row.psp_code_url || "",
294
+ // A1: 收货地址 / 交付凭据 (老订单两列为空 → 空串)
295
+ shipping_address: String(row.shipping_address || ""),
296
+ delivery_credential: String(row.delivery_credential || ""),
244
297
  };
245
298
  }
246
299
 
@@ -328,6 +381,13 @@ function createBuyerShopRouter(deps) {
328
381
  if (!(unitFen >= 1)) return err("PRICE_UNAVAILABLE", 409, "该商品暂未定价,暂不可购买");
329
382
  const totalFen = unitFen * qty;
330
383
 
384
+ // A1 商品类型驱动收货地址 (2026-10-05 设计方案): physical 强制收货地址, virtual 免地址生成交付凭据
385
+ const productType = String(product.product_type || "physical").toLowerCase() === "virtual" ? "virtual" : "physical";
386
+ const shippingAddress = String(b.shipping_address || "").trim().slice(0, 500);
387
+ if (productType === "physical" && !shippingAddress) {
388
+ return err("SHIPPING_ADDRESS_REQUIRED", 400, "该商品需要填写收货地址");
389
+ }
390
+
331
391
  // 幂等②: 无 client_order_no 时, 同买家同商品 5 分钟内未支付未过期单直接复用
332
392
  const now = nowSec();
333
393
  if (!clientOrderNo) {
@@ -338,23 +398,35 @@ function createBuyerShopRouter(deps) {
338
398
  if (exist) return res.json(orderToJson(exist));
339
399
  }
340
400
 
401
+ // Lane C: 本单属于哪个特约商户 = 商品卖家的登记商户号 (不再是全局 env)
402
+ const sellerResolved = resolveSubMchid(product.seller_address);
403
+ const subMchid = sellerResolved.subMchid;
404
+
341
405
  // U7: rate>0 且 SP 模式 → 微信下单带 settle_info.profit_sharing (不带标记的订单分账被拒)
342
- const rateBp = await resolveRateBp();
343
- const psFlag = process.env.WXPAY_SUB_MCHID && rateBp > 0 ? 1 : 0;
406
+ const rateBp = await resolveRateBp(subMchid);
407
+ const psFlag = subMchid && rateBp > 0 ? 1 : 0;
344
408
 
345
409
  // 创建订单 (订单号 = order_ref, 对外 id)
346
410
  const orderRef = "tb-" + crypto.randomBytes(8).toString("hex");
347
411
  const clientSessionId = `buyer:${buyerCard}:${clientOrderNo || orderRef}`;
348
412
  const exp = now + SESSION_TTL_SEC;
349
413
 
414
+ // A1: 虚拟商品交付凭据 (license/兑换码, 与订单绑定, 格式 tb-license-{order_ref}-{random4}; 实物单为空)
415
+ const deliveryCredential = productType === "virtual"
416
+ ? `tb-license-${orderRef}-${crypto.randomBytes(2).toString("hex")}`
417
+ : "";
418
+
350
419
  // 微信 Native 下单拿 code_url (mock 模式生成伪码, 不碰真微信)
351
420
  let codeUrl = "";
352
421
  if (MODE === "mock") {
353
422
  codeUrl = `weixin://mock-pay/${orderRef}`;
354
423
  } else {
355
424
  try {
356
- codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp,
357
- psFlag ? { settleInfo: { profit_sharing: true } } : undefined);
425
+ codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp, {
426
+ subMchid,
427
+ subMchidSource: sellerResolved.source,
428
+ ...(psFlag ? { settleInfo: { profit_sharing: true } } : {}),
429
+ });
358
430
  } catch (e) {
359
431
  console.error("[buyer-shop] wx order fail:", e && e.message);
360
432
  return err("PAYMENT_UPSTREAM", 502, "支付通道暂时不可用,请稍后再试");
@@ -365,11 +437,11 @@ function createBuyerShopRouter(deps) {
365
437
  // 买家面无账号体系, 订单 id 即凭据 — 与 pwa-shop-demo 轮询模型一致)
366
438
  // U7: ps_flag 台账标注该单是否带分账标记 (运营台区分用)
367
439
  db.prepare(`INSERT INTO agent_pay_sessions
368
- (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, ps_flag)
369
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
440
+ (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, ps_flag, sub_mchid, shipping_address, delivery_credential)
441
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
370
442
  .run(clientSessionId, productId, qty, totalFen, "CNY", "buyer:" + buyerCard, orderRef,
371
443
  crypto.randomBytes(32).toString("hex"), crypto.randomBytes(32).toString("hex"),
372
- "CREATED", codeUrl, "", exp, now, now, psFlag);
444
+ "CREATED", codeUrl, "", exp, now, now, psFlag, subMchid, shippingAddress, deliveryCredential);
373
445
  console.log(`[buyer-shop] 下单 ${orderRef} product=${productId} qty=${qty} total=${totalFen}分 ps_flag=${psFlag}`);
374
446
  const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
375
447
  res.json(orderToJson(row));
@@ -71,10 +71,10 @@ function withWechatMode(fn) {
71
71
  };
72
72
  }
73
73
 
74
- async function placeOrder(base, card) {
74
+ async function placeOrder(base, card, productId = "p1") {
75
75
  const r = await fetch(base + "/api/orders", {
76
76
  method: "POST", headers: { "Content-Type": "application/json" },
77
- body: JSON.stringify({ product_id: "p1", quantity: 1, buyer_card: card }),
77
+ body: JSON.stringify({ product_id: productId, quantity: 1, buyer_card: card, shipping_address: "addr-ps" }),
78
78
  });
79
79
  return { status: r.status, json: await r.json() };
80
80
  }
@@ -90,7 +90,7 @@ test("rate=0 (网关 404 未登记) + SP 模式 → 不带 settle_info, ps_flag=
90
90
  const { status, json } = await placeOrder(base, "card_ps1");
91
91
  assert.strictEqual(status, 200, JSON.stringify(json));
92
92
  assert.strictEqual(wxCalls.length, 1);
93
- assert.strictEqual(wxCalls[0].opts, undefined, "rate=0 不传 settleInfo");
93
+ assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback" }, "rate=0 不传 settleInfo");
94
94
  const row = db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(json.id);
95
95
  assert.strictEqual(row.ps_flag, 0, "台账 ps_flag=0");
96
96
  } finally { srv.close(); restoreEnv(env); }
@@ -110,7 +110,7 @@ test("rate=100 (env SP_COMMISSION_BP 覆盖) + SP 模式 → settle_info.profit_
110
110
  assert.strictEqual(status, 200, JSON.stringify(json));
111
111
  assert.strictEqual(gwCalls, 0, "env 覆盖时不调网关");
112
112
  assert.strictEqual(wxCalls.length, 1);
113
- assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } }, "带分账标记");
113
+ assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } }, "带分账标记");
114
114
  assert.strictEqual(db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(json.id).ps_flag, 1);
115
115
  } finally { srv.close(); restoreEnv(env); }
116
116
  }));
@@ -126,13 +126,13 @@ test("网关接口返回 rate_bp=100 → 带 settle_info; 60s 缓存 (第二次
126
126
  process.env.SP_GATEWAY_URL = "https://gw.example.com";
127
127
  const a = await placeOrder(base, "card_ps3a");
128
128
  assert.strictEqual(a.status, 200);
129
- assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } });
129
+ assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } });
130
130
  assert.strictEqual(gwCalls, 1);
131
131
  // 第二次下单 (不同买家卡避免幂等复用) → 命中缓存, 不再调网关
132
132
  const b = await placeOrder(base, "card_ps3b");
133
133
  assert.strictEqual(b.status, 200);
134
134
  assert.strictEqual(gwCalls, 1, "60s 缓存生效");
135
- assert.deepStrictEqual(wxCalls[1].opts, { settleInfo: { profit_sharing: true } });
135
+ assert.deepStrictEqual(wxCalls[1].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } });
136
136
  assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions WHERE ps_flag = 1").get().n, 2);
137
137
  } finally { srv.close(); restoreEnv(env); }
138
138
  }));
@@ -148,7 +148,7 @@ test("费率网关失败 → 缓存 0 防抖动, 不阻塞下单, 不带 settle_
148
148
  process.env.SP_GATEWAY_URL = "https://unreachable.example.com";
149
149
  const a = await placeOrder(base, "card_ps4a");
150
150
  assert.strictEqual(a.status, 200, "网关失败不阻塞下单");
151
- assert.strictEqual(wxCalls[0].opts, undefined, "失败按 rate=0 不带标记");
151
+ assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback" }, "失败按 rate=0 不带标记");
152
152
  const b = await placeOrder(base, "card_ps4b");
153
153
  assert.strictEqual(b.status, 200);
154
154
  assert.strictEqual(gwCalls, 1, "失败也缓存 60s (防抖动)");
@@ -167,12 +167,15 @@ test("本地 sp_commission_rates 兜底 (网关未配置, 同库直读) + 直连
167
167
  process.env.WXPAY_SUB_MCHID = "1118152327";
168
168
  const a = await placeOrder(base, "card_ps5a");
169
169
  assert.strictEqual(a.status, 200);
170
- assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } }, "本地表 rate=150 → 带标记");
170
+ assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } }, "本地表 rate=150 → 带标记");
171
171
  // 直连模式: 即便有费率也不标记 (settle_info 是 SP 分账语义)
172
+ // Lane C: sub_mchid 解析按卖家地址缓存 — 换另一个卖家的商品, 避免命中上一单的 env-fallback 缓存
172
173
  delete process.env.WXPAY_SUB_MCHID;
173
- const b = await placeOrder(base, "card_ps5b");
174
+ db.prepare(`INSERT INTO store_products (id, seller_address, title, description, price_utct, stock, status, image_url, created_at, updated_at)
175
+ VALUES ('p2','tct1sellerDirect','U7直连商品','',2500000,10,'active',NULL,?,?)`).run(NOW_MS, NOW_MS);
176
+ const b = await placeOrder(base, "card_ps5b", "p2");
174
177
  assert.strictEqual(b.status, 200);
175
- assert.strictEqual(wxCalls[1].opts, undefined, "直连模式不带 settle_info");
178
+ assert.deepStrictEqual(wxCalls[1].opts, { subMchid: "", subMchidSource: "none" }, "直连模式不带 settle_info");
176
179
  assert.strictEqual(db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(b.json.id).ps_flag, 0);
177
180
  } finally { srv.close(); restoreEnv(env); }
178
181
  }));
@@ -87,12 +87,13 @@ test("GET /api/products: 只在架商品 + 契约字段 + utct 折元 (整数分
87
87
  assert.ok(Array.isArray(list), "契约: 裸数组");
88
88
  assert.strictEqual(list.length, 3, "draft 被过滤 (active 3 个)");
89
89
  const p = list.find((x) => x.id === "p-active-1");
90
- assert.deepStrictEqual(Object.keys(p).sort(), ["description", "id", "image_url", "name", "price", "stock"], "契约字段一字不差");
90
+ assert.deepStrictEqual(Object.keys(p).sort(), ["description", "id", "image_url", "name", "price", "product_type", "stock"], "契约字段一字不差");
91
91
  assert.strictEqual(p.name, "测试商品A");
92
92
  assert.strictEqual(p.description, "好用");
93
93
  assert.strictEqual(p.price, 2.5, "2_500_000 utct / 1e6 = 2.5 元");
94
94
  assert.strictEqual(p.image_url, "https://img.example.com/a.jpg");
95
95
  assert.strictEqual(p.stock, 10);
96
+ assert.strictEqual(p.product_type, "physical", "A1: 无 product_type 列的老库默认 physical");
96
97
  const noImg = list.find((x) => x.id === "p-nostock");
97
98
  assert.strictEqual(noImg.image_url, "", "无图给空字符串");
98
99
  assert.strictEqual(noImg.price, 1);
@@ -125,7 +126,7 @@ test("POST /api/orders: 正常下单 (mock code_url) + 价格以 DB 为权威 (
125
126
  try {
126
127
  const r = await fetch(base + "/api/orders", {
127
128
  method: "POST", headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_test001", amount: 0.01, price: 0 }), // 乱传金额无效
129
+ body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_test001", shipping_address: "上海市浦东新区测试路1号", amount: 0.01, price: 0 }), // 乱传金额无效
129
130
  });
130
131
  const j = await r.json();
131
132
  assert.strictEqual(r.status, 200, JSON.stringify(j));
@@ -182,20 +183,20 @@ test("POST /api/orders 幂等: client_order_no 同号返原单; 无号 5 分钟
182
183
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
183
184
  }).then((r) => r.json());
184
185
  // ① client_order_no 幂等
185
- const a = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1" });
186
- const b = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1" });
186
+ const a = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1", shipping_address: "addr-1" });
187
+ const b = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1", shipping_address: "addr-1" });
187
188
  assert.strictEqual(b.id, a.id, "同 client_order_no 返原单");
188
189
  assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions").get().n, 1);
189
190
  // 不同 client_order_no → 新单
190
- const c = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-2" });
191
+ const c = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-2", shipping_address: "addr-1" });
191
192
  assert.notStrictEqual(c.id, a.id);
192
193
  // ② 无号窗口复用 (同买家同商品)
193
- const d = await post({ product_id: "p-active-1", quantity: 3, buyer_card: "card_idem2" });
194
- const e = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem2" });
194
+ const d = await post({ product_id: "p-active-1", quantity: 3, buyer_card: "card_idem2", shipping_address: "addr-2" });
195
+ const e = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem2", shipping_address: "addr-2" });
195
196
  assert.strictEqual(e.id, d.id, "5 分钟窗口内复用未支付单");
196
197
  assert.strictEqual(e.amount, 7.5, "复用的是原单金额 (qty=3)");
197
198
  // 不同买家不复用
198
- const f = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem3" });
199
+ const f = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem3", shipping_address: "addr-3" });
199
200
  assert.notStrictEqual(f.id, d.id);
200
201
  } finally { srv.close(); }
201
202
  });
@@ -205,7 +206,7 @@ test("POST /api/orders 限流: IP 维度 10 单/分钟, 第 11 单 429", async (
205
206
  try {
206
207
  const post = (card) => fetch(base + "/api/orders", {
207
208
  method: "POST", headers: { "Content-Type": "application/json" },
208
- body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: card }),
209
+ body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: card, shipping_address: "addr-rl" }),
209
210
  });
210
211
  for (let i = 0; i < 10; i++) {
211
212
  const r = await post("card_rl_" + i); // 不同买家卡 → 每单独立 (不触发幂等)
@@ -226,12 +227,12 @@ test("GET /api/orders/:id: 状态映射 pending/paid/completed/failed + 404", as
226
227
  try {
227
228
  const cr = await fetch(base + "/api/orders", {
228
229
  method: "POST", headers: { "Content-Type": "application/json" },
229
- body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_st1" }),
230
+ body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_st1", shipping_address: "addr-st1" }),
230
231
  });
231
232
  const { id } = await cr.json();
232
233
  const get = () => fetch(base + "/api/orders/" + id).then((r) => r.json());
233
234
  let o = await get();
234
- assert.deepStrictEqual(Object.keys(o).sort(), ["amount", "code_url", "id", "product_id", "product_name", "quantity", "status", "tx_hash"] /* 0.5.2 补 product_id (Pi 复核 P2) */, "契约字段一字不差");
235
+ assert.deepStrictEqual(Object.keys(o).sort(), ["amount", "code_url", "delivery_credential", "id", "product_id", "product_name", "quantity", "shipping_address", "status", "tx_hash"] /* 0.5.2 补 product_id (Pi 复核 P2); A1 补 shipping_address/delivery_credential */, "契约字段一字不差");
235
236
  assert.strictEqual(o.status, "pending", "CREATED → pending");
236
237
  assert.strictEqual(o.amount, 5);
237
238
  assert.strictEqual(o.product_name, "测试商品A", "product_name 联 store_products.title");
@@ -295,7 +296,7 @@ test("wechat 模式: wxNativeOrder 注入捕获参数 (out_trade_no/金额分/
295
296
  try {
296
297
  const r = await fetch(base + "/api/orders", {
297
298
  method: "POST", headers: { "Content-Type": "application/json" },
298
- body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_wx1" }),
299
+ body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_wx1", shipping_address: "addr-wx1" }),
299
300
  });
300
301
  const j = await r.json();
301
302
  assert.strictEqual(r.status, 200, JSON.stringify(j));
@@ -319,7 +320,7 @@ test("wechat 模式: 微信下单失败 → 502, 不落单", async () => {
319
320
  try {
320
321
  const r = await fetch(base + "/api/orders", {
321
322
  method: "POST", headers: { "Content-Type": "application/json" },
322
- body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: "card_wx2" }),
323
+ body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: "card_wx2", shipping_address: "addr-wx2" }),
323
324
  });
324
325
  assert.strictEqual(r.status, 502);
325
326
  const j = await r.json();
package/test-laneC.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Lane C 多商户路由 单测 (2026-10-03 kimi)
3
+ * 验证: ① 按商品卖家解析 sub_mchid ② 兜底回退 env 且有告警语义 ③ 费率按商户号解析
4
+ * 跑法: node test-laneC.js
5
+ */
6
+ const sqlite3 = require("better-sqlite3");
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = path.join(os.tmpdir(), "lanec-test-" + Date.now() + ".db");
13
+ const db = new sqlite3(tmp);
14
+
15
+ // 造最小 schema (与线上同结构)
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS sp_sub_merchants (sub_mchid TEXT, seller_address TEXT, shop_name TEXT, webhook_url TEXT, status TEXT, created_at INTEGER);
18
+ CREATE TABLE IF NOT EXISTS sp_commission_rates (sub_mchid TEXT, rate_bp INTEGER);
19
+ CREATE TABLE IF NOT EXISTS store_products (id TEXT, seller_address TEXT, title TEXT, price_utct INTEGER, stock INTEGER, status TEXT);
20
+ `);
21
+ db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1118152327", "tct1v75dSellerA", "港思恒", "", "active", 1);
22
+ db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1900000109", "tct1v75dSellerB", "第二商户", "", "active", 1);
23
+ db.prepare("INSERT INTO sp_commission_rates VALUES (?,?)").run("1900000109", 100); // 第二商户 1%
24
+
25
+ process.env.WXPAY_SUB_MCHID = "1118152327"; // 全局默认(单商户旧部署)
26
+ delete process.env.SP_COMMISSION_BP; // 走解析路径
27
+
28
+ // 载入被测函数 (从 buyer-shop.js 里抠出来, 避免 require 整个应用)
29
+ const src = fs.readFileSync(path.join(__dirname, "routes", "buyer-shop.js"), "utf8");
30
+ function extract(name) {
31
+ const i = src.indexOf("function " + name);
32
+ if (i < 0) throw new Error("找不到函数 " + name);
33
+ // 找到函数体结束
34
+ let depth = 0, started = false, j = i;
35
+ for (; j < src.length; j++) {
36
+ if (src[j] === "{") { depth++; started = true; }
37
+ else if (src[j] === "}") { depth--; if (started && depth === 0) { j++; break; } }
38
+ }
39
+ return src.slice(i, j);
40
+ }
41
+ global.hasTable = eval("(" + extract("hasTable") + ")");
42
+ const makeSubMchidResolver = eval("(" + extract("makeSubMchidResolver") + ")");
43
+ const RATE_CACHE_MS = 60 * 1000; // 与 buyer-shop.js 同值
44
+ const makeCommissionRateResolver = eval("(" + extract("makeCommissionRateResolver") + ")");
45
+
46
+ const resolveSubMchid = makeSubMchidResolver(db);
47
+ const resolveRateBp = makeCommissionRateResolver(db, null);
48
+
49
+ let pass = 0, fail = 0;
50
+ function check(name, got, want) {
51
+ const okv = JSON.stringify(got) === JSON.stringify(want);
52
+ console.log((okv ? " ✔ " : " ✘ ") + name + (okv ? "" : ` got=${JSON.stringify(got)} want=${JSON.stringify(want)}`));
53
+ okv ? pass++ : fail++;
54
+ }
55
+
56
+ console.log("Lane C 多商户路由单测:");
57
+
58
+ // ① 已登记商户 → 走登记表
59
+ check("港思恒(1118152327) 解析", resolveSubMchid("tct1v75dSellerA").subMchid, "1118152327");
60
+ check("第二商户(1900000109) 解析", resolveSubMchid("tct1v75dSellerB").subMchid, "1900000109");
61
+ check("第二商户 source=registry", resolveSubMchid("tct1v75dSellerB").source, "registry");
62
+
63
+ // ② 未登记卖家 → 回落 env + source 标记(不静默)
64
+ const r3 = resolveSubMchid("tct1unknown");
65
+ check("未登记卖家回落 env", r3.subMchid, "1118152327");
66
+ check("未登记卖家 source=env-fallback", r3.source, "env-fallback");
67
+
68
+ // ③ 无卖家地址 → 也不静默
69
+ check("无 seller 回落 env", resolveSubMchid("").source, "env-fallback-no-seller");
70
+
71
+ // ④ 费率按传入商户号解析 (第二商户 1% = 100bp; 全局默认未登记 → 0)
72
+ resolveRateBp.cache = null;
73
+ (async () => {
74
+ const bp2 = await resolveRateBp("1900000109");
75
+ check("费率按第二商户解析 (1%=100bp)", bp2, 100);
76
+ const bp1 = await resolveRateBp("1118152327");
77
+ check("费率按港思恒解析 (未登记→0)", bp1, 0);
78
+ // 不传参 → 回落 env 商户号
79
+ const bp0 = await resolveRateBp();
80
+ check("费率不传参回落 env", bp0, 0);
81
+
82
+ console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
83
+ try { fs.unlinkSync(tmp); } catch (_) {}
84
+ process.exit(fail ? 1 : 0);
85
+ })();
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * test-product-type.js — A1 商品类型驱动收货地址 离线单测 (2026-10-05 设计方案)
4
+ * node --test test-product-type.js
5
+ *
6
+ * 照 test-buyer-shop.js 模式: :memory: 库起 mock server, AGENT_PAY_MODE=mock 不碰真微信/真链。
7
+ * 覆盖设计方案 4 条验收:
8
+ * 1. physical 商品下单必须提供 shipping_address, 缺失 400 (SHIPPING_ADDRESS_REQUIRED)
9
+ * 2. virtual 商品下单不要地址, 返回 delivery_credential (tb-license-{order_ref}-{random4})
10
+ * 3. 向后兼容: 无 product_type 列的商品默认 physical, 要求地址
11
+ * 4. 老订单 (新列为空) 查询正常, 返回空串
12
+ */
13
+ const test = require("node:test");
14
+ const assert = require("node:assert");
15
+ const express = require("express");
16
+ const Database = require("better-sqlite3");
17
+
18
+ process.env.AGENT_PAY_MODE = "mock";
19
+
20
+ const buyerShop = require("./routes/buyer-shop");
21
+
22
+ const NOW_MS = Date.now();
23
+ const ADDR = "上海市浦东新区测试路1号 张三收 13800000000";
24
+
25
+ /** 新 schema: store_products 含 product_type 列 (A1 迁移后) */
26
+ function seedProductsWithType(db) {
27
+ db.exec(`
28
+ CREATE TABLE store_products (
29
+ id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
30
+ description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
31
+ stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
32
+ product_type TEXT NOT NULL DEFAULT 'physical',
33
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
34
+ );
35
+ `);
36
+ const ins = db.prepare(`INSERT INTO store_products (id, seller_address, title, price_utct, stock, status, product_type, created_at, updated_at)
37
+ VALUES (?,?,?,?,?,?,?,?,?)`);
38
+ ins.run("p-phys-1", "tct1seller", "实物商品", 2500000, 10, "active", "physical", NOW_MS, NOW_MS);
39
+ ins.run("p-virt-1", "tct1seller", "虚拟商品", 1000000, 99, "active", "virtual", NOW_MS, NOW_MS);
40
+ }
41
+
42
+ /** 老 schema: store_products 无 product_type 列 (A1 迁移前的生产库形状, 同 test-buyer-shop.js) */
43
+ function seedProductsLegacy(db) {
44
+ db.exec(`
45
+ CREATE TABLE store_products (
46
+ id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
47
+ description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
48
+ stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
49
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
50
+ );
51
+ `);
52
+ db.prepare(`INSERT INTO store_products (id, seller_address, title, price_utct, stock, status, created_at, updated_at)
53
+ VALUES (?,?,?,?,?,?,?,?)`)
54
+ .run("p-legacy-1", "tct1seller", "老商品", 2500000, 10, "active", NOW_MS, NOW_MS);
55
+ }
56
+
57
+ async function startServer(seed) {
58
+ const db = new Database(":memory:");
59
+ seed(db);
60
+ const app = express();
61
+ app.use(express.json());
62
+ app.use("/api", buyerShop.createBuyerShopRouter({ db }));
63
+ const srv = app.listen(0);
64
+ await new Promise((r) => srv.once("listening", r));
65
+ return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
66
+ }
67
+
68
+ function post(base, body) {
69
+ return fetch(base + "/api/orders", {
70
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
71
+ });
72
+ }
73
+
74
+ test("① physical 商品: 缺 shipping_address → 400 SHIPPING_ADDRESS_REQUIRED; 提供后下单成功且落库/返回地址", async () => {
75
+ const { db, srv, base } = await startServer(seedProductsWithType);
76
+ try {
77
+ // 缺地址 → 400
78
+ let r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1" });
79
+ assert.strictEqual(r.status, 400);
80
+ let j = await r.json();
81
+ assert.strictEqual(j.error, "SHIPPING_ADDRESS_REQUIRED");
82
+ assert.ok(j.message, "{error, message} 结构");
83
+ // 空白地址也视为缺失
84
+ r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1", shipping_address: " " });
85
+ assert.strictEqual(r.status, 400);
86
+ assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions").get().n, 0, "校验失败不落单");
87
+ // 提供地址 → 200, 地址落库且随单返回
88
+ r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1", shipping_address: ADDR });
89
+ j = await r.json();
90
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
91
+ assert.match(j.id, /^tb-[0-9a-f]{16}$/);
92
+ assert.strictEqual(j.shipping_address, ADDR);
93
+ assert.strictEqual(j.delivery_credential, "", "实物单无交付凭据");
94
+ const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(j.id);
95
+ assert.strictEqual(row.shipping_address, ADDR, "地址落 agent_pay_sessions");
96
+ assert.strictEqual(row.delivery_credential, "");
97
+ // 查单也带地址
98
+ const o = await (await fetch(base + "/api/orders/" + j.id)).json();
99
+ assert.strictEqual(o.shipping_address, ADDR);
100
+ } finally { srv.close(); }
101
+ });
102
+
103
+ test("② virtual 商品: 不要地址, 返回 delivery_credential (license 格式), 幂等重放凭据不变", async () => {
104
+ const { db, srv, base } = await startServer(seedProductsWithType);
105
+ try {
106
+ let r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
107
+ const j = await r.json();
108
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
109
+ assert.match(j.delivery_credential, /^tb-license-tb-[0-9a-f]{16}-[0-9a-f]{4}$/, "license 格式 tb-license-{order_ref}-{random4}");
110
+ assert.ok(j.delivery_credential.includes(j.id), "凭据与订单绑定");
111
+ assert.strictEqual(j.shipping_address, "", "虚拟单地址为空");
112
+ const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(j.id);
113
+ assert.strictEqual(row.delivery_credential, j.delivery_credential, "凭据落库");
114
+ // 幂等重放 → 同一凭据 (不重新生成)
115
+ r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
116
+ const j2 = await r.json();
117
+ assert.strictEqual(j2.id, j.id);
118
+ assert.strictEqual(j2.delivery_credential, j.delivery_credential);
119
+ // 查单带凭据
120
+ const o = await (await fetch(base + "/api/orders/" + j.id)).json();
121
+ assert.strictEqual(o.delivery_credential, j.delivery_credential);
122
+ // 两个虚拟单凭据不同
123
+ const j3 = await (await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-2" })).json();
124
+ assert.notStrictEqual(j3.delivery_credential, j.delivery_credential);
125
+ } finally { srv.close(); }
126
+ });
127
+
128
+ test("②b 商品面: GET /products 返回 product_type (virtual/physical 如实; 列表含类型)", async () => {
129
+ const { srv, base } = await startServer(seedProductsWithType);
130
+ try {
131
+ const list = await (await fetch(base + "/api/products")).json();
132
+ assert.strictEqual(list.find((x) => x.id === "p-phys-1").product_type, "physical");
133
+ assert.strictEqual(list.find((x) => x.id === "p-virt-1").product_type, "virtual");
134
+ const one = await (await fetch(base + "/api/products/p-virt-1")).json();
135
+ assert.strictEqual(one.product_type, "virtual");
136
+ } finally { srv.close(); }
137
+ });
138
+
139
+ test("③ 向后兼容: 无 product_type 列的老商品默认 physical, 下单要求地址", async () => {
140
+ const { srv, base } = await startServer(seedProductsLegacy);
141
+ try {
142
+ // 商品面默认 physical
143
+ const p = await (await fetch(base + "/api/products/p-legacy-1")).json();
144
+ assert.strictEqual(p.product_type, "physical", "无该列 → 默认 physical");
145
+ // 缺地址 → 400
146
+ let r = await post(base, { product_id: "p-legacy-1", quantity: 1, buyer_card: "card_pt3" });
147
+ assert.strictEqual(r.status, 400);
148
+ assert.strictEqual((await r.json()).error, "SHIPPING_ADDRESS_REQUIRED");
149
+ // 带地址 → 200
150
+ r = await post(base, { product_id: "p-legacy-1", quantity: 1, buyer_card: "card_pt3", shipping_address: ADDR });
151
+ assert.strictEqual(r.status, 200, JSON.stringify(await r.json()));
152
+ } finally { srv.close(); }
153
+ });
154
+
155
+ test("④ 老订单 (新列为空) 查询正常, shipping_address / delivery_credential 返回空串", async () => {
156
+ const { db, srv, base } = await startServer(seedProductsWithType);
157
+ try {
158
+ // 直插一行"迁移前形状"的老订单 (不指定新列 → DEFAULT '')
159
+ const now = Math.floor(Date.now() / 1000);
160
+ db.prepare(`INSERT INTO agent_pay_sessions
161
+ (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, ps_flag, sub_mchid)
162
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
163
+ .run("buyer:card_old:tb-00000000000000ff", "p-phys-1", 1, 250, "CNY", "buyer:card_old", "tb-00000000000000ff",
164
+ "x".repeat(64), "y".repeat(64), "PAID", "", "", now + 900, now, now, 0, "");
165
+ const r = await fetch(base + "/api/orders/tb-00000000000000ff");
166
+ assert.strictEqual(r.status, 200);
167
+ const o = await r.json();
168
+ assert.strictEqual(o.shipping_address, "", "老订单地址空串");
169
+ assert.strictEqual(o.delivery_credential, "", "老订单凭据空串");
170
+ assert.strictEqual(o.status, "paid", "状态机不受影响");
171
+ assert.strictEqual(o.amount, 2.5);
172
+ } finally { srv.close(); }
173
+ });