trustbase-seller-backend 0.5.7 → 0.5.9
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/package.json +1 -1
- package/payments/wechat/profitsharing.js +50 -26
- package/routes/agent-pay.js +9 -2
- package/routes/buyer-shop.js +19 -0
- package/routes/sp-notify.js +44 -17
- package/run-unified.js +11 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
}
|
package/routes/agent-pay.js
CHANGED
|
@@ -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
|
|
142
|
-
...(isSp
|
|
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,
|
package/routes/buyer-shop.js
CHANGED
|
@@ -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 {
|
package/routes/sp-notify.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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++;
|
|
@@ -362,25 +378,36 @@ async function verifyChainSigning(deps) {
|
|
|
362
378
|
const addr = await chainWrites.keyAddress(c);
|
|
363
379
|
console.log("[sp-notify][STARTUP-CHECK] ✓ 签名 key 可用, provider 地址:", addr);
|
|
364
380
|
|
|
365
|
-
// 检查该地址是否是链上 ACTIVE provider
|
|
381
|
+
// 检查该地址是否是链上 ACTIVE provider。
|
|
382
|
+
// 用 trustchaind CLI 而不是 abci_query: 模块的 gRPC Query 路径不确定,
|
|
383
|
+
// 而 CLI 的 'q serviceprovider show' 是实机验证过的口径 (kimi 18462 复核修复:
|
|
384
|
+
// 原 abci_query path 未加引号 → 必 32602, 改对后仍返回空; 且非 200 时无任何输出)。
|
|
366
385
|
try {
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
386
|
+
const bin = c.bin || process.env.TRUSTCHAIND_BIN || "trustchaind";
|
|
387
|
+
const out = await new Promise((resolve) => {
|
|
388
|
+
const cp = require("child_process");
|
|
389
|
+
const child = cp.execFile(bin,
|
|
390
|
+
["q", "serviceprovider", "show", addr, "--node", c.rpcUrl, "--output", "json"],
|
|
391
|
+
{ timeout: 8000, maxBuffer: 1 << 20 },
|
|
392
|
+
(err, stdout) => resolve({ err, stdout }));
|
|
393
|
+
});
|
|
394
|
+
if (out.err || !out.stdout) {
|
|
395
|
+
console.error("[sp-notify][STARTUP-CHECK] ❌ provider 状态查询失败:",
|
|
396
|
+
String((out.err && out.err.message) || "无输出").slice(0, 160));
|
|
397
|
+
} else {
|
|
398
|
+
const parsed = JSON.parse(out.stdout);
|
|
399
|
+
const sp = parsed && (parsed.provider || parsed);
|
|
400
|
+
const status = sp && sp.status;
|
|
401
|
+
if (status === "ACTIVE") {
|
|
402
|
+
console.log("[sp-notify][STARTUP-CHECK] ✓ provider 状态 ACTIVE (质押 " + (sp.stake || "?") + " utct)");
|
|
403
|
+
} else if (status) {
|
|
404
|
+
console.error("[sp-notify][STARTUP-CHECK] ❌ provider 状态 " + status + " (需 ACTIVE) — 支付事实将被链上拒绝");
|
|
378
405
|
} else {
|
|
379
|
-
console.
|
|
406
|
+
console.error("[sp-notify][STARTUP-CHECK] ❌ provider 查询结果里没有 status 字段, 请人工核: trustchaind q serviceprovider show " + addr);
|
|
380
407
|
}
|
|
381
408
|
}
|
|
382
409
|
} catch (queryErr) {
|
|
383
|
-
console.warn("[sp-notify][STARTUP-CHECK] ⚠️ provider
|
|
410
|
+
console.warn("[sp-notify][STARTUP-CHECK] ⚠️ provider 状态查询异常 (链节点可能未启动):", queryErr.message);
|
|
384
411
|
}
|
|
385
412
|
return true;
|
|
386
413
|
} catch (keyErr) {
|
package/run-unified.js
CHANGED
|
@@ -633,6 +633,17 @@ async function startUnifiedServer() {
|
|
|
633
633
|
// 2026-09-26: 微信支付服务商 (SP) 模式三件套 — 特约商户回调 (/api/sp/notify/wechat) + 抽佣登记 (/api/sp/commission-rate*)
|
|
634
634
|
// 分账骨架在 payments/wechat/profitsharing.js (纯函数, 由 sp-notify 按抽佣比例触发, 默认 mock)
|
|
635
635
|
app.use('/api/sp', (0, sp_notify_1.createSpNotifyRouter)({ db: sqliteIndexer.getRawDb() }));
|
|
636
|
+
// 上链签名自检 (kimi 18462 接线): 签名 key 缺失 / 不是 ACTIVE provider 时启动即报,
|
|
637
|
+
// 不用等第一笔真实支付才发现写不上去。异步跑, 不阻塞启动, 失败只告警不退出。
|
|
638
|
+
try {
|
|
639
|
+
Promise.resolve(sp_notify_1.verifyChainSigning({ db: sqliteIndexer.getRawDb() }))
|
|
640
|
+
.then((ok) => {
|
|
641
|
+
if (!ok) console.warn(" ⚠️ [sp] 上链签名自检未通过, 支付事实将无法上链 (见上方 STARTUP-CHECK)");
|
|
642
|
+
})
|
|
643
|
+
.catch((e) => console.warn(" ⚠️ [sp] 上链签名自检异常 (不阻塞):", e && e.message));
|
|
644
|
+
} catch (e) {
|
|
645
|
+
console.warn(" ⚠️ [sp] 上链签名自检无法执行:", e && e.message);
|
|
646
|
+
}
|
|
636
647
|
// P1 fix: retryPending 此前只导出无调度方 — 支付事实上链失败的补链清扫, 每 5min 一轮
|
|
637
648
|
// (unref 不拖住进程退出; 连续 3 轮有失败额外告警, 单轮失败明细由 retryPending 内部 warn)
|
|
638
649
|
let spRetryFailStreak = 0;
|