trustbase-seller-backend 0.7.5 → 0.7.7

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.
@@ -49,12 +49,21 @@ async function fetchSlots(lcdUrl, seller) {
49
49
  const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
50
50
  if (!res.ok) throw new Error(`LCD ${res.status} ${url}`);
51
51
  const body = await res.json();
52
- const list = body && body.slots && Array.isArray(body.slots.slots) ? body.slots.slots : [];
53
- return list;
52
+ // 2026-10-18 fix: 200 + 错误形状不能再被当成“零 slot 成功”。
53
+ // 只接受 {slots:{slots:[...]}},其余一律视为上游异常并进入 degraded。
54
+ if (!body || !body.slots || !Array.isArray(body.slots.slots)) {
55
+ throw new Error(`LCD slots 响应形状错误 ${url}`);
56
+ }
57
+ return body.slots.slots;
54
58
  }
55
59
 
56
60
  function updateLocalFromSlot(db, product, slot) {
57
61
  const createdMs = Number(slot.created_at) > 0 ? Number(slot.created_at) * 1000 : Date.now();
62
+ // 2026-10-18 fix: 本地已有 chain_stake_per_unit 时保留卖家原始快照,
63
+ // 不能用链上声誉调整后的值覆盖。只有本地为空时才用链上值补齐。
64
+ const stake = (product.chain_stake_per_unit === null || product.chain_stake_per_unit === undefined || product.chain_stake_per_unit === "")
65
+ ? String(slot.stake_per_unit || "")
66
+ : product.chain_stake_per_unit;
58
67
  db.prepare(`UPDATE store_products SET
59
68
  chain_listed = 1,
60
69
  chain_sku_id = ?,
@@ -62,7 +71,7 @@ function updateLocalFromSlot(db, product, slot) {
62
71
  chain_stake_per_unit = ?,
63
72
  chain_listed_at = ?
64
73
  WHERE id = ?`)
65
- .run(slot.sku_id, Number(slot.slot_index), String(slot.stake_per_unit || ""), createdMs, product.id);
74
+ .run(slot.sku_id, Number(slot.slot_index), stake, createdMs, product.id);
66
75
  }
67
76
 
68
77
  /**
@@ -132,6 +141,8 @@ function startChainReconcile({ db, lcdUrl }) {
132
141
  console.log(`[ChainReconcile] ${r.status}: recovered=${r.recovered} warnings=${r.warnings} errors=${r.errors} checked=${r.checked}`);
133
142
  } catch (e) {
134
143
  console.error("[ChainReconcile] failed:", e.message);
144
+ // 2026-10-18: 启动/定时失败也必须留下 degraded 状态,不能沿用旧状态或为空。
145
+ try { saveState(db, "degraded", e.message); } catch (_) { /* 审计不阻塞业务 */ }
135
146
  } finally {
136
147
  running = false;
137
148
  }
