trustbase-seller-backend 0.6.0 → 0.6.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.
@@ -165,7 +165,10 @@ 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
167
  if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
168
- return TUNNEL_BUYER_PATHS.some(p => req.path.startsWith(p));
168
+ // 注意: 本中间件挂在 '/api' 上, req.path 是挂载后路径 ('/products'), req.originalUrl 才带 '/api'
169
+ // → 两种前缀都要判, 否则放行条件恒为假 (2026-10-03 .211 实测 401 才发现)
170
+ const paths = [String(req.path || ''), String(req.originalUrl || '').split('?')[0]];
171
+ return TUNNEL_BUYER_PATHS.some(p => paths.some(x => x === p || x.startsWith(p + '/') || x.startsWith(p)));
169
172
  }
170
173
 
171
174
  function requireAPIKey(skipLAN = true, exemptPaths = []) { return (req, res, next) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS agent_pay_sessions (
44
44
  pay_token_hash TEXT NOT NULL,
45
45
  status_token_hash TEXT NOT NULL,
46
46
  state TEXT NOT NULL DEFAULT 'CREATED', -- CREATED/PAID/ON_CHAIN/SHIPPED/DELIVERED/CONFIRMED/CLOSED/EXPIRED/REFUNDED/DISPUTED
47
+ sub_mchid TEXT DEFAULT '', -- Lane C: 本单所属特约商户 (多商户; 查单/对账按它选 partner 端点)
47
48
  psp_code_url TEXT DEFAULT '',
48
49
  psp_txn_id TEXT DEFAULT '',
49
50
  shopxo_order_no TEXT DEFAULT '',
@@ -62,7 +63,13 @@ CREATE TABLE IF NOT EXISTS psp_txns (
62
63
  created_at INTEGER NOT NULL
63
64
  );
64
65
  CREATE INDEX IF NOT EXISTS idx_aps_state ON agent_pay_sessions(state);
65
- `);
66
+ `);
67
+ // Lane C (2026-10-03): 旧库补列 — CREATE TABLE IF NOT EXISTS 不会给已有表加列
68
+ try {
69
+ const _c = db.prepare("PRAGMA table_info(agent_pay_sessions)").all().map((x) => x.name);
70
+ if (!_c.includes("sub_mchid")) db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN sub_mchid TEXT DEFAULT ''");
71
+ } catch (_) {}
72
+
66
73
  // 2026-09-20 E4': 物流列 (老库兼容)
67
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 ''"]) {
68
75
  try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
@@ -138,13 +145,21 @@ async function wxApiCall(method, urlPath, bodyObj, override) {
138
145
  // opts.settleInfo (2026-10-06 U7): 可选, 如 { profit_sharing: true } — 微信规则: 订单下单时
139
146
  // 不带 settle_info.profit_sharing 的分账请求会被拒; 默认不带 (向后兼容, 直连模式不用传)
140
147
  async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opts) {
148
+ // Lane C (2026-10-03): sub_mchid 按本单商家传入 (多商户), 未传才回落全局 env (单商户旧部署)
149
+ const subMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
150
+ if (opts && opts.subMchidSource && opts.subMchidSource !== "registry") {
151
+ console.warn(
152
+ `[agent-pay] 本单 sub_mchid 来源=${opts.subMchidSource} (非登记表), 使用 ${subMchid || "直连模式"}` +
153
+ (subMchid ? " — 多商户场景请核对该商户是否已进件登记" : "")
154
+ );
155
+ }
141
156
  // SP 模式: 用服务商身份签名 + 请求体带 sp_mchid + sub_mchid
142
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
157
+ const isSp = !!(WX.spMchid && subMchid);
143
158
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
144
159
  const body = {
145
160
  // 直连模式: appid = 商户公众号; SP 模式: 微信不认 appid, 要 sp_appid(服务商) + sub_appid(特约商户)
146
161
  ...(isSp
147
- ? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: process.env.WXPAY_SUB_MCHID, sub_appid: WX.subAppid || WX.appid }
162
+ ? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: subMchid, sub_appid: WX.subAppid || WX.appid }
148
163
  : { appid: WX.appid, mchid: WX.mchid }),
149
164
  description: String(description).slice(0, 127),
150
165
  out_trade_no: outTradeNo, time_expire: new Date(expireSec * 1000).toISOString(),
@@ -161,11 +176,13 @@ async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opt
161
176
  return r.json.code_url;
162
177
  }
