trustbase-seller-backend 0.7.0 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -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: 'PRODUCT_TYPE_UNSPECIFIED', // proto 默认值 0 (实体物品)
277
+ product_type: chainProductType, // 2026-10-06: 不再写死 (见 mapDbProductTypeToChain)
232
278
  };
233
279
  let result = await sendTxAs(c, addr, [listMsg]);
234
280
  let action = 'list';
@@ -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
+ });