trustbase-seller-backend 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -158,14 +158,31 @@ function listAPIKeys() {
158
158
  /**
159
159
  * API Key 认证中间件(集成入侵检测)
160
160
  */
161
- function requireAPIKey(skipLAN = true, exemptPaths = []) {
162
- return (req, res, next) => {
161
+ /** 隧道买家面放行判定 (2026-10-03, T2/Lane B)
162
+ * 三条同时成立才放行: ① 来源回环 ② X-TB-Tunnel: 1 头 ③ 买家只读路径 */
163
+ const TUNNEL_BUYER_PATHS = ['/api/products', '/api/orders'];
164
+ function isTunnelBuyerRequest(req, clientIP) {
165
+ const fromLoopback = clientIP === '127.0.0.1' || clientIP === '::1' || /^127\./.test(clientIP);
166
+ if (!fromLoopback) return false;
167
+ if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
168
+ return TUNNEL_BUYER_PATHS.some(p => req.path.startsWith(p));
169
+ }
170
+
171
+ function requireAPIKey(skipLAN = true, exemptPaths = []) { return (req, res, next) => {
163
172
  // 获取客户端 IP
164
173
  const clientIP = (req.ip || req.connection.remoteAddress || '').replace('::ffff:', '');
165
174
  // 跳过豁免路径(如 P2P MCP 路由已有 X-Signature 认证)
166
175
  if (exemptPaths.some(p => req.path.startsWith(p))) {
167
176
  return next();
168
177
  }
178
+ // 隧道可信放行 (2026-10-03, T2/Lane B 真机发现):
179
+ // 商户机的店铺要经隧道(tunnel-client.js 本机 127.0.0.1 转发)对公网买家开放商品面,
180
+ // 而商户机默认 REQUIRE_API_KEY_FOR_LAN=1 → 回环也不放行 → 买家拿到 401, 公网店店等于不可用。
181
+ // 口径收窄到三者同时成立: ① 来源是回环(只有本机进程能发这个头) ② 带 X-TB-Tunnel 头(隧道客户端转发时加)
182
+ // ③ 命中买家面路径(商品/订单读)。管理面 API 一律仍要密钥。
183
+ if (isTunnelBuyerRequest(req, clientIP)) {
184
+ return next();
185
+ }
169
186
  // 1. 检查 IP 黑名单
170
187
  if ((0, intrusion_detection_1.isIPBlacklisted)(clientIP)) {
171
188
  return res.status(403).json({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -25,6 +25,10 @@ function wxCfg() {
25
25
  appid: process.env.WXPAY_APPID || "",
26
26
  mchid: process.env.WXPAY_MCHID || "",
27
27
  spMchid: process.env.WXPAY_SP_MCHID || "",
28
+ // SP 模式下单/分账必填: sp_appid (服务商公众号) + sub_mchid (特约商户)
29
+ // 2026-10-02 实测: 缺 sub_mchid → 微信 NO_AUTH "无分账权限"
30
+ spAppid: process.env.WXPAY_SP_APPID || "",
31
+ subMchid: process.env.WXPAY_SUB_MCHID || "",
28
32
  spSerial: process.env.WXPAY_SP_SERIAL || "",
29
33
  spKeyPath: process.env.WXPAY_SP_KEY_PATH || "",
30
34
  enabled: String(process.env.WXPAY_PROFITSHARING_ENABLED || "").toLowerCase() === "true",
@@ -90,17 +94,24 @@ async function addReceivers(receivers) {
90
94
  const results = [];
91
95
  for (const rcv of list) {
92
96
  if (!rcv || !rcv.account) throw new Error("receiver.account 必填");
93
- const body = {
94
- appid: c.appid,
95
- type: rcv.type || "MERCHANT_ID",
96
- account: String(rcv.account),
97
- relation_type: rcv.relation_type || "SERVICE_PROVIDER",
98
- };
97
+ // SP 模式: 微信该接口要 appid(服务商关联公众号) + sp_mchid + sub_mchid
98
+ // 2026-10-02 两次实测: 缺 sub_mchid → NO_AUTH; 用 sp_appid 替代 appid → PARAM_ERROR(/body/appid 必填)
99
+ const body = c.spMchid
100
+ ? {
101
+ appid: c.spAppid || c.appid,
102
+ sp_mchid: c.spMchid,
103
+ sub_mchid: String(rcv.sub_mchid || c.subMchid || ""),
104
+ type: rcv.type || "MERCHANT_ID",
105
+ account: String(rcv.account),
106
+ relation_type: rcv.relation_type || "SERVICE_PROVIDER",
107
+ }
108
+ : {
109
+ appid: c.appid,
110
+ type: rcv.type || "MERCHANT_ID",
111
+ account: String(rcv.account),
112
+ relation_type: rcv.relation_type || "SERVICE_PROVIDER",
113
+ };
99
114
  if (rcv.name) body.name = c.enabled ? encryptReceiverName(String(rcv.name)) : String(rcv.name); // MERCHANT_ID 真调时微信要求 name (加密)
100
- if (c.spMchid) {
101
- body.sp_mchid = c.spMchid;
102
- if (rcv.sub_mchid) body.sub_mchid = String(rcv.sub_mchid);
103
- }
104
115
  const r = await wxCall("POST", "/v3/profitsharing/receivers/add", body);
105
116
  results.push(r.mock ? { mock: true, account: body.account } : r);
106
117
  }
@@ -134,22 +145,35 @@ async function requestProfitsharing(params) {
134
145
  if (!rcv || !rcv.account) throw new Error("receiver.account 必填");
135
146
  assertShareAmount(p.tradeAmount, rcv.amount); // 护栏先于一切网络调用
136
147
  }
137
- const body = {
138
- appid: c.appid,
139
- transaction_id: String(p.transactionId),
140
- out_order_no: String(p.outTradeNo),
141
- receivers: p.receivers.map((r) => ({
142
- type: r.type || "MERCHANT_ID",
143
- account: String(r.account),
144
- amount: r.amount,
145
- description: String(r.description || "服务商分账").slice(0, 80),
146
- })),
147
- unfreeze_unsplit: p.unfreezeUnsplit !== false,
148
- };
149
- if (c.spMchid) {
150
- body.sp_mchid = c.spMchid;
151
- if (p.subMchid) body.sub_mchid = String(p.subMchid);
152
- }
148
+ // SP 模式: appid(服务商关联公众号) + sp_mchid + sub_mchid (与接收方注册同口径)
149
+ const body = c.spMchid
150
+ ? {
151
+ appid: c.spAppid || c.appid,
152
+ sp_mchid: c.spMchid,
153
+ sub_mchid: String(p.subMchid || c.subMchid || ""),
154
+ transaction_id: String(p.transactionId),
155
+ out_order_no: String(p.outTradeNo),
156
+ receivers: p.receivers.map((r) => ({
157
+ type: r.type || "MERCHANT_ID",
158
+ account: String(r.account),
159
+ amount: r.amount,
160
+ description: String(r.description || "服务商分账").slice(0, 80),
161
+ })),
162
+ unfreeze_unsplit: p.unfreezeUnsplit !== false,
163
+ }
164
+ : {
165
+ appid: c.appid,
166
+ transaction_id: String(p.transactionId),
167
+ out_order_no: String(p.outTradeNo),
168
+ receivers: p.receivers.map((r) => ({
169
+ type: r.type || "MERCHANT_ID",
170
+ account: String(r.account),
171
+ amount: r.amount,
172
+ description: String(r.description || "服务商分账").slice(0, 80),
173
+ })),
174
+ unfreeze_unsplit: p.unfreezeUnsplit !== false,
175
+ };
176
+ if (c.spMchid && p.subMchid) body.sub_mchid = String(p.subMchid);
153
177
  const r = await wxCall("POST", "/v3/profitsharing/orders", body);
154
178
  return r.mock ? { mock: true, out_order_no: body.out_order_no, receivers: body.receivers } : r;
155
179
  }
@@ -94,6 +94,10 @@ const WX = {
94
94
  notifyUrl: process.env.WXPAY_NOTIFY_URL || "https://placeholder.invalid/tb-notify",
95
95
  // 服务商模式 (A 方案): SP 统一下单带 sub_mchid 参数
96
96
  spMchid: process.env.WXPAY_SP_MCHID || "",
97
+ // 服务商公众号ID: 微信 SP 模式下单必填 (2026-10-02 实测 PARAM_ERROR: /body/sp_appid 必填性校验失败)
98
+ spAppid: process.env.WXPAY_SP_APPID || "",
99
+ // 特约商户公众号ID: SP 模式下单必填 (与 appid 不同, 是商户进件时绑的那个)
100
+ subAppid: process.env.WXPAY_SUB_APPID || "",
97
101
  spSerial: process.env.WXPAY_SP_SERIAL || "",
98
102
  spKeyPath: process.env.WXPAY_SP_KEY_PATH || "",
99
103
  spApiv3Key: process.env.WXPAY_SP_APIV3_KEY || "", // SP 模式回调/平台证书解密用服务商 APIv3 key (缺省回落 apiv3Key)
@@ -138,8 +142,11 @@ async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opt
138
142
  const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
139
143
  const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
140
144
  const body = {
141
- appid: WX.appid,
142
- ...(isSp ? { sp_mchid: WX.spMchid, sub_mchid: process.env.WXPAY_SUB_MCHID } : { mchid: WX.mchid }), description: String(description).slice(0, 127),
145
+ // 直连模式: appid = 商户公众号; SP 模式: 微信不认 appid, 要 sp_appid(服务商) + sub_appid(特约商户)
146
+ ...(isSp
147
+ ? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: process.env.WXPAY_SUB_MCHID, sub_appid: WX.subAppid || WX.appid }
148
+ : { appid: WX.appid, mchid: WX.mchid }),
149
+ description: String(description).slice(0, 127),
143
150
  out_trade_no: outTradeNo, time_expire: new Date(expireSec * 1000).toISOString(),
144
151
  // notify_url: 可选回调 (未配置=查单模式, 占位符即可下单成功)
145
152
  notify_url: WX.notifyUrl,
@@ -244,6 +244,25 @@ function createBuyerShopRouter(deps) {
244
244
  };
245
245
  }
246
246
 
247
+ /** GET /qr?text=weixin://wxpay/... — 服务端渲染支付二维码 (SVG)
248
+ * 2026-10-03: PWA 自带的 qrcode.min.js 在浏览器里没导出公共 API → 支付页二维码空白;
249
+ * 改由服务端渲染。只认 weixin://wxpay/ 前缀, 避免变成任意文本转二维码的公共服务。 */
250
+ router.get("/qr", async (req, res) => {
251
+ try {
252
+ const text = String(req.query.text || "").trim();
253
+ if (!/^weixin:\/\/wxpay\//.test(text)) {
254
+ return res.status(400).json({ error: "BAD_QR_TEXT", message: "仅支持 weixin://wxpay/ 链接" });
255
+ }
256
+ const QRCode = require("qrcode");
257
+ const svg = await QRCode.toString(text, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
258
+ res.set("Content-Type", "image/svg+xml; charset=utf-8");
259
+ res.set("Cache-Control", "no-store");
260
+ res.send(svg);
261
+ } catch (e) {
262
+ res.status(500).json({ error: "QR_RENDER_FAILED", message: String((e && e.message) || e).slice(0, 120) });
263
+ }
264
+ });
265
+
247
266
  /** GET /products — 商品列表 (公开只读, 只在架) */
248
267
  router.get("/products", (req, res) => {
249
268
  try {
@@ -100,7 +100,9 @@ async function submitPaymentFactToChain(fact) {
100
100
  /** 补链清扫: 扫描 chain_txhash 为空的台账行重试上链 (定时器/手动调用)
101
101
  * opts.sendTx 可注入 (测试); 返回 {scanned, retried, failed} */
102
102
  async function retryPending(db, opts) {
103
- const rows = db.prepare("SELECT * FROM sp_payment_facts WHERE chain_txhash = '' LIMIT 20").all();
103
+ // 2026-10-03 修复: 链开关关闭期间写入的是 chain_txhash='disabled' (非空), 原条件 chain_txhash=''
104
+ // 永远扫不到 → 9/25-9/29 那批事实被永久漏在链外。disabled 与空串同义(都要补链), 一并扫。
105
+ const rows = db.prepare("SELECT * FROM sp_payment_facts WHERE chain_txhash IN ('','disabled') LIMIT 20").all();
104
106
  const injected = opts && opts.sendTx;
105
107
  let retried = 0, failed = 0;
106
108
  for (const row of rows) {
@@ -165,8 +167,22 @@ async function retryProfitsharingSweep(db, opts) {
165
167
  }
166
168
  // pending 且微信侧还在处理中: 保持 pending 下轮再扫
167
169
  } else {
168
- failed++;
169
- console.warn(`[sp-notify] 分账补扫查询未成功 out_order_no=${row.ps_out_order_no} status=${q && q.status}`);
170
+ // 查询失败/查不到 (微信 400 = 这张分账单根本不存在, 说明首次分账就没发出去)
171
+ // → 必须走重发路径, 否则这一笔永远补不回来。
172
+ // 2026-10-03 修复: 原实现只 failed++ 记日志, 从不重发 (10/3 首笔 ¥10 分账即踩中, 手工补发)
173
+ if (row.ps_status === "failed" || row.ps_status === "pending") {
174
+ const r2 = await profitsharing.requestProfitsharing({
175
+ outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
176
+ subMchid: row.sub_mchid,
177
+ receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
178
+ });
179
+ const ok2 = r2 && r2.status === 200;
180
+ db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(ok2 ? "pending" : "failed", nowSec(), row.txn_id);
181
+ if (ok2) retried++; else failed++;
182
+ } else {
183
+ failed++;
184
+ }
185
+ console.warn(`[sp-notify] 分账补扫查询未成功 out_order_no=${row.ps_out_order_no} status=${q && q.status}${row.ps_status === "failed" ? " (已尝试重发)" : ""}`);
170
186
  }
171
187
  } catch (e) {
172
188
  failed++;