163
178
  // 查单: trade_state ∈ {SUCCESS, NOTPAY, CLOSED, ...} (SP 模式走 partner endpoint + 双 mchid 查询串)
164
- async function wxQueryOrder(outTradeNo) {
165
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
179
+ async function wxQueryOrder(outTradeNo, opts) {
180
+ // Lane C: 本单商户号优先, 回落全局 env (多商户)
181
+ const qSubMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
182
+ const isSp = !!(WX.spMchid && qSubMchid);
166
183
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
167
184
  const endpoint = isSp
168
- ? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${process.env.WXPAY_SUB_MCHID}`
185
+ ? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${qSubMchid}`
169
186
  : `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${WX.mchid}`;
170
187
  const r = await wxApiCall("GET", endpoint, null, override);
171
188
  if (r.status !== 200) return { state: "UNKNOWN", raw: r.json };
@@ -210,7 +227,8 @@ function loadPlatformCertsFromDisk() {
210
227
 
211
228
  /** 拉取微信平台证书 (GET /v3/certificates, 密文用调用方 APIv3 key 解密) → 内存+磁盘缓存 */
212
229
  async function refreshPlatformCerts() {
213
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
230
+ // Lane C: SP 身份只判 sp_mchid 是否配置 — 不能依赖全局 sub_mchid (多商户部署常不设默认值)
231
+ const isSp = !!WX.spMchid;
214
232
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
215
233
  const r = await wxApiCall("GET", "/v3/certificates", null, override);
216
234
  if (r.status !== 200 || !Array.isArray(r.json && r.json.data)) {
@@ -260,7 +278,8 @@ function wxVerifyNotifySignature(serial, timestamp, nonce, rawBody, signature) {
260
278
 
261
279
  /** 回调资源解密 (SP 模式优先 sp APIv3 key) */
262
280
  function wxDecryptNotifyResource(resource) {
263
- const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
281
+ // Lane C: 回调是发给服务商的, 必须用服务商 apiv3 key — 不能依赖全局 sub_mchid
282
+ const isSp = !!WX.spMchid;
264
283
  const apiv3 = isSp && WX.spApiv3Key ? WX.spApiv3Key : WX.apiv3Key;
265
284
  const plain = wxAesGcmDecrypt(apiv3, resource.nonce, resource.associated_data, resource.ciphertext);
266
285
  return JSON.parse(plain.toString("utf8"));
@@ -624,7 +643,7 @@ async function runReconcileOnce(db) {
624
643
  scanned = pend.length;
625
644
  for (const row of pend) {
626
645
  try {
627
- const q = await wxQueryOrder(row.order_ref);
646
+ const q = await wxQueryOrder(row.order_ref, { subMchid: row.sub_mchid });
628
647
  if (q.state === "SUCCESS") {
629
648
  settlePayment(row, q.txnId || ("wxq-" + row.order_ref), "wechat_native", q.amount || row.amount_total, JSON.stringify(q.raw).slice(0, 2000));
630
649
  settled++;
@@ -139,13 +139,14 @@ const RATE_CACHE_MS = 60 * 1000; // U7: 费率网关查询 60s 内存缓
139
139
  function makeCommissionRateResolver(db, fetcher) {
140
140
  const cache = new Map(); // sub_mchid → {bp, expiresAt}
141
141
  const fetchFn = fetcher || globalThis.fetch;
142
- return async function resolveRateBp() {
142
+ return async function resolveRateBp(subMchidArg) {
143
143
  // ① env 显式覆盖 (最高优先, 不调网关不读表)
144
144
  if (process.env.SP_COMMISSION_BP !== undefined && process.env.SP_COMMISSION_BP !== "") {
145
145
  const v = Number(process.env.SP_COMMISSION_BP);
146
146
  return Number.isInteger(v) && v >= 0 ? v : 0;
147
147
  }
148
- const subMchid = String(process.env.WXPAY_SUB_MCHID || "");
148
+ // Lane C (2026-10-03): 按传入的商户号解析; 未传才回落全局 env (多商户)
149
+ const subMchid = String(subMchidArg || process.env.WXPAY_SUB_MCHID || "");
149
150
  if (!subMchid) return 0; // 直连模式不标记
150
151
  const hit = cache.get(subMchid);
151
152
  if (hit && Date.now() < hit.expiresAt) return hit.bp;
@@ -176,12 +177,48 @@ function makeCommissionRateResolver(db, fetcher) {
176
177
  };
177
178
  }
178
179
 
180
+ /** Lane C (2026-10-03): 按「商品卖家地址」解析特约商户号 —— 多商户的核心
181
+ * 数据源: sp_sub_merchants.seller_address → sub_mchid (进件时写入, 网关与商家机各一份)
182
+ * 兜底: 全局 env WXPAY_SUB_MCHID (单商户旧部署) —— **必须显式告警**, 不静默错分
183
+ * 返回 { subMchid, source: registry | env-fallback | none } */
184
+ function makeSubMchidResolver(db) {
185
+ const cache = new Map(); // seller_address → {subMchid, source}
186
+ return function resolveSubMchid(sellerAddress) {
187
+ const env = String(process.env.WXPAY_SUB_MCHID || "");
188
+ const key = String(sellerAddress || "");
189
+ if (!key) return { subMchid: env, source: env ? "env-fallback-no-seller" : "none" };
190
+ const hit = cache.get(key);
191
+ if (hit) return hit;
192
+ let out = { subMchid: env, source: env ? "env-fallback" : "none" };
193
+ try {
194
+ if (hasTable(db, "sp_sub_merchants")) {
195
+ const row = db.prepare(
196
+ "SELECT sub_mchid FROM sp_sub_merchants WHERE seller_address = ? AND status = 'active'"
197
+ ).get(key);
198
+ if (row && row.sub_mchid) out = { subMchid: String(row.sub_mchid), source: "registry" };
199
+ }
200
+ } catch (_) { /* 表缺失/异常 → 走兜底 */ }
201
+ if (out.source !== "registry") {
202
+ console.warn(
203
+ `[buyer-shop] 卖家 ${key.slice(0, 14)}… 未在 sp_sub_merchants 登记 → ` +
204
+ (out.subMchid
205
+ ? `回退全局 WXPAY_SUB_MCHID=${out.subMchid}(多商户场景可能分错商户!)`
206
+ : "无 sub_mchid(按直连模式,本单不分账)")
207
+ );
208
+ }
209
+ cache.set(key, out);
210
+ return out;
211
+ };
212
+ }
213
+
179
214
  function createBuyerShopRouter(deps) {
180
215
  const router = express.Router();
181
216
  const { db } = deps;
182
217
  ensureBuyerTables(db);
183
218
  const rateLimit = makeRateLimiter();
184
219
  const resolveRateBp = makeCommissionRateResolver(db, deps.fetcher);
220
+ // Lane C: 按商品卖家解析 sub_mchid (多商户); 表缺失时回落 env 并告警
221
+ const resolveSubMchid = makeSubMchidResolver(db);
185
222
 
186
223
  const MODE = process.env.AGENT_PAY_MODE || "mock"; // mock | wechat (同 agent-pay 口径)
187
224
  // 测试可注入假 wxNativeOrder; 生产默认 agent-pay 真实现 (SP 模式其内部自动切换)
@@ -338,9 +375,13 @@ function createBuyerShopRouter(deps) {
338
375
  if (exist) return res.json(orderToJson(exist));
339
376
  }
340
377
 
378
+ // Lane C: 本单属于哪个特约商户 = 商品卖家的登记商户号 (不再是全局 env)
379
+ const sellerResolved = resolveSubMchid(product.seller_address);
380
+ const subMchid = sellerResolved.subMchid;
381
+
341
382
  // U7: rate>0 且 SP 模式 → 微信下单带 settle_info.profit_sharing (不带标记的订单分账被拒)
342
- const rateBp = await resolveRateBp();
343
- const psFlag = process.env.WXPAY_SUB_MCHID && rateBp > 0 ? 1 : 0;
383
+ const rateBp = await resolveRateBp(subMchid);
384
+ const psFlag = subMchid && rateBp > 0 ? 1 : 0;
344
385
 
345
386
  // 创建订单 (订单号 = order_ref, 对外 id)
346
387
  const orderRef = "tb-" + crypto.randomBytes(8).toString("hex");
@@ -353,8 +394,11 @@ function createBuyerShopRouter(deps) {
353
394
  codeUrl = `weixin://mock-pay/${orderRef}`;
354
395
  } else {
355
396
  try {
356
- codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp,
357
- psFlag ? { settleInfo: { profit_sharing: true } } : undefined);
397
+ codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp, {
398
+ subMchid,
399
+ subMchidSource: sellerResolved.source,
400
+ ...(psFlag ? { settleInfo: { profit_sharing: true } } : {}),
401
+ });
358
402
  } catch (e) {
359
403
  console.error("[buyer-shop] wx order fail:", e && e.message);
360
404
  return err("PAYMENT_UPSTREAM", 502, "支付通道暂时不可用,请稍后再试");
@@ -365,11 +409,11 @@ function createBuyerShopRouter(deps) {
365
409
  // 买家面无账号体系, 订单 id 即凭据 — 与 pwa-shop-demo 轮询模型一致)
366
410
  // U7: ps_flag 台账标注该单是否带分账标记 (运营台区分用)
367
411
  db.prepare(`INSERT INTO agent_pay_sessions
368
- (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)
369
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
370
414
  .run(clientSessionId, productId, qty, totalFen, "CNY", "buyer:" + buyerCard, orderRef,
371
415
  crypto.randomBytes(32).toString("hex"), crypto.randomBytes(32).toString("hex"),
372
- "CREATED", codeUrl, "", exp, now, now, psFlag);
416
+ "CREATED", codeUrl, "", exp, now, now, psFlag, subMchid);
373
417
  console.log(`[buyer-shop] 下单 ${orderRef} product=${productId} qty=${qty} total=${totalFen}分 ps_flag=${psFlag}`);
374
418
  const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
375
419
  res.json(orderToJson(row));
package/test-laneC.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Lane C 多商户路由 单测 (2026-10-03 kimi)
3
+ * 验证: ① 按商品卖家解析 sub_mchid ② 兜底回退 env 且有告警语义 ③ 费率按商户号解析
4
+ * 跑法: node test-laneC.js
5
+ */
6
+ const sqlite3 = require("better-sqlite3");
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = path.join(os.tmpdir(), "lanec-test-" + Date.now() + ".db");
13
+ const db = new sqlite3(tmp);
14
+
15
+ // 造最小 schema (与线上同结构)
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS sp_sub_merchants (sub_mchid TEXT, seller_address TEXT, shop_name TEXT, webhook_url TEXT, status TEXT, created_at INTEGER);
18
+ CREATE TABLE IF NOT EXISTS sp_commission_rates (sub_mchid TEXT, rate_bp INTEGER);
19
+ CREATE TABLE IF NOT EXISTS store_products (id TEXT, seller_address TEXT, title TEXT, price_utct INTEGER, stock INTEGER, status TEXT);
20
+ `);
21
+ db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1118152327", "tct1v75dSellerA", "港思恒", "", "active", 1);
22
+ db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1900000109", "tct1v75dSellerB", "第二商户", "", "active", 1);
23
+ db.prepare("INSERT INTO sp_commission_rates VALUES (?,?)").run("1900000109", 100); // 第二商户 1%
24
+
25
+ process.env.WXPAY_SUB_MCHID = "1118152327"; // 全局默认(单商户旧部署)
26
+ delete process.env.SP_COMMISSION_BP; // 走解析路径
27
+
28
+ // 载入被测函数 (从 buyer-shop.js 里抠出来, 避免 require 整个应用)
29
+ const src = fs.readFileSync(path.join(__dirname, "routes", "buyer-shop.js"), "utf8");
30
+ function extract(name) {
31
+ const i = src.indexOf("function " + name);
32
+ if (i < 0) throw new Error("找不到函数 " + name);
33
+ // 找到函数体结束
34
+ let depth = 0, started = false, j = i;
35
+ for (; j < src.length; j++) {
36
+ if (src[j] === "{") { depth++; started = true; }
37
+ else if (src[j] === "}") { depth--; if (started && depth === 0) { j++; break; } }
38
+ }
39
+ return src.slice(i, j);
40
+ }
41
+ global.hasTable = eval("(" + extract("hasTable") + ")");
42
+ const makeSubMchidResolver = eval("(" + extract("makeSubMchidResolver") + ")");
43
+ const RATE_CACHE_MS = 60 * 1000; // 与 buyer-shop.js 同值
44
+ const makeCommissionRateResolver = eval("(" + extract("makeCommissionRateResolver") + ")");
45
+
46
+ const resolveSubMchid = makeSubMchidResolver(db);
47
+ const resolveRateBp = makeCommissionRateResolver(db, null);
48
+
49
+ let pass = 0, fail = 0;
50
+ function check(name, got, want) {
51
+ const okv = JSON.stringify(got) === JSON.stringify(want);
52
+ console.log((okv ? " ✔ " : " ✘ ") + name + (okv ? "" : ` got=${JSON.stringify(got)} want=${JSON.stringify(want)}`));
53
+ okv ? pass++ : fail++;
54
+ }
55
+
56
+ console.log("Lane C 多商户路由单测:");
57
+
58
+ // ① 已登记商户 → 走登记表
59
+ check("港思恒(1118152327) 解析", resolveSubMchid("tct1v75dSellerA").subMchid, "1118152327");
60
+ check("第二商户(1900000109) 解析", resolveSubMchid("tct1v75dSellerB").subMchid, "1900000109");
61
+ check("第二商户 source=registry", resolveSubMchid("tct1v75dSellerB").source, "registry");
62
+
63
+ // ② 未登记卖家 → 回落 env + source 标记(不静默)
64
+ const r3 = resolveSubMchid("tct1unknown");
65
+ check("未登记卖家回落 env", r3.subMchid, "1118152327");
66
+ check("未登记卖家 source=env-fallback", r3.source, "env-fallback");
67
+
68
+ // ③ 无卖家地址 → 也不静默
69
+ check("无 seller 回落 env", resolveSubMchid("").source, "env-fallback-no-seller");
70
+
71
+ // ④ 费率按传入商户号解析 (第二商户 1% = 100bp; 全局默认未登记 → 0)
72
+ resolveRateBp.cache = null;
73
+ (async () => {
74
+ const bp2 = await resolveRateBp("1900000109");
75
+ check("费率按第二商户解析 (1%=100bp)", bp2, 100);
76
+ const bp1 = await resolveRateBp("1118152327");
77
+ check("费率按港思恒解析 (未登记→0)", bp1, 0);
78
+ // 不传参 → 回落 env 商户号
79
+ const bp0 = await resolveRateBp();
80
+ check("费率不传参回落 env", bp0, 0);
81
+
82
+ console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
83
+ try { fs.unlinkSync(tmp); } catch (_) {}
84
+ process.exit(fail ? 1 : 0);
85
+ })();