trustbase-seller-backend 0.6.1 → 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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.6.1",
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": {
@@ -70,8 +70,9 @@ CREATE INDEX IF NOT EXISTS idx_aps_state ON agent_pay_sessions(state);
70
70
  if (!_c.includes("sub_mchid")) db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN sub_mchid TEXT DEFAULT ''");
71
71
  } catch (_) {}
72
72
 
73
- // 2026-09-20 E4': 物流列 (老库兼容)
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 ''"]) {
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 ''"]) {
75
76
  try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
76
77
  }
77
78
  }
@@ -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) {
@@ -247,6 +258,8 @@ function createBuyerShopRouter(deps) {
247
258
  price: fenToYuan(unitFenOf(row)),
248
259
  image_url: productCols.includes("image_url") ? String(row.image_url || "") : "",
249
260
  stock: Number(row.stock) || 0,
261
+ // A1: 商品类型 (physical=实物要收货地址 / virtual=虚拟免地址发凭据); 无该列的老库默认 physical
262
+ product_type: productCols.includes("product_type") ? String(row.product_type || "physical") : "physical",
250
263
  };
251
264
  }
252
265
 
@@ -278,6 +291,9 @@ function createBuyerShopRouter(deps) {
278
291
  quantity: row.qty,
279
292
  tx_hash: txHash,
280
293
  code_url: row.psp_code_url || "",
294
+ // A1: 收货地址 / 交付凭据 (老订单两列为空 → 空串)
295
+ shipping_address: String(row.shipping_address || ""),
296
+ delivery_credential: String(row.delivery_credential || ""),
281
297
  };
282
298
  }
283
299
 
@@ -365,6 +381,13 @@ function createBuyerShopRouter(deps) {
365
381
  if (!(unitFen >= 1)) return err("PRICE_UNAVAILABLE", 409, "该商品暂未定价,暂不可购买");
366
382
  const totalFen = unitFen * qty;
367
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
+
368
391
  // 幂等②: 无 client_order_no 时, 同买家同商品 5 分钟内未支付未过期单直接复用
369
392
  const now = nowSec();
370
393
  if (!clientOrderNo) {
@@ -388,6 +411,11 @@ function createBuyerShopRouter(deps) {
388
411
  const clientSessionId = `buyer:${buyerCard}:${clientOrderNo || orderRef}`;
389
412
  const exp = now + SESSION_TTL_SEC;
390
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
+
391
419
  // 微信 Native 下单拿 code_url (mock 模式生成伪码, 不碰真微信)
392
420
  let codeUrl = "";
393
421
  if (MODE === "mock") {
@@ -409,11 +437,11 @@ function createBuyerShopRouter(deps) {
409
437
  // 买家面无账号体系, 订单 id 即凭据 — 与 pwa-shop-demo 轮询模型一致)
410
438
  // U7: ps_flag 台账标注该单是否带分账标记 (运营台区分用)
411
439
  db.prepare(`INSERT INTO agent_pay_sessions
412
- (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)
413
- 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
414
442
  .run(clientSessionId, productId, qty, totalFen, "CNY", "buyer:" + buyerCard, orderRef,
415
443
  crypto.randomBytes(32).toString("hex"), crypto.randomBytes(32).toString("hex"),
416
- "CREATED", codeUrl, "", exp, now, now, psFlag, subMchid);
444
+ "CREATED", codeUrl, "", exp, now, now, psFlag, subMchid, shippingAddress, deliveryCredential);
417
445
  console.log(`[buyer-shop] 下单 ${orderRef} product=${productId} qty=${qty} total=${totalFen}分 ps_flag=${psFlag}`);
418
446
  const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
419
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();
@@ -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
+ });