@@ -160,10 +160,14 @@ function listAPIKeys() {
160
160
  */
161
161
  /** 隧道买家面放行判定 (2026-10-03, T2/Lane B)
162
162
  * 三条同时成立才放行: ① 来源回环 ② X-TB-Tunnel: 1 头 ③ 买家只读路径 */
163
- const TUNNEL_BUYER_PATHS = ['/api/products', '/api/orders'];
163
+ const TUNNEL_BUYER_PATHS = ['/api/products', '/api/orders', '/api/qr'];
164
164
  function isTunnelBuyerRequest(req, clientIP) {
165
165
  const fromLoopback = clientIP === '127.0.0.1' || clientIP === '::1' || /^127\./.test(clientIP);
166
166
  if (!fromLoopback) return false;
167
+ // 2026-10-18 security: 隧道放行只允许只读请求。
168
+ // 原实现未检查方法,带 X-TB-Tunnel 的 POST /api/products 等写操作会连续绕过
169
+ // requireAPIKey 与 sensitiveOpGuard 两层鉴权,形成真实写操作绕过。
170
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
167
171
  if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
168
172
  // 注意: 本中间件挂在 '/api' 上, req.path 是挂载后路径 ('/products'), req.originalUrl 才带 '/api'
169
173
  // → 两种前缀都要判, 否则放行条件恒为假 (2026-10-03 .211 实测 401 才发现)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
package/plugins/sqlite.js CHANGED
@@ -871,11 +871,24 @@ class SQLitePlugin {
871
871
  }
872
872
  if (this.storeProductsReady) {
873
873
  const imgArr = Array.isArray(product.images) ? product.images : [];
874
+ // 2026-10-18 fix: 不再 INSERT OR REPLACE。
875
+ // REPLACE 会删旧行重插,未提供的 product_type 会被重置成默认 physical。
876
+ // 改为 ON CONFLICT 只更新链同步负责的列,明确保留本地 product_type。
874
877
  this.db.prepare(`
875
- INSERT OR REPLACE INTO store_products (
878
+ INSERT INTO store_products (
876
879
  id, seller_address, title, description, price_utct, stock, status,
877
880
  category, image_url, tags, featured, sales_count, created_at, updated_at
878
881
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 0, 0, ?, ?)
882
+ ON CONFLICT(id) DO UPDATE SET
883
+ seller_address = excluded.seller_address,
884
+ title = excluded.title,
885
+ description = excluded.description,
886
+ price_utct = excluded.price_utct,
887
+ stock = excluded.stock,
888
+ status = excluded.status,
889
+ category = excluded.category,
890
+ image_url = excluded.image_url,
891
+ updated_at = excluded.updated_at
879
892
  `).run(product.skuId, product.seller, product.title, product.description, Math.round(Number(product.priceTct || 0) * 1_000_000), // price_tct(TCT 字符串) → utct
880
893
  product.stock, product.status === 'active' ? 'active' : 'archived', product.category.level1 || 'general', imgArr[0] || null, product.createdAt, product.updatedAt);
881
894
  }
@@ -282,10 +282,14 @@ function createBuyerShopRouter(deps) {
282
282
  const c = db.prepare("SELECT txhash FROM chain_write_log WHERE order_id = ?").get(row.chain_order_id);
283
283
  if (c && c.txhash) txHash = String(c.txhash);
284
284
  }
285
+ const status = mapStatus(row.state);
286
+ // 2026-10-18 security: 交付凭据只能在支付成功后公开。
287
+ // 原实现创建订单 pending 时就返回 delivery_credential,买家可不付款直接取走。
288
+ const canDeliver = status === "paid" || status === "completed";
285
289
  return {
286
290
  id: row.order_ref,
287
291
  product_id: row.sku_id, // Pi 复核 P2: 前端详情页跳转要 product_id (契约补齐)
288
- status: mapStatus(row.state),
292
+ status,
289
293
  amount: fenToYuan(row.amount_total),
290
294
  product_name: productName,
291
295
  quantity: row.qty,
@@ -293,7 +297,7 @@ function createBuyerShopRouter(deps) {
293
297
  code_url: row.psp_code_url || "",
294
298
  // A1: 收货地址 / 交付凭据 (老订单两列为空 → 空串)
295
299
  shipping_address: String(row.shipping_address || ""),
296
- delivery_credential: String(row.delivery_credential || ""),
300
+ delivery_credential: canDeliver ? String(row.delivery_credential || "") : "",
297
301
  };
298
302
  }
299
303
 
@@ -208,7 +208,7 @@ async function queryAccount(c, addr) {
208
208
  if (!acct || acct.account_number === undefined || acct.sequence === undefined) {
209
209
  throw new HttpError(502, 'lcd_upstream_error', `LCD 账户响应缺 account_number/sequence: ${addr}`);
210
210
  }
211
- return { accountNumber: String(acct.account_number), sequence: String(acct.sequence) };
211
+ return { accountNumber: String(acct.account_number), sequence: String(acct.sequence), address: addr };
212
212
  }
213
213
  /** 手写 unsigned tx JSON (cosmos proto JSON 格式, snake_case 原名 + uint64 字符串)
214
214
  * memo: v3.9.4 起下单走 "tb-auth:<sha256(整批订单意愿)>" (见 core/order-intent.js);
@@ -415,30 +415,36 @@ function tryParseBroadcast(out) {
415
415
  */
416
416
  exports.sendTxAs = sendTxAs;
417
417
  async function sendTxAs(c, messages, addr, opts) {
418
- const run = async () => {
419
- let account;
420
- try {
421
- account = await queryAccount(c, addr);
422
- }
423
- catch (e) {
424
- // N15: 0 余额新商户 (auth 账户都不存在) + feegrant 代付 → 首笔 tx 以账户号 0/0 发起
425
- const isNotFound = (e && e.code === 'account_not_found') || /account_not_found/.test(String(e && e.message));
426
- if (!isNotFound || !c.feeGranter)
427
- throw e;
428
- console.log(`[chain-writes] 地址未上链 + feegrant 代付, 以 account 0/0 发起首笔 tx: ${addr}`);
429
- account = { accountNumber: '0', sequence: '0' };
430
- }
431
- try {
432
- return await signAndBroadcastInner(c, messages, account, opts);
433
- }
434
- catch (e) {
435
- if (!(e instanceof SequenceMismatchError))
436
- throw e;
437
- console.warn('[chain-writes] account sequence mismatch, 重查序号重试一次');
438
- account = await queryAccount(c, addr);
439
- return await signAndBroadcastInner(c, messages, account, opts);
440
- }
441
- };
418
+ // 2026-10-18 fix: sendTxAs 也必须经过上链审计。
419
+ // 原实现直接调 signAndBroadcastInner,订单/支付/入驻等主要写路径全部绕过审计。
420
+ const run = () => chain_write_log_1.withChainWriteLog({
421
+ messages,
422
+ account: { address: addr },
423
+ run: async () => {
424
+ let account;
425
+ try {
426
+ account = await queryAccount(c, addr);
427
+ }
428
+ catch (e) {
429
+ // N15: 0 余额新商户 (auth 账户都不存在) + feegrant 代付 → 首笔 tx 以账户号 0/0 发起
430
+ const isNotFound = (e && e.code === 'account_not_found') || /account_not_found/.test(String(e && e.message));
431
+ if (!isNotFound || !c.feeGranter)
432
+ throw e;
433
+ console.log(`[chain-writes] 地址未上链 + feegrant 代付, 以 account 0/0 发起首笔 tx: ${addr}`);
434
+ account = { accountNumber: '0', sequence: '0' };
435
+ }
436
+ try {
437
+ return await signAndBroadcastInner(c, messages, account, opts);
438
+ }
439
+ catch (e) {
440
+ if (!(e instanceof SequenceMismatchError))
441
+ throw e;
442
+ console.warn('[chain-writes] account sequence mismatch, 重查序号重试一次');
443
+ account = await queryAccount(c, addr);
444
+ return await signAndBroadcastInner(c, messages, account, opts);
445
+ }
446
+ },
447
+ });
442
448
  const p = sendQueue.then(run, run);
443
449
  sendQueue = p.then(() => undefined, () => undefined);
444
450
  return p;
package/run-unified.js CHANGED
@@ -296,11 +296,6 @@ async function startUnifiedServer() {
296
296
  await indexer.start();
297
297
  // 2026-10-17 kimi: 初始化上链尝试审计(append-only,独立于幂等表 chain_write_log)。
298
298
  chain_write_log_1.initChainWriteLogger(sqliteIndexer.getRawDb());
299
- // 2026-10-17 kimi: 启动时 + 定时对账;链上读失败显式 degraded,不静默返回缓存。
300
- chain_reconcile_1.startChainReconcile({
301
- db: sqliteIndexer.getRawDb(),
302
- lcdUrl: process.env.CHAIN_LCD_URL || process.env.LCD_URL || 'http://localhost:1317',
303
- });
304
299
  // ── F-01 fix: ChainListener 接回 run-unified ──
305
300
  // SQLitePlugin.start() 本身不 wire ChainListener (只有 TrustCoinIndexer.start() wire).
306
301
  // 原来 run.ts 走 TrustCoinIndexer 所以 listener 自动起来, run-unified 直用
@@ -443,7 +438,7 @@ async function startUnifiedServer() {
443
438
  // (pwa-shop-demo 契约) 无账号无 api key。写路径仍受下方 sensitiveOpGuard 保护:
444
439
  // POST /api/products (卖家上架) 强制 key 不变; POST /api/orders 由 buyer-shop 按 body 形状
445
440
  // 接管买家单 (buyer_card+product_id), 其余形状落回 guard + chain-writes 老路径。
446
- app.use('/api', (0, api_key_auth_1.requireAPIKey)(skipLAN, ['/api/p2p/mcp', '/api/p2p/oauth', '/api/products', '/api/orders']));
441
+ app.use('/api', (0, api_key_auth_1.requireAPIKey)(skipLAN, ['/api/p2p/mcp', '/api/p2p/oauth', '/api/products', '/api/orders', '/api/qr']));
447
442
  // v0.17.1 安全修复 (H2): 以下三族路由此前挂载在 requireAPIKey 之前,
448
443
  // 任意外网 IP 都能无认证调用 —— 特别是 POST /api/api-keys/create
449
444
  // 允许任何人给自己签发任意权限的 key。现已挪到鉴权中间件之后。
@@ -483,6 +478,11 @@ async function startUnifiedServer() {
483
478
  if (sqliteDb) {
484
479
  (0, social_migrate_1.runSocialMigrations)(sqliteDb); // kimi 8/26: 私田 4 表 (幂等), 不修会 no such table (实测)
485
480
  (0, store_migrate_1.runStoreMigrations)(sqliteIndexer.getRawDb()); // kimi 9/6: storefront store_products 等表 (幂等), 不修 storefront 查询炸 no such table
481
+ // 2026-10-18 fix: 首次对账必须在 store_products 建表之后,避免新库 no such table。
482
+ chain_reconcile_1.startChainReconcile({
483
+ db: sqliteIndexer.getRawDb(),
484
+ lcdUrl: process.env.CHAIN_LCD_URL || process.env.LCD_URL || 'http://localhost:1317',
485
+ });
486
486
  app.use('/api/social', (0, social_router_1.createSocialFieldRouter)({ db: sqliteDb }));
487
487
  console.log(' ✅ SocialField router mounted at /api/social/{posts,shares,whitelist,feed}');
488
488
  }
@@ -54,7 +54,7 @@ CREATE TABLE store_products (
54
54
 
55
55
  const app = express();
56
56
  app.use(express.json());
57
- app.use("/api", requireAPIKey(false, ["/api/p2p/mcp", "/api/p2p/oauth", "/api/products", "/api/orders"]));
57
+ app.use("/api", requireAPIKey(false, ["/api/p2p/mcp", "/api/p2p/oauth", "/api/products", "/api/orders", "/api/qr"]));
58
58
  app.use("/api", buyerShop.createBuyerShopRouter({ db }));
59
59
  app.use("/api/orders", sensitiveOpGuard);
60
60
  app.use("/api/products", sensitiveOpGuard);
@@ -74,6 +74,7 @@ async function closeServer(ctx) {
74
74
 
75
75
  const cases = [
76
76
  { name: "GET /api/products 公开 200", method: "GET", path: "/api/products", status: 200 },
77
+ { name: "GET /api/qr 支付二维码不再 401", method: "GET", path: "/api/qr?text=weixin%3A%2F%2Fwxpay%2Fexample", status: 200 },
77
78
  { name: "GET /api/products?limit=1 query 不影响豁免", method: "GET", path: "/api/products?limit=1", status: 200 },
78
79
  { name: "GET /api/products/p1?expand=1 子路径与 query 公开", method: "GET", path: "/api/products/p1?expand=1", status: 200 },
79
80
  { name: "GET /api/status 仍 401", method: "GET", path: "/api/status", status: 401 },
@@ -89,7 +90,30 @@ const cases = [
89
90
  status: 401,
90
91
  },
91
92
  { name: "POST /api/products 豁免后仍受 sensitiveOpGuard 拦截", method: "POST", path: "/api/products", body: {}, status: 401 },
93
+ {
94
+ name: "POST /api/products 带隧道头也不能绕过写操作鉴权",
95
+ method: "POST",
96
+ path: "/api/products",
97
+ headers: { "X-TB-Tunnel": "1" },
98
+ body: {},
99
+ status: 401,
100
+ },
92
101
  { name: "POST /api/orders 非买家形状仍受 sensitiveOpGuard 拦截", method: "POST", path: "/api/orders", body: { woo_order_id: 1 }, status: 401 },
102
+ {
103
+ name: "POST /api/orders 非买家形状带隧道头也不能绕过写操作鉴权",
104
+ method: "POST",
105
+ path: "/api/orders",
106
+ headers: { "X-TB-Tunnel": "1" },
107
+ body: { woo_order_id: 1 },
108
+ status: 401,
109
+ },
110
+ {
111
+ name: "GET /api/products 带隧道头仍只读放行",
112
+ method: "GET",
113
+ path: "/api/products",
114
+ headers: { "X-TB-Tunnel": "1" },
115
+ status: 200,
116
+ },
93
117
  {
94
118
  name: "POST /api/orders 买家形状仍由公开路由处理",
95
119
  method: "POST",
@@ -110,10 +134,16 @@ for (const c of cases) {
110
134
  headers,
111
135
  body: c.body === undefined ? undefined : JSON.stringify(c.body),
112
136
  });
113
- const payload = await r.json();
114
- assert.strictEqual(r.status, c.status, JSON.stringify(payload));
115
- if (c.status === 401) assert.strictEqual(payload.error, "API Key Required");
116
- if (c.status === 400) assert.strictEqual(payload.error, "INVALID_PARAM");
137
+ if (c.path.startsWith("/api/qr")) {
138
+ const svg = await r.text();
139
+ assert.strictEqual(r.status, c.status, svg.slice(0, 200));
140
+ assert.ok(svg.includes("<svg"), "二维码应返回 SVG 而不是 401/JSON");
141
+ } else {
142
+ const payload = await r.json();
143
+ assert.strictEqual(r.status, c.status, JSON.stringify(payload));
144
+ if (c.status === 401) assert.strictEqual(payload.error, "API Key Required");
145
+ if (c.status === 400) assert.strictEqual(payload.error, "INVALID_PARAM");
146
+ }
117
147
  } finally {
118
148
  await closeServer(ctx);
119
149
  }
@@ -38,6 +38,7 @@ async function startLcd(handler) {
38
38
  const out = handler(req.url);
39
39
  if (out.error) { res.writeHead(500); return res.end(out.error); }
40
40
  res.writeHead(200, { "Content-Type": "application/json" });
41
+ if (Object.prototype.hasOwnProperty.call(out, "raw")) return res.end(JSON.stringify(out.raw));
41
42
  return res.end(JSON.stringify({ slots: { slots: out.slots || [] } }));
42
43
  }
43
44
  res.writeHead(404); res.end();
@@ -99,6 +100,28 @@ test("链上孤儿 slot → 只记录,不自动创建商品", async () => {
99
100
  } finally { srv.close(); db.close(); }
100
101
  });
101
102
 
103
+ test("本地已有质押快照时,不被链上调整值覆盖", async () => {
104
+ const db = makeDb();
105
+ db.prepare("INSERT INTO store_products (id, seller_address, title, chain_listed, chain_sku_id, chain_slot_index, chain_stake_per_unit) VALUES (?, ?, ?, ?, ?, ?, ?)")
106
+ .run("sku-1", SELLER, "sku-1", 1, "sku-1", 0, "2500000");
107
+ const { srv, url } = await startLcd(() => ({ slots: [{ ...activeSlot("sku-1"), stake_per_unit: "5000000" }] }));
108
+ try {
109
+ await runChainReconcile({ db, lcdUrl: url });
110
+ const p = db.prepare("SELECT chain_stake_per_unit FROM store_products WHERE id = 'sku-1'").get();
111
+ assert.strictEqual(p.chain_stake_per_unit, "2500000", "必须保留卖家原始质押快照");
112
+ } finally { srv.close(); db.close(); }
113
+ });
114
+
115
+ test("LCD 返回 200 但形状错误 → degraded,不当成零 slot 成功", async () => {
116
+ const db = makeDb(); insertProduct(db, "sku-1", 1);
117
+ const { srv, url } = await startLcd(() => ({ raw: {} }));
118
+ try {
119
+ const r = await runChainReconcile({ db, lcdUrl: url });
120
+ assert.strictEqual(r.status, "degraded");
121
+ assert.strictEqual(r.errors, 1);
122
+ } finally { srv.close(); db.close(); }
123
+ });
124
+
102
125
  test("已经一致 → 不重复 recover", async () => {
103
126
  const db = makeDb(); insertProduct(db, "sku-1", 1);
104
127
  const { srv, url } = await startLcd(() => ({ slots: [activeSlot("sku-1")] }));
@@ -7,7 +7,7 @@ const test = require("node:test");
7
7
  const assert = require("node:assert");
8
8
  const Database = require("better-sqlite3");
9
9
  const { initChainWriteLogger } = require("./core/chain-write-log");
10
- const { signAndBroadcast } = require("./routes/chain-writes");
10
+ const { signAndBroadcast, sendTxAs } = require("./routes/chain-writes");
11
11
 
12
12
  test("signAndBroadcast 失败时 chain_write_attempts 也有记录", async () => {
13
13
  const db = new Database(":memory:");
@@ -34,3 +34,29 @@ test("signAndBroadcast 失败时 chain_write_attempts 也有记录", async () =>
34
34
  assert.ok(row.error.length > 0, "必须保留失败原因");
35
35
  db.close();
36
36
  });
37
+
38
+ test("sendTxAs 失败时也落审计且 signer 为调用方地址", async () => {
39
+ const db = new Database(":memory:");
40
+ initChainWriteLogger(db);
41
+ const c = {
42
+ signMode: "js",
43
+ feeGranter: "",
44
+ rpcUrl: "http://127.0.0.1:1",
45
+ lcdUrl: "http://127.0.0.1:1",
46
+ home: "/tmp/definitely-not-a-chain-home",
47
+ keyName: "missing-key",
48
+ bin: "definitely-not-a-binary",
49
+ chainId: "test-chain",
50
+ };
51
+ const messages = [{ "@type": "/trustchain.order.v1.MsgCreateOrder", order_id: "order-1" }];
52
+
53
+ await assert.rejects(sendTxAs(c, messages, "tct1seller"), /.*/);
54
+
55
+ const row = db.prepare("SELECT * FROM chain_write_attempts").get();
56
+ assert.ok(row, "sendTxAs 失败也必须有审计记录");
57
+ assert.strictEqual(row.status, "failed");
58
+ assert.strictEqual(row.msg_types, "/trustchain.order.v1.MsgCreateOrder");
59
+ assert.strictEqual(row.signer, "tct1seller");
60
+ assert.ok(row.error.length > 0);
61
+ db.close();
62
+ });
@@ -100,28 +100,32 @@ test("① physical 商品: 缺 shipping_address → 400 SHIPPING_ADDRESS_REQUIRE
100
100
  } finally { srv.close(); }
101
101
  });
102
102
 
103
- test("② virtual 商品: 不要地址, 返回 delivery_credential (license 格式), 幂等重放凭据不变", async () => {
103
+ test("② virtual 商品: pending 不交付凭据, paid 后才返回且幂等不变", async () => {
104
104
  const { db, srv, base } = await startServer(seedProductsWithType);
105
105
  try {
106
106
  let r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
107
107
  const j = await r.json();
108
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), "凭据与订单绑定");
109
+ assert.strictEqual(j.status, "pending");
110
+ assert.strictEqual(j.delivery_credential, "", "待支付不得提前交付凭据");
111
111
  assert.strictEqual(j.shipping_address, "", "虚拟单地址为空");
112
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
- // 幂等重放 → 同一凭据 (不重新生成)
113
+ assert.match(row.delivery_credential, /^tb-license-tb-[0-9a-f]{16}-[0-9a-f]{4}$/, "license 已在库中生成");
114
+ assert.ok(row.delivery_credential.includes(j.id), "凭据与订单绑定");
115
+ // 幂等重放仍不得提前交付
115
116
  r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
116
117
  const j2 = await r.json();
117
118
  assert.strictEqual(j2.id, j.id);
118
- assert.strictEqual(j2.delivery_credential, j.delivery_credential);
119
- // 查单带凭据
119
+ assert.strictEqual(j2.delivery_credential, "", "幂等重放也不提前交付");
120
+ // 支付成功后才公开同一凭据
121
+ db.prepare("UPDATE agent_pay_sessions SET state = 'PAID' WHERE order_ref = ?").run(j.id);
120
122
  const o = await (await fetch(base + "/api/orders/" + j.id)).json();
121
- assert.strictEqual(o.delivery_credential, j.delivery_credential);
122
- // 两个虚拟单凭据不同
123
+ assert.strictEqual(o.status, "paid");
124
+ assert.strictEqual(o.delivery_credential, row.delivery_credential, "支付后返回同一凭据");
125
+ // 第二个虚拟单凭据不同
123
126
  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);
127
+ assert.notStrictEqual(j3.delivery_credential, row.delivery_credential);
128
+ assert.strictEqual(j3.delivery_credential, "", "新单待支付同样不交付");
125
129
  } finally { srv.close(); }
126
130
  });
127
131
 
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * test-sqlite-product-type-preserve.js — 链事件双写不得重置 product_type
4
+ * node --test test-sqlite-product-type-preserve.js
5
+ */
6
+ const test = require("node:test");
7
+ const assert = require("node:assert");
8
+ const Database = require("better-sqlite3");
9
+ const { SQLitePlugin } = require("./plugins/sqlite");
10
+
11
+ function makeDb() {
12
+ const db = new Database(":memory:");
13
+ db.exec(`CREATE TABLE products (
14
+ sku_id TEXT PRIMARY KEY, seller TEXT, title TEXT, description TEXT, content_hash TEXT,
15
+ price_cny REAL, price_tct TEXT, category_l1 TEXT, category_l2 TEXT, category_l3 TEXT,
16
+ latitude REAL, longitude REAL, city TEXT, stock INTEGER, stake_per_unit TEXT,
17
+ images TEXT, shipping_method TEXT, shipping_fee REAL, delivery_days INTEGER,
18
+ created_at INTEGER, updated_at INTEGER, status TEXT, brand_auth_type INTEGER, insurance_seller TEXT
19
+ )`);
20
+ db.exec(`CREATE TABLE products_fts (rowid INTEGER, title TEXT, description TEXT, category_l1 TEXT, category_l2 TEXT, category_l3 TEXT)`);
21
+ db.exec(`CREATE TABLE store_products (
22
+ id TEXT PRIMARY KEY, seller_address TEXT, title TEXT, description TEXT,
23
+ price_utct INTEGER, stock INTEGER, status TEXT, category TEXT, image_url TEXT,
24
+ tags TEXT, featured INTEGER, sales_count INTEGER, created_at INTEGER, updated_at INTEGER,
25
+ product_type TEXT NOT NULL DEFAULT 'physical'
26
+ )`);
27
+ return db;
28
+ }
29
+ function sampleProduct() {
30
+ return {
31
+ skuId: "sku-virtual", seller: "tct1seller", title: "虚拟商品", description: "",
32
+ contentHash: "hash", priceCny: 1, priceTct: "1",
33
+ category: { level1: "general", level2: "", level3: "" },
34
+ location: { latitude: 0, longitude: 0, city: "" },
35
+ stock: 1, stakePerUnit: "1000000", images: [],
36
+ shipping: { method: "none", fee: 0, deliveryDays: 0 },
37
+ createdAt: 1, updatedAt: 2, status: "active",
38
+ brand: { authType: 0 }, insurance: { insurer: "" },
39
+ };
40
+ }
41
+
42
+ test("已有 virtual 商品经过链事件双写后仍是 virtual", async () => {
43
+ const db = makeDb();
44
+ db.prepare("INSERT INTO store_products (id, seller_address, title, product_type) VALUES (?, ?, ?, ?)")
45
+ .run("sku-virtual", "tct1seller", "虚拟商品", "virtual");
46
+ const plugin = Object.create(SQLitePlugin.prototype);
47
+ plugin.db = db;
48
+ plugin.storeProductsReady = true;
49
+ await plugin.indexProduct(sampleProduct());
50
+ const row = db.prepare("SELECT product_type FROM store_products WHERE id = 'sku-virtual'").get();
51
+ assert.strictEqual(row.product_type, "virtual", "双写不得把 virtual 重置为 physical");
52
+ db.close();
53
+ });
54
+
55
+ test("新商品没有 product_type 时仍按默认 physical 插入", async () => {
56
+ const db = makeDb();
57
+ const plugin = Object.create(SQLitePlugin.prototype);
58
+ plugin.db = db;
59
+ plugin.storeProductsReady = true;
60
+ await plugin.indexProduct(sampleProduct());
61
+ const row = db.prepare("SELECT product_type FROM store_products WHERE id = 'sku-virtual'").get();
62
+ assert.strictEqual(row.product_type, "physical");
63
+ db.close();
64
+ });
package/web/dist/app.js CHANGED
@@ -97,7 +97,7 @@ function renderList() {
97
97
  <div class="product-grid">
98
98
  ${products.map(p => `
99
99
  <div class="product-card" onclick="location.hash='#product/${p.id}'">
100
- <img class="product-img" src="${p.image_url || '/placeholder.svg'}" alt="${esc(p.name)}" loading="lazy">
100
+ <img class="product-img" src="${safeImageUrl(p.image_url)}" alt="${esc(p.name)}" loading="lazy">
101
101
  <div class="product-info">
102
102
  <div class="product-name">${esc(p.name)}</div>
103
103
  <div class="product-price">¥${fmt(p.price)}</div>
@@ -126,7 +126,7 @@ function renderDetail(productId) {
126
126
  <div style="padding:12px 0">
127
127
  <button class="btn btn-secondary" style="width:auto;padding:8px 16px;font-size:14px" onclick="location.hash=''">← 返回</button>
128
128
  </div>
129
- <img class="detail-img" src="${p.image_url || '/placeholder.svg'}" alt="${esc(p.name)}">
129
+ <img class="detail-img" src="${safeImageUrl(p.image_url)}" alt="${esc(p.name)}">
130
130
  <div class="detail-name">${esc(p.name)}</div>
131
131
  <div class="detail-desc">${esc(p.description || '')}</div>
132
132
  <div class="detail-price">¥${fmt(p.price)}</div>
@@ -258,6 +258,12 @@ function renderOrder(orderId) {
258
258
  </div>
259
259
  </div>
260
260
  </div>
261
+ ${(order.status === 'paid' || order.status === 'completed') && order.delivery_credential ? `
262
+ <div class="product-card" style="cursor:default;margin-top:16px">
263
+ <div style="font-size:12px;color:var(--muted);margin-bottom:6px">交付凭据</div>
264
+ <code style="word-break:break-all;user-select:all">${esc(order.delivery_credential)}</code>
265
+ </div>
266
+ ` : ''}
261
267
  <button class="btn btn-secondary" style="margin-top:24px" onclick="location.hash=''">返回首页</button>
262
268
  `;
263
269
  })
@@ -377,6 +383,14 @@ function esc(s) {
377
383
  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
378
384
  }
379
385
 
386
+ // 2026-10-18 security: 图片 URL 只允许 https 或站内相对路径,且做属性转义。
387
+ // 防止卖家把 image_url 写成 x" onerror="..." 或 javascript: 形成存储型 XSS。
388
+ function safeImageUrl(url) {
389
+ const s = String(url || '').trim();
390
+ if (/^https:\/\//i.test(s) || /^\/(?!\/)/.test(s)) return esc(s);
391
+ return '/icon.svg';
392
+ }
393
+
380
394
  function fmt(n) {
381
395
  return Number(n || 0).toFixed(2);
382
396
  }
@@ -121,6 +121,6 @@
121
121
  <div class="offline-bar" id="offline-bar">⚠ 网络已断开,显示缓存内容</div>
122
122
  <div class="container" id="app"></div>
123
123
 
124
- <script type="module" src="/app.js?v=20261017a"></script>
124
+ <script src="/app.js?v=20261018a"></script>
125
125
  </body>
126
126
  </html>
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // TrustBase 示例店铺 Service Worker (pwa-kit 生成风格的手写版)
2
- const CACHE = 'shop-demo-v5'; // 2026-10-17 pi: A1 physical/virtual 收货地址 conditional (18578)
2
+ const CACHE = 'shop-demo-v6'; // 2026-10-18: 普通 script + 错误边界 + 图片 XSS 修复,强制刷新旧缓存
3
3
  const CORE = ['/', '/app.js', '/manifest.json', '/icon.svg'];
4
4
 
5
5
  self.addEventListener('install', (e) => {