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.
- package/db/store-migrate.js +2 -0
- package/package.json +1 -1
- package/routes/agent-pay.js +3 -2
- package/routes/buyer-shop.js +33 -5
- package/test-buyer-shop-ps.js +13 -10
- package/test-buyer-shop.js +14 -13
- package/test-product-type.js +173 -0
package/db/store-migrate.js
CHANGED
|
@@ -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
package/routes/agent-pay.js
CHANGED
|
@@ -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
|
}
|
package/routes/buyer-shop.js
CHANGED
|
@@ -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
|
-
"
|
|
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));
|
package/test-buyer-shop-ps.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
}));
|
package/test-buyer-shop.js
CHANGED
|
@@ -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
|
+
});
|