trustbase-seller-backend 0.6.1 → 0.7.1
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/api/seller-products.js +56 -4
- 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/routes/chain-writes-products.js +48 -2
- package/test-buyer-shop-ps.js +13 -10
- package/test-buyer-shop.js +14 -13
- package/test-product-type-chain.js +73 -0
- package/test-product-type-write.js +211 -0
- package/test-product-type.js +173 -0
package/api/seller-products.js
CHANGED
|
@@ -22,7 +22,27 @@ exports.createSellerProductsRouter = createSellerProductsRouter;
|
|
|
22
22
|
const express_1 = require("express");
|
|
23
23
|
const crypto_1 = require("crypto");
|
|
24
24
|
const wallet_auth_1 = require("../middleware/wallet-auth");
|
|
25
|
+
// A1: 复用 buyer-shop 的建表/迁移 (它已 export ensureBuyerTables; 无反向依赖, 无循环引用风险)
|
|
26
|
+
const buyer_shop_1 = require("../routes/buyer-shop");
|
|
25
27
|
const VALID_STATUSES = ['draft', 'active', 'out_of_stock', 'archived'];
|
|
28
|
+
/**
|
|
29
|
+
* A1 商品类型白名单 —— 与 `buyer-shop.js` 下单侧判定**同一口径**(`physical` 要收货地址,
|
|
30
|
+
* `virtual` 免地址并生成交付凭证)。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ 这里**显式拒绝**非法值而不是静默回退成 `physical`:下单侧是
|
|
33
|
+
* `=== 'virtual' ? virtual : physical`, 若上架时误传链上枚举名(如 `saas`)却被静默存成
|
|
34
|
+
* `physical`, 买家买虚拟商品时会被迫填收货地址 —— 那是静默的业务错误, 比 400 更糟。
|
|
35
|
+
* 缺省才回退 `physical`(向后兼容:老客户端不传该字段不受影响)。
|
|
36
|
+
*
|
|
37
|
+
* 注:链上 proto 的 5 值枚举(UNSPECIFIED/PHYSICAL/DIGITAL/SAAS/SERVICE)与此处的 2 值
|
|
38
|
+
* 不是同一套口径, 映射规则待 xiaomu 拍板后在此处收敛。
|
|
39
|
+
*/
|
|
40
|
+
const VALID_PRODUCT_TYPES = ['physical', 'virtual'];
|
|
41
|
+
/** 缺省 `physical`(DB 列 `NOT NULL DEFAULT 'physical'`, 与之保持一致) */
|
|
42
|
+
const DEFAULT_PRODUCT_TYPE = 'physical';
|
|
43
|
+
function normalizeProductType(value) {
|
|
44
|
+
return typeof value === 'string' && value !== '' ? value : DEFAULT_PRODUCT_TYPE;
|
|
45
|
+
}
|
|
26
46
|
function parsePagination(req) {
|
|
27
47
|
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
|
28
48
|
const pageSize = Math.min(100, Math.max(1, parseInt(String(req.query.page_size || '20'), 10) || 20));
|
|
@@ -34,6 +54,16 @@ function parsePagination(req) {
|
|
|
34
54
|
function createSellerProductsRouter(config) {
|
|
35
55
|
const router = (0, express_1.Router)();
|
|
36
56
|
const { db, enableAuth = true } = config;
|
|
57
|
+
// A1: 建品/改品要写 `product_type`, 老库没这列会直接
|
|
58
|
+
// `table store_products has no column named product_type` → 商家建品 500。
|
|
59
|
+
// `buyer-shop` 也会建这列, 但两个 router 的加载顺序不保证谁先到, 这里自建一次兜底
|
|
60
|
+
// (`ensureBuyerTables` 内部对已存在的列是 no-op, 重复调用安全)。
|
|
61
|
+
try {
|
|
62
|
+
(0, buyer_shop_1.ensureBuyerTables)(db);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
console.warn('[seller-products] ensureBuyerTables 失败 (建品可能受影响):', e.message);
|
|
66
|
+
}
|
|
37
67
|
// Lesson #308: 全部端点走 wallet signature 鉴权
|
|
38
68
|
if (enableAuth !== false) {
|
|
39
69
|
router.use((0, wallet_auth_1.walletAuth)());
|
|
@@ -143,6 +173,15 @@ function createSellerProductsRouter(config) {
|
|
|
143
173
|
errorMessage: `status must be one of ${VALID_STATUSES.join('|')}, got ${status}`,
|
|
144
174
|
});
|
|
145
175
|
}
|
|
176
|
+
// A1: 商品类型 (缺省 physical; 非法值显式 400 —— 不静默降级, 见 VALID_PRODUCT_TYPES 注释)
|
|
177
|
+
const productType = normalizeProductType(input.product_type);
|
|
178
|
+
if (!VALID_PRODUCT_TYPES.includes(productType)) {
|
|
179
|
+
return res.status(400).json({
|
|
180
|
+
success: false,
|
|
181
|
+
errorCode: 'INVALID_PRODUCT_TYPE',
|
|
182
|
+
errorMessage: `product_type must be one of ${VALID_PRODUCT_TYPES.join('|')}, got ${input.product_type}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
146
185
|
// 2. 验商家注册 (Lesson #266b: 必须在 store_settings 中 active 才能创建商品)
|
|
147
186
|
const sellerSetting = db
|
|
148
187
|
.prepare(`SELECT status FROM store_settings WHERE seller_address = ?`)
|
|
@@ -160,9 +199,9 @@ function createSellerProductsRouter(config) {
|
|
|
160
199
|
const tagsJson = JSON.stringify(input.tags || []);
|
|
161
200
|
db.prepare(`INSERT INTO store_products (
|
|
162
201
|
id, seller_address, title, description, price_utct, stock,
|
|
163
|
-
status, category, image_url, tags, featured, sales_count,
|
|
202
|
+
status, category, image_url, tags, featured, sales_count, product_type,
|
|
164
203
|
created_at, updated_at
|
|
165
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`).run(productId, sellerAddress, input.title, input.description || '', input.price_utct, input.stock, status, input.category || 'general', input.image_url || null, tagsJson, now, now);
|
|
204
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`).run(productId, sellerAddress, input.title, input.description || '', input.price_utct, input.stock, status, input.category || 'general', input.image_url || null, tagsJson, productType, now, now);
|
|
166
205
|
// 4. 返回 (Lesson #335: 字段一致)
|
|
167
206
|
const product = db
|
|
168
207
|
.prepare(`SELECT * FROM store_products WHERE id = ?`)
|
|
@@ -268,10 +307,21 @@ function createSellerProductsRouter(config) {
|
|
|
268
307
|
// 4. 更新
|
|
269
308
|
const now = Date.now();
|
|
270
309
|
const tagsJson = input.tags !== undefined ? JSON.stringify(input.tags) : row.tags;
|
|
310
|
+
// A1: 商品类型可改 (缺省沿用原值; 非法值显式 400 —— 不静默降级)
|
|
311
|
+
if (input.product_type !== undefined && !VALID_PRODUCT_TYPES.includes(input.product_type)) {
|
|
312
|
+
return res.status(400).json({
|
|
313
|
+
success: false,
|
|
314
|
+
errorCode: 'INVALID_PRODUCT_TYPE',
|
|
315
|
+
errorMessage: `product_type must be one of ${VALID_PRODUCT_TYPES.join('|')}, got ${input.product_type}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const nextProductType = input.product_type !== undefined
|
|
319
|
+
? input.product_type
|
|
320
|
+
: normalizeProductType(row.product_type);
|
|
271
321
|
db.prepare(`UPDATE store_products SET
|
|
272
322
|
title = ?, description = ?, price_utct = ?, category = ?,
|
|
273
|
-
image_url = ?, tags = ?, status = ?, updated_at = ?
|
|
274
|
-
WHERE id = ?`).run(input.title !== undefined ? input.title : row.title, input.description !== undefined ? input.description : row.description, input.price_utct !== undefined ? input.price_utct : row.price_utct, input.category !== undefined ? input.category : row.category, input.image_url !== undefined ? input.image_url : row.image_url, tagsJson, newStatus, now, productId);
|
|
323
|
+
image_url = ?, tags = ?, status = ?, product_type = ?, updated_at = ?
|
|
324
|
+
WHERE id = ?`).run(input.title !== undefined ? input.title : row.title, input.description !== undefined ? input.description : row.description, input.price_utct !== undefined ? input.price_utct : row.price_utct, input.category !== undefined ? input.category : row.category, input.image_url !== undefined ? input.image_url : row.image_url, tagsJson, newStatus, nextProductType, now, productId);
|
|
275
325
|
// 5. 返回
|
|
276
326
|
const updated = db
|
|
277
327
|
.prepare(`SELECT * FROM store_products WHERE id = ?`)
|
|
@@ -446,6 +496,8 @@ function rowToProduct(row) {
|
|
|
446
496
|
tags,
|
|
447
497
|
featured: row.featured === 1 ? 1 : 0,
|
|
448
498
|
sales_count: row.sales_count,
|
|
499
|
+
// A1: 缺列的老库 / NULL 一律按 physical —— 与下单侧判定同口径
|
|
500
|
+
product_type: normalizeProductType(row.product_type),
|
|
449
501
|
created_at: row.created_at,
|
|
450
502
|
updated_at: row.updated_at,
|
|
451
503
|
};
|
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));
|
|
@@ -10,6 +10,7 @@ exports.findSlotBySku = findSlotBySku;
|
|
|
10
10
|
exports.replayHit = replayHit;
|
|
11
11
|
exports.logWrite = logWrite;
|
|
12
12
|
exports.createChainWritesProductsRouter = createChainWritesProductsRouter;
|
|
13
|
+
exports.mapDbProductTypeToChain = mapDbProductTypeToChain;
|
|
13
14
|
/**
|
|
14
15
|
* chain-writes-products.ts — 商品上链路由 (WooCommerce 商品 → 链上 x/marketplace)
|
|
15
16
|
*
|
|
@@ -57,6 +58,40 @@ const CATEGORY_RE = /^[A-Za-z0-9 _-]*$/;
|
|
|
57
58
|
/** keeper ListSKU 重复报错文案 (msg_server.go: "SKU already listed: %s") */
|
|
58
59
|
exports.DUP_SKU_RE = /already listed|already exists|duplicate/i;
|
|
59
60
|
const REPLAY_WINDOW_SQL = "created_at >= datetime('now', '-5 minutes')";
|
|
61
|
+
/**
|
|
62
|
+
* A1 商品类型映射 (2026-10-06 xiaomu 拍板**口径 A**)。
|
|
63
|
+
*
|
|
64
|
+
* | DB (二值, 决定"要不要收货地址") | 链上 `ProductType` |
|
|
65
|
+
* |---|---|
|
|
66
|
+
* | `physical` (缺省) | `PRODUCT_TYPE_PHYSICAL` |
|
|
67
|
+
* | `virtual` | `PRODUCT_TYPE_DIGITAL` |
|
|
68
|
+
*
|
|
69
|
+
* 为什么 `virtual` 落到 `DIGITAL`: 链上另有 `SAAS` / `SERVICE` 两个非实体值, 但 xiaomu 定的是
|
|
70
|
+
* "商品类型只用于一个决策——要不要匹配收货地址", 细分语义暂不使用, 统一收敛到 `DIGITAL`。
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ 非法值**显式 400** 而不是静默回落 `physical`: 静默回落会让虚拟商品被当成实体,
|
|
73
|
+
* 买家下单时被迫填收货地址 —— 静默的业务错误比 400 危险。缺省才回落 `physical`
|
|
74
|
+
* (向后兼容: 老 hook 不传该字段)。
|
|
75
|
+
*
|
|
76
|
+
* 注: `PRODUCT_TYPE_UNSPECIFIED`(=0) 不再用于新品 —— 它是 proto 的"未指定"默认值,
|
|
77
|
+
* 显式写 PHYSICAL 比留 UNSPECIFIED 更清晰, 也与 DB 侧口径一致。
|
|
78
|
+
*/
|
|
79
|
+
const DB_PRODUCT_TYPES = ['physical', 'virtual'];
|
|
80
|
+
const DB_TO_CHAIN_PRODUCT_TYPE = {
|
|
81
|
+
physical: 'PRODUCT_TYPE_PHYSICAL',
|
|
82
|
+
virtual: 'PRODUCT_TYPE_DIGITAL',
|
|
83
|
+
};
|
|
84
|
+
/** 缺省 `physical`; 非法值抛 400 */
|
|
85
|
+
function mapDbProductTypeToChain(value) {
|
|
86
|
+
if (value === undefined || value === null || value === '') return DB_TO_CHAIN_PRODUCT_TYPE.physical;
|
|
87
|
+
const key = String(value).toLowerCase();
|
|
88
|
+
const mapped = DB_TO_CHAIN_PRODUCT_TYPE[key];
|
|
89
|
+
if (mapped === undefined) {
|
|
90
|
+
throw new chain_writes_1.HttpError(400, 'invalid_param',
|
|
91
|
+
`product_type must be one of ${DB_PRODUCT_TYPES.join('|')}, got ${value}`);
|
|
92
|
+
}
|
|
93
|
+
return mapped;
|
|
94
|
+
}
|
|
60
95
|
function sha256Hex(s) {
|
|
61
96
|
return crypto_1.default.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
62
97
|
}
|
|
@@ -198,14 +233,25 @@ function createChainWritesProductsRouter(deps) {
|
|
|
198
233
|
const imageUrls = assertImageUrls(b.image_urls);
|
|
199
234
|
const category = assertCategory(b.category);
|
|
200
235
|
const skuId = `${assertSkuPrefix(b.sku_prefix)}-${wpPostId}`;
|
|
236
|
+
// A1 (2026-10-06 xiaomu 拍板口径 A): DB 二值 physical/virtual → 链上 5 值枚举。
|
|
237
|
+
// 选项 A 的映射: virtual → PRODUCT_TYPE_DIGITAL (数字产品);
|
|
238
|
+
// 其余一律 PHYSICAL。链上 DIGITAL/SAAS/SERVICE 的细分语义**暂不使用**
|
|
239
|
+
// (xiaomu: "商品类型只用于一个决策——要不要匹配收货地址")。
|
|
240
|
+
// ⚠️ 这只是 `MsgListSKU` 的链上字段; DB 侧仍是二值, 两侧靠本函数收敛。
|
|
241
|
+
const chainProductType = mapDbProductTypeToChain(b.product_type);
|
|
201
242
|
// content_hash: sha256(JSON) 的 raw bytes → proto JSON bytes 字段要 base64
|
|
243
|
+
// 2026-10-06: product_type **不进 content_hash** —— 它是链上独立的枚举字段,
|
|
244
|
+
// 已由 MsgListSKU.product_type 承载; 塞进内容哈希会改变**已上架商品**的链上锚定,
|
|
245
|
+
// 代价大于收益。xiao mu 未要求, 保守处理 (见 A1 设计文档 §7.4)。
|
|
202
246
|
const contentHashHex = sha256Hex(JSON.stringify({
|
|
203
247
|
title, description, image_urls: imageUrls, category, price_usd_cents: priceUsdCents,
|
|
204
248
|
}));
|
|
205
249
|
const contentHashB64 = Buffer.from(contentHashHex, 'hex').toString('base64');
|
|
250
|
+
// payload_hash: 幂等键。2026-10-06 **必须包含 product_type** ——
|
|
251
|
+
// 否则商家改商品类型后重发同一请求会被 replayHit 判成重放, 改了不生效。
|
|
206
252
|
const payloadHash = sha256Hex(JSON.stringify({
|
|
207
253
|
wp_post_id: wpPostId, title, description, price_usd_cents: priceUsdCents,
|
|
208
|
-
stock, image_urls: imageUrls, category,
|
|
254
|
+
stock, image_urls: imageUrls, category, product_type: chainProductType,
|
|
209
255
|
}));
|
|
210
256
|
// 幂等短路: 完全相同 payload 5 分钟内重放 → 返回上次 txhash, 不再发链
|
|
211
257
|
// (list/add_stock 两个 key 都查: 上次可能走了重复转 AddStock 路径)
|
|
@@ -228,7 +274,7 @@ function createChainWritesProductsRouter(deps) {
|
|
|
228
274
|
stake_per_unit: process.env.CHAIN_STAKE_PER_UNIT || '1000000',
|
|
229
275
|
cdn_url: '',
|
|
230
276
|
p2p_endpoint: process.env.P2P_ENDPOINT || '',
|
|
231
|
-
product_type:
|
|
277
|
+
product_type: chainProductType, // 2026-10-06: 不再写死 (见 mapDbProductTypeToChain)
|
|
232
278
|
};
|
|
233
279
|
let result = await sendTxAs(c, addr, [listMsg]);
|
|
234
280
|
let action = 'list';
|
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,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-product-type-chain.js — A1 **链上上架**侧 (2026-10-06, xiaomu 拍板口径 A)
|
|
4
|
+
* node --test test-product-type-chain.js
|
|
5
|
+
*
|
|
6
|
+
* 覆盖 `routes/chain-writes-products.js` 的 DB→链上枚举映射, 以及两个哈希的取舍:
|
|
7
|
+
*
|
|
8
|
+
* 1. `physical` → `PRODUCT_TYPE_PHYSICAL` (缺省也是它, 向后兼容老 hook)
|
|
9
|
+
* 2. `virtual` → `PRODUCT_TYPE_DIGITAL` (口径 A: 细分语义暂不使用)
|
|
10
|
+
* 3. 大小写不敏感
|
|
11
|
+
* 4. 非法值 / 链上枚举名 (`saas`/`digital`) → **显式 400**, 不静默回落 physical
|
|
12
|
+
* ⚠️ 静默回落会让虚拟商品被当实体, 买家被迫填收货地址
|
|
13
|
+
* 5. `payload_hash` **含** product_type ⇒ 改类型后不会被幂等短路
|
|
14
|
+
* 6. `content_hash` **不含** product_type ⇒ 不改变已上架商品的链上锚定
|
|
15
|
+
*
|
|
16
|
+
* 本文件不碰真链: 只测纯函数 + 用源码结构断言两个哈希的取舍 (口径 A 的关键决策点)。
|
|
17
|
+
*/
|
|
18
|
+
const test = require("node:test");
|
|
19
|
+
const assert = require("node:assert");
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const path = require("node:path");
|
|
22
|
+
|
|
23
|
+
const { mapDbProductTypeToChain } = require("./routes/chain-writes-products");
|
|
24
|
+
|
|
25
|
+
const SRC = fs.readFileSync(path.join(__dirname, "routes/chain-writes-products.js"), "utf8");
|
|
26
|
+
|
|
27
|
+
test("口径 A 映射: physical → PRODUCT_TYPE_PHYSICAL", () => {
|
|
28
|
+
assert.strictEqual(mapDbProductTypeToChain("physical"), "PRODUCT_TYPE_PHYSICAL");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("口径 A 映射: 缺省 → PRODUCT_TYPE_PHYSICAL (向后兼容老 hook 不传该字段)", () => {
|
|
32
|
+
assert.strictEqual(mapDbProductTypeToChain(undefined), "PRODUCT_TYPE_PHYSICAL");
|
|
33
|
+
assert.strictEqual(mapDbProductTypeToChain(null), "PRODUCT_TYPE_PHYSICAL");
|
|
34
|
+
assert.strictEqual(mapDbProductTypeToChain(""), "PRODUCT_TYPE_PHYSICAL");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("口径 A 映射: virtual → PRODUCT_TYPE_DIGITAL (不是 SAAS/SERVICE)", () => {
|
|
38
|
+
assert.strictEqual(mapDbProductTypeToChain("virtual"), "PRODUCT_TYPE_DIGITAL");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("口径 A 映射: 大小写不敏感", () => {
|
|
42
|
+
assert.strictEqual(mapDbProductTypeToChain("VIRTUAL"), "PRODUCT_TYPE_DIGITAL");
|
|
43
|
+
assert.strictEqual(mapDbProductTypeToChain("Physical"), "PRODUCT_TYPE_PHYSICAL");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("口径 A 映射: 链上枚举名当入参 → 显式抛错, 不静默回落", () => {
|
|
47
|
+
// 这三个是链上 proto 的值, 不是 DB 的值 —— 直接传进来必须报错, 否则会静默变实体
|
|
48
|
+
for (const bad of ["saas", "digital", "service", "PRODUCT_TYPE_SAAS"]) {
|
|
49
|
+
assert.throws(() => mapDbProductTypeToChain(bad), (err) => {
|
|
50
|
+
assert.match(err.message, /product_type must be one of physical\|virtual/);
|
|
51
|
+
return true;
|
|
52
|
+
}, `输入 ${bad} 应当抛错`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("幂等键 payload_hash 含 product_type (改类型不会被重放短路)", () => {
|
|
57
|
+
const m = SRC.match(/const payloadHash = sha256Hex\(JSON\.stringify\(\{([^}]*)\}\)\)/);
|
|
58
|
+
assert.ok(m, "应能定位到 payloadHash 计算块");
|
|
59
|
+
assert.match(m[1], /product_type/, "payload_hash 必须含 product_type, 否则改类型会被幂等当重放");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("content_hash 不含 product_type (不改变已上架商品的链上锚定)", () => {
|
|
63
|
+
const m = SRC.match(/const contentHashHex = sha256Hex\(JSON\.stringify\(\{([^}]*)\}\)\)/);
|
|
64
|
+
assert.ok(m, "应能定位到 contentHash 计算块");
|
|
65
|
+
assert.doesNotMatch(m[1], /product_type/,
|
|
66
|
+
"content_hash 不应含 product_type —— 会改变已上架商品的链上内容哈希");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("上架 msg 不再写死 PRODUCT_TYPE_UNSPECIFIED", () => {
|
|
70
|
+
assert.doesNotMatch(SRC, /product_type:\s*'PRODUCT_TYPE_UNSPECIFIED'/,
|
|
71
|
+
"应已改为走 mapDbProductTypeToChain");
|
|
72
|
+
assert.match(SRC, /product_type:\s*chainProductType/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-product-type-write.js — A1 **写入端**离线单测 (2026-10-06 补)
|
|
4
|
+
* node --test test-product-type-write.js
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么单独一个文件
|
|
7
|
+
*
|
|
8
|
+
* 原 `test-product-type.js` 的 5 个用例**全部直接 INSERT 数据库**再打下单接口,
|
|
9
|
+
* 没有一个走过**建品接口**。于是"商家建品时无法指定 product_type"这条断链
|
|
10
|
+
* 当时完全测不出来 —— 单测全绿,真实链路上 `virtual` 却永远写不进去。
|
|
11
|
+
*
|
|
12
|
+
* 本文件专门覆盖写入端,让这种断链以后能被测出来。
|
|
13
|
+
*
|
|
14
|
+
* 覆盖:
|
|
15
|
+
* 1. 建品传 `product_type: 'virtual'` → 真落库 (断链一回归)
|
|
16
|
+
* 2. 建品不传 → 默认 `physical` (向后兼容, 老客户端不受影响)
|
|
17
|
+
* 3. 建品传**非法值** (如链上枚举名 `saas`) → 400 INVALID_PRODUCT_TYPE
|
|
18
|
+
* ⚠️ 必须报错而不是静默降级成 physical: 下单侧是 `=== 'virtual' ? virtual : physical`,
|
|
19
|
+
* 静默降级会让买虚拟商品的买家被迫填收货地址
|
|
20
|
+
* 4. 改品 `product_type` → 可改 (physical ⇄ virtual)
|
|
21
|
+
* 5. 改品不传该字段 → 沿用原值, 不被意外重置
|
|
22
|
+
* 6. 响应体回显 `product_type`
|
|
23
|
+
* 7. 老库缺 `product_type` 列时 rowToProduct 仍返回 `physical` (不炸)
|
|
24
|
+
*
|
|
25
|
+
* 离��: `:memory:` 库 + express, `enableAuth: false` 绕开 wallet 签名 (本文件只测参数校验与落库)。
|
|
26
|
+
*/
|
|
27
|
+
const test = require("node:test");
|
|
28
|
+
const assert = require("node:assert");
|
|
29
|
+
const express = require("express");
|
|
30
|
+
const Database = require("better-sqlite3");
|
|
31
|
+
|
|
32
|
+
const { createSellerProductsRouter } = require("./api/seller-products");
|
|
33
|
+
|
|
34
|
+
const SELLER = "tct1" + "s".repeat(38);
|
|
35
|
+
const NOW_MS = Date.now();
|
|
36
|
+
|
|
37
|
+
/** A1 迁移后的 schema (含 product_type 列) */
|
|
38
|
+
function seed(db) {
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE TABLE store_products (
|
|
41
|
+
id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
|
|
42
|
+
description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
|
|
43
|
+
stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
|
|
44
|
+
category TEXT DEFAULT 'general', image_url TEXT, tags TEXT DEFAULT '[]',
|
|
45
|
+
featured INTEGER NOT NULL DEFAULT 0, sales_count INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
product_type TEXT NOT NULL DEFAULT 'physical',
|
|
47
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE store_settings (seller_address TEXT PRIMARY KEY, status TEXT NOT NULL);
|
|
50
|
+
`);
|
|
51
|
+
db.prepare("INSERT INTO store_settings (seller_address, status) VALUES (?, 'active')").run(SELLER);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 老库: 没有 product_type 列 (验证向后兼容不炸) */
|
|
55
|
+
function seedLegacyNoColumn(db) {
|
|
56
|
+
db.exec(`
|
|
57
|
+
CREATE TABLE store_products (
|
|
58
|
+
id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
|
|
59
|
+
description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
|
|
60
|
+
stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
|
|
61
|
+
category TEXT DEFAULT 'general', image_url TEXT, tags TEXT DEFAULT '[]',
|
|
62
|
+
featured INTEGER NOT NULL DEFAULT 0, sales_count INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE store_settings (seller_address TEXT PRIMARY KEY, status TEXT NOT NULL);
|
|
66
|
+
`);
|
|
67
|
+
db.prepare("INSERT INTO store_settings (seller_address, status) VALUES (?, 'active')").run(SELLER);
|
|
68
|
+
// 前置: 确认这张表确实没有 product_type 列(boot 里 router 会把它补上)
|
|
69
|
+
assert.ok(
|
|
70
|
+
!db.prepare("PRAGMA table_info(store_products)").all().map((c) => c.name).includes("product_type"),
|
|
71
|
+
"前置失败: seedLegacyNoColumn 建出的表不该有 product_type 列"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function post(port, body) {
|
|
76
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/seller/products`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json" },
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
return { status: r.status, body: await r.json() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function patch(port, id, body) {
|
|
85
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/seller/products/${id}`, {
|
|
86
|
+
method: "PUT",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
});
|
|
90
|
+
return { status: r.status, body: await r.json() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 起服务并确保端口就绪 (app.listen(0) 之后 address() 才可用) */
|
|
94
|
+
async function boot(seedFn) {
|
|
95
|
+
const db = new Database(":memory:");
|
|
96
|
+
seedFn(db);
|
|
97
|
+
const app = express();
|
|
98
|
+
app.use(express.json());
|
|
99
|
+
app.use((req, _res, next) => {
|
|
100
|
+
req.walletAddress = SELLER;
|
|
101
|
+
next();
|
|
102
|
+
});
|
|
103
|
+
app.use("/api/seller", createSellerProductsRouter({ db, enableAuth: false }));
|
|
104
|
+
const srv = await new Promise((resolve) => {
|
|
105
|
+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
|
106
|
+
});
|
|
107
|
+
return { db, port: srv.address().port, close: () => srv.close() };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("A1 写入端: 建品能指定 virtual —— 断链一回归", async () => {
|
|
111
|
+
const { db, port, close } = await boot(seed);
|
|
112
|
+
try {
|
|
113
|
+
const r = await post(port, { title: "技能商店基础版", price_utct: 1000000, stock: 99, product_type: "virtual" });
|
|
114
|
+
assert.strictEqual(r.status, 201, "建品应成功");
|
|
115
|
+
assert.strictEqual(r.body.product.product_type, "virtual", "响应体应回显 virtual");
|
|
116
|
+
|
|
117
|
+
// 真落库核验 (不能只看响应)
|
|
118
|
+
const row = db.prepare("SELECT product_type FROM store_products WHERE id = ?").get(r.body.product.id);
|
|
119
|
+
assert.strictEqual(row.product_type, "virtual", "DB 里必须真的写进去了");
|
|
120
|
+
} finally { close(); }
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("A1 写入端: 不传 product_type → 默认 physical (向后兼容)", async () => {
|
|
124
|
+
const { db, port, close } = await boot(seed);
|
|
125
|
+
try {
|
|
126
|
+
const r = await post(port, { title: "云南滇红 250g", price_utct: 2500000, stock: 10 });
|
|
127
|
+
assert.strictEqual(r.status, 201);
|
|
128
|
+
assert.strictEqual(r.body.product.product_type, "physical");
|
|
129
|
+
const row = db.prepare("SELECT product_type FROM store_products WHERE id = ?").get(r.body.product.id);
|
|
130
|
+
assert.strictEqual(row.product_type, "physical");
|
|
131
|
+
} finally { close(); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("A1 写入端: 非法 product_type → 400, 绝不静默降级成 physical", async () => {
|
|
135
|
+
const { db, port, close } = await boot(seed);
|
|
136
|
+
try {
|
|
137
|
+
// 链上 proto 的枚举名 —— 属于 §7.2 待拍板的口径冲突, 现在必须明确拒绝而不是静默吞掉
|
|
138
|
+
const r = await post(port, { title: "订阅服务", price_utct: 100, stock: 1, product_type: "saas" });
|
|
139
|
+
assert.strictEqual(r.status, 400, "非法值必须 400");
|
|
140
|
+
assert.strictEqual(r.body.errorCode, "INVALID_PRODUCT_TYPE");
|
|
141
|
+
// 关键: 不能建出商品来
|
|
142
|
+
const n = db.prepare("SELECT COUNT(*) c FROM store_products").get().c;
|
|
143
|
+
assert.strictEqual(n, 0, "被拒的商品不应落库");
|
|
144
|
+
} finally { close(); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("A1 写入端: product_type 可改 (physical ⇄ virtual)", async () => {
|
|
148
|
+
const { db, port, close } = await boot(seed);
|
|
149
|
+
try {
|
|
150
|
+
const created = await post(port, { title: "手作陶壶", price_utct: 8800000, stock: 5, product_type: "physical" });
|
|
151
|
+
const id = created.body.product.id;
|
|
152
|
+
|
|
153
|
+
const r1 = await patch(port, id, { product_type: "virtual" });
|
|
154
|
+
assert.strictEqual(r1.status, 200);
|
|
155
|
+
assert.strictEqual(r1.body.product.product_type, "virtual");
|
|
156
|
+
assert.strictEqual(
|
|
157
|
+
db.prepare("SELECT product_type FROM store_products WHERE id = ?").get(id).product_type,
|
|
158
|
+
"virtual"
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const r2 = await patch(port, id, { product_type: "physical" });
|
|
162
|
+
assert.strictEqual(r2.body.product.product_type, "physical");
|
|
163
|
+
} finally { close(); }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("A1 写入端: 改品不传该字段 → 沿用原值, 不被重置", async () => {
|
|
167
|
+
const { db, port, close } = await boot(seed);
|
|
168
|
+
try {
|
|
169
|
+
const created = await post(port, { title: "入门课程", price_utct: 5000, stock: 9, product_type: "virtual" });
|
|
170
|
+
const id = created.body.product.id;
|
|
171
|
+
// 只改标题, 不碰类型
|
|
172
|
+
const r = await patch(port, id, { title: "课程 (改名)" });
|
|
173
|
+
assert.strictEqual(r.status, 200);
|
|
174
|
+
assert.strictEqual(r.body.product.product_type, "virtual", "类型不应被意外重置成 physical");
|
|
175
|
+
} finally { close(); }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("A1 写入端: 改品传非法值 → 400, 原值不变", async () => {
|
|
179
|
+
const { db, port, close } = await boot(seed);
|
|
180
|
+
try {
|
|
181
|
+
const created = await post(port, { title: "插件工具包", price_utct: 1000, stock: 3, product_type: "virtual" });
|
|
182
|
+
const id = created.body.product.id;
|
|
183
|
+
const r = await patch(port, id, { product_type: "digital" });
|
|
184
|
+
assert.strictEqual(r.status, 400);
|
|
185
|
+
assert.strictEqual(r.body.errorCode, "INVALID_PRODUCT_TYPE");
|
|
186
|
+
assert.strictEqual(
|
|
187
|
+
db.prepare("SELECT product_type FROM store_products WHERE id = ?").get(id).product_type,
|
|
188
|
+
"virtual",
|
|
189
|
+
"被拒的修改不应生效"
|
|
190
|
+
);
|
|
191
|
+
} finally { close(); }
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("A1 写入端: 老库缺 product_type 列 → 自动补列, 建品成功 (不是 500)", async () => {
|
|
195
|
+
const { db, port, close } = await boot(seedLegacyNoColumn);
|
|
196
|
+
try {
|
|
197
|
+
// 修复证据: router 初始化时 ensureBuyerTables 已把列补上
|
|
198
|
+
const cols = db.prepare("PRAGMA table_info(store_products)").all().map((c) => c.name);
|
|
199
|
+
assert.ok(cols.includes("product_type"), "router 应已自动补上 product_type 列");
|
|
200
|
+
|
|
201
|
+
const r = await post(port, { title: "老库商品", price_utct: 100, stock: 1, product_type: "virtual" });
|
|
202
|
+
// router 初始化时 ensureBuyerTables 应已把列补上 —— 老库商家不该被 500 拦住
|
|
203
|
+
assert.strictEqual(r.status, 201, `老库建品应成功, 实得 ${r.status}: ${JSON.stringify(r.body)}`);
|
|
204
|
+
assert.strictEqual(r.body.product.product_type, "virtual");
|
|
205
|
+
|
|
206
|
+
// 不传类型时也应回落 physical, 而不是因为补列前的 NULL 出问题
|
|
207
|
+
const r2 = await post(port, { title: "老库商品二", price_utct: 100, stock: 1 });
|
|
208
|
+
assert.strictEqual(r2.status, 201);
|
|
209
|
+
assert.strictEqual(r2.body.product.product_type, "physical");
|
|
210
|
+
} finally { close(); }
|
|
211
|
+
});
|
|
@@ -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
|
+
});
|