trustbase-seller-backend 0.2.2 → 0.2.4
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 +38 -1
- package/routes/onboard.html +23 -0
- package/routes/onboard.js +3 -0
- package/routes/sp-applyment.js +105 -8
- package/routes/sp-commission.js +16 -1
- package/routes/sp-notify.js +86 -4
- package/test-onboard-applyment.js +4 -0
- package/test-sp-applyment.js +110 -5
- package/test-sp-gateway.js +74 -0
package/package.json
CHANGED
|
@@ -147,4 +147,41 @@ async function queryProfitsharing(outOrderNo, opts) {
|
|
|
147
147
|
return r.mock ? { mock: true, out_order_no: String(outOrderNo) } : r;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
/** M3-1: 分账接收方注册台账 (幂等标记; 已注册不重复调微信 — 重复调会报 "接收方已存在" 类错误) */
|
|
151
|
+
function ensurePsReceiverTable(db) {
|
|
152
|
+
db.exec(`
|
|
153
|
+
CREATE TABLE IF NOT EXISTS sp_ps_receivers (
|
|
154
|
+
account TEXT PRIMARY KEY, -- 接收方商户号 (SP 自身 mchid)
|
|
155
|
+
registered_at INTEGER NOT NULL,
|
|
156
|
+
mock INTEGER NOT NULL DEFAULT 0
|
|
157
|
+
);
|
|
158
|
+
`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** M3-1: 注册默认分账接收方 (SP 自身 mchid, type MERCHANT_ID)。
|
|
162
|
+
* 调用时机: SP 服务启动 (createSpNotifyRouter 挂载) + 进件 FINISHED 钩子 (sp-applyment)。
|
|
163
|
+
* 幂等: 台账有记录 → {registered:false, reason:'already'} 不调微信; mock 模式同样落台账。 */
|
|
164
|
+
async function registerDefaultReceivers(db) {
|
|
165
|
+
ensurePsReceiverTable(db);
|
|
166
|
+
const c = wxCfg();
|
|
167
|
+
const account = c.spMchid || c.mchid;
|
|
168
|
+
if (!account) {
|
|
169
|
+
console.warn("[profitsharing] 未配 WXPAY_SP_MCHID/WXPAY_MCHID, 跳过接收方注册");
|
|
170
|
+
return { registered: false, reason: "no_mchid" };
|
|
171
|
+
}
|
|
172
|
+
const row = db.prepare("SELECT account FROM sp_ps_receivers WHERE account = ?").get(account);
|
|
173
|
+
if (row) return { registered: false, reason: "already" };
|
|
174
|
+
const r = await addReceivers([{ account, type: "MERCHANT_ID", name: process.env.SP_NAME || "服务商" }]);
|
|
175
|
+
const one = Array.isArray(r) ? r[0] : r;
|
|
176
|
+
const ok = !!(one && (one.mock || one.status === 200));
|
|
177
|
+
if (ok) {
|
|
178
|
+
db.prepare("INSERT OR IGNORE INTO sp_ps_receivers (account, registered_at, mock) VALUES (?,?,?)")
|
|
179
|
+
.run(account, Math.floor(Date.now() / 1000), one.mock ? 1 : 0);
|
|
180
|
+
console.log(`[profitsharing] 分账接收方已注册 account=${account} mock=${!!one.mock}`);
|
|
181
|
+
return { registered: true, mock: !!one.mock };
|
|
182
|
+
}
|
|
183
|
+
console.warn(`[profitsharing] 接收方注册未成功 account=${account}: ${JSON.stringify(one && one.json || one).slice(0, 200)}`);
|
|
184
|
+
return { registered: false, reason: "wx_error", detail: one && one.json };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { addReceivers, requestProfitsharing, queryProfitsharing, assertShareAmount, MAX_SHARE_BP, registerDefaultReceivers, ensurePsReceiverTable };
|
package/routes/onboard.html
CHANGED
|
@@ -566,6 +566,29 @@ function renderApplyment(j){
|
|
|
566
566
|
}
|
|
567
567
|
box.innerHTML=html;
|
|
568
568
|
if(j.status==='APPLYMENT_STATE_FINISHED') markStepDone('st-verification');
|
|
569
|
+
maybeAutoKyc(j); // M2 断点 a: 进件受理后自动提交身份认证 (feegrant 已备, 0 余额可签)
|
|
570
|
+
}
|
|
571
|
+
// M2 自动 KYC: 进件已被微信/SP 受理 (SUBMITTED/AUDITING 等) 且本地未标已提交 → 自动调
|
|
572
|
+
// POST /onboard/submit-verification (此时 SP submit 成功已触发 oracle 入队 → feegrant 先行,
|
|
573
|
+
// 商户 0 余额可签)。失败只 warn + 5min 退避重试, 不阻塞页面; 成功打 localStorage 标记防重复。
|
|
574
|
+
async function maybeAutoKyc(j){
|
|
575
|
+
try{
|
|
576
|
+
if(!j||!j.status) return;
|
|
577
|
+
if(['NOT_SUBMITTED','PENDING_PUSH','APPLYMENT_STATE_REJECTED','APPLYMENT_STATE_CANCELED'].indexOf(j.status)>=0) return;
|
|
578
|
+
if(localStorage.getItem('tbKycSubmitted')==='1') return;
|
|
579
|
+
var last=Number(localStorage.getItem('tbKycLastTry')||0);
|
|
580
|
+
if(Date.now()-last < 5*60*1000) return; // 失败退避 (feegrant 可能还在 oracle tick 路上)
|
|
581
|
+
localStorage.setItem('tbKycLastTry', String(Date.now()));
|
|
582
|
+
var rj=await readJson(await fetch('/onboard/submit-verification',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}));
|
|
583
|
+
if(rj && rj.ok){
|
|
584
|
+
localStorage.setItem('tbKycSubmitted','1');
|
|
585
|
+
var msg=document.getElementById('verify-msg');
|
|
586
|
+
if(msg){msg.style.display='block';msg.textContent='✅ 身份认证已自动提交 (手续费由服务商代付), 等待审核'+(rj.duplicate?' — 此前已提交过':'');}
|
|
587
|
+
loadStatus();
|
|
588
|
+
}else{
|
|
589
|
+
console.warn('[onboard] KYC 自动提交未成功 (5min 后重试):', (rj&&(rj.message||rj.error))||'');
|
|
590
|
+
}
|
|
591
|
+
}catch(e){ console.warn('[onboard] KYC 自动提交异常 (5min 后重试):', e && e.message); }
|
|
569
592
|
}
|
|
570
593
|
async function submitApplyment(){
|
|
571
594
|
var btn=document.getElementById('btn-apply-submit');
|
package/routes/onboard.js
CHANGED
|
@@ -633,6 +633,9 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
|
|
|
633
633
|
payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
|
|
634
634
|
// 通用类目资质留空 → 默认 "无" (微信通用场景接受, 避免留空被 SP 拒)
|
|
635
635
|
if (!payload.qualification_type) payload.qualification_type = "无";
|
|
636
|
+
// M2 断点 c: tb_address 贯通 — SP 台账落列, FINISHED 后 oracle 引导金才知道发给谁
|
|
637
|
+
const identity = await ensureSellerIdentity(basePaths.baseDir());
|
|
638
|
+
payload.tb_address = identity.address;
|
|
636
639
|
// F3: business_code 卖家侧首次提交时本地生成 (TB{毫秒}{rand6}), 台账暂存/转发/重试都带同号;
|
|
637
640
|
// SP 侧收到带号的提交不再新生成 (驳回重提也复用原号 — 微信同号覆盖)
|
|
638
641
|
const businessCode = (latest && latest.business_code) || spApplyment.genBusinessCode();
|
package/routes/sp-applyment.js
CHANGED
|
@@ -46,6 +46,8 @@ const crypto = require("crypto");
|
|
|
46
46
|
const fs = require("fs");
|
|
47
47
|
const agentPay = require("./agent-pay");
|
|
48
48
|
const chainWrites = require("./chain-writes");
|
|
49
|
+
const spCommission = require("./sp-commission");
|
|
50
|
+
const profitsharing = require("../payments/wechat/profitsharing");
|
|
49
51
|
|
|
50
52
|
const HttpError = chainWrites.HttpError;
|
|
51
53
|
|
|
@@ -75,10 +77,25 @@ CREATE TABLE IF NOT EXISTS sp_applyments (
|
|
|
75
77
|
state_msg TEXT NOT NULL DEFAULT '', -- 微信状态描述
|
|
76
78
|
audit_detail TEXT NOT NULL DEFAULT '[]', -- 驳回明细 JSON (field/field_name/reject_reason)
|
|
77
79
|
subject_json TEXT NOT NULL DEFAULT '{}', -- 表单快照 (敏感字段已脱敏)
|
|
80
|
+
tb_address TEXT NOT NULL DEFAULT '', -- 商户链上地址 (M2: 引导金 recipient 贯通)
|
|
81
|
+
hook_fired_at INTEGER, -- FINISHED 钩子已触发时间 (NULL=未触发, 幂等)
|
|
78
82
|
created_at INTEGER NOT NULL,
|
|
79
83
|
updated_at INTEGER NOT NULL
|
|
80
84
|
);
|
|
85
|
+
-- 回调路由表 (与 sp-notify 同构; 这里兜底建, FINISHED 钩子 upsert 写入)
|
|
86
|
+
CREATE TABLE IF NOT EXISTS sp_sub_merchants (
|
|
87
|
+
sub_mchid TEXT PRIMARY KEY,
|
|
88
|
+
seller_address TEXT DEFAULT '',
|
|
89
|
+
shop_name TEXT DEFAULT '',
|
|
90
|
+
webhook_url TEXT DEFAULT '',
|
|
91
|
+
status TEXT DEFAULT 'active',
|
|
92
|
+
created_at INTEGER NOT NULL
|
|
93
|
+
);
|
|
81
94
|
`);
|
|
95
|
+
// 老库兼容: 缺列补列 (PRAGMA 检查后再 ALTER)
|
|
96
|
+
const cols = db.prepare("PRAGMA table_info(sp_applyments)").all().map((c) => c.name);
|
|
97
|
+
if (!cols.includes("tb_address")) db.exec("ALTER TABLE sp_applyments ADD COLUMN tb_address TEXT NOT NULL DEFAULT ''");
|
|
98
|
+
if (!cols.includes("hook_fired_at")) db.exec("ALTER TABLE sp_applyments ADD COLUMN hook_fired_at INTEGER");
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
/** 微信支付公钥 pem (mock 无配置时临时生成内存对, 仅测试用) */
|
|
@@ -305,6 +322,39 @@ function maskTail(s) {
|
|
|
305
322
|
return s.length <= 4 ? "***" : "***" + s.slice(-4);
|
|
306
323
|
}
|
|
307
324
|
|
|
325
|
+
// ============================== M2: oracle 引导金入队通知 (进件→feegrant→KYC→审核→发放 自动链) ==============================
|
|
326
|
+
/** oracle 配置 (懒读 env): ORACLE_URL 默认 .123; 鉴权头对齐 oracle-sync c5AdminAuth (X-API-Key/X-API-Password) */
|
|
327
|
+
function oracleConf() {
|
|
328
|
+
return {
|
|
329
|
+
url: (process.env.ORACLE_URL || "http://192.168.1.123:3004").trim().replace(/\/+$/, ""),
|
|
330
|
+
key: (process.env.ORACLE_ADMIN_API_KEY || "").trim(),
|
|
331
|
+
pass: (process.env.ORACLE_ADMIN_API_PASSWORD || "").trim(),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** 入队引导金: POST {ORACLE_URL}/api/oracle/bootstrap-grant {recipient, credential_hash}
|
|
335
|
+
* credential_hash 锚点对齐 c5-glue enqueueBootstrapGrant: sha256("license:"+统一社会信用代码)
|
|
336
|
+
* (ops-console 材料入库的 recipient 哈希锚点不同, 但 enqueue 有 recipient 双去重 → 同商户只留一行)
|
|
337
|
+
* 幂等由 oracle 侧担保; 失败上抛由调用方降级 warn (不阻塞进件)。 */
|
|
338
|
+
async function notifyOracleGrant(deps, info) {
|
|
339
|
+
if (!info.tb_address) return { ok: false, skipped: true, reason: "no_tb_address" };
|
|
340
|
+
const cfg = oracleConf();
|
|
341
|
+
// mock 模式默认假成功 (测试不打真 oracle); 注入 deps.oracleFetch 可捕获断言
|
|
342
|
+
const fetcher = deps.oracleFetch || (isMock()
|
|
343
|
+
? async () => ({ ok: true, status: 200, json: async () => ({ ok: true, enqueued: true, mock: true }) })
|
|
344
|
+
: globalThis.fetch);
|
|
345
|
+
const headers = { "Content-Type": "application/json" };
|
|
346
|
+
if (cfg.key) headers["X-API-Key"] = cfg.key;
|
|
347
|
+
if (cfg.pass) headers["X-API-Password"] = cfg.pass;
|
|
348
|
+
const credentialHash = crypto.createHash("sha256").update("license:" + String(info.license_number || "")).digest("hex");
|
|
349
|
+
const r = await fetcher(cfg.url + "/api/oracle/bootstrap-grant", {
|
|
350
|
+
method: "POST", headers,
|
|
351
|
+
body: JSON.stringify({ recipient: info.tb_address, credential_hash: credentialHash }),
|
|
352
|
+
signal: AbortSignal.timeout(8000),
|
|
353
|
+
});
|
|
354
|
+
const j = await r.json().catch(() => ({}));
|
|
355
|
+
return { ok: !!r.ok, status: r.status, json: j, credential_hash: credentialHash };
|
|
356
|
+
}
|
|
357
|
+
|
|
308
358
|
// ============================== 手机单页表单 (内联 HTML, 无构建) ==============================
|
|
309
359
|
// 注意: 内嵌脚本只用单/双引号字符串, 不用反引号模板串 (本页本身在 JS 模板串内)。
|
|
310
360
|
const PAGE_HTML = `<!DOCTYPE html>
|
|
@@ -582,10 +632,10 @@ function createSpApplymentRouter(deps) {
|
|
|
582
632
|
}
|
|
583
633
|
|
|
584
634
|
/** GET /info — 服务商公开名片 (2026-10-03 xiaomu: 卖家入驻页要显示服务商是谁)
|
|
585
|
-
* 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息
|
|
635
|
+
* 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息
|
|
636
|
+
* M3 🟡-1: 公示比例读 sp_commission_rates 默认行 (__default__) 优先, env 只作首次种子 */
|
|
586
637
|
router.get("/info", (req, res) => {
|
|
587
|
-
const
|
|
588
|
-
const bp = Number.isInteger(rateBp) && rateBp >= 0 ? rateBp : 100;
|
|
638
|
+
const bp = spCommission.getDefaultRateBp(db);
|
|
589
639
|
res.json({
|
|
590
640
|
name: process.env.SP_NAME || "TrustBase 官方服务商",
|
|
591
641
|
desc: process.env.SP_DESC || "",
|
|
@@ -671,23 +721,38 @@ function createSpApplymentRouter(deps) {
|
|
|
671
721
|
if (r.status !== 200 || !(r.json.applyment_id != null)) return wxError(res, r, "提交进件失败");
|
|
672
722
|
const applymentId = String(r.json.applyment_id);
|
|
673
723
|
// 台账快照: 敏感字段脱敏 (身份证/卡号只留后 4 位; already_encrypted 时已是密文, 脱敏无妨)
|
|
724
|
+
// M3 🟡-1: 进件时刻的公示抽佣比例快照进 subject_json (事后对账依据)
|
|
674
725
|
const snapshot = {
|
|
675
726
|
...f,
|
|
676
727
|
legal_id_number: maskTail(f.legal_id_number),
|
|
677
728
|
account_number: maskTail(f.account_number),
|
|
678
729
|
business_code: undefined,
|
|
679
730
|
already_encrypted: undefined,
|
|
731
|
+
commission_bp_at_apply: spCommission.getDefaultRateBp(db),
|
|
680
732
|
};
|
|
681
733
|
const now = nowSec();
|
|
682
734
|
// upsert: 驳回重提时更新已有行 (同 business_code 覆盖), 否则插新行
|
|
735
|
+
// M2: tb_address 落列 (商户链上地址贯通 — 引导金 recipient)
|
|
736
|
+
const tbAddress = /^tct1[a-z0-9]{20,60}$/.test(String(f.tb_address || "")) ? String(f.tb_address) : "";
|
|
683
737
|
db.prepare(`INSERT INTO sp_applyments
|
|
684
|
-
(business_code, applyment_id, status, subject_json, created_at, updated_at)
|
|
685
|
-
VALUES (
|
|
738
|
+
(business_code, applyment_id, status, subject_json, tb_address, created_at, updated_at)
|
|
739
|
+
VALUES (?,?,?,?,?,?,?)
|
|
686
740
|
ON CONFLICT(business_code) DO UPDATE SET
|
|
687
741
|
applyment_id=excluded.applyment_id, status=excluded.status,
|
|
688
|
-
subject_json=excluded.subject_json, updated_at=excluded.updated_at`)
|
|
689
|
-
.run(businessCode, applymentId, "SUBMITTED", JSON.stringify(snapshot).slice(0, 8000), now, now);
|
|
690
|
-
console.log(`[sp-applyment] 进件已提交 bc=${businessCode} applyment_id=${applymentId}`);
|
|
742
|
+
subject_json=excluded.subject_json, tb_address=excluded.tb_address, updated_at=excluded.updated_at`)
|
|
743
|
+
.run(businessCode, applymentId, "SUBMITTED", JSON.stringify(snapshot).slice(0, 8000), tbAddress, now, now);
|
|
744
|
+
console.log(`[sp-applyment] 进件已提交 bc=${businessCode} applyment_id=${applymentId}${tbAddress ? " tb=" + tbAddress.slice(0, 14) + "…" : ""}`);
|
|
745
|
+
// M2 断点 b/d: 微信受理成功即入队 oracle 引导金 → oracle tick 先发 feegrant (N16 顺序已有测试锁定),
|
|
746
|
+
// 商户 0 余额也能签 KYC。失败 warn 不阻塞, FINISHED 钩子里会再入队 (oracle 侧幂等)。
|
|
747
|
+
if (tbAddress) {
|
|
748
|
+
try {
|
|
749
|
+
const g = await notifyOracleGrant(deps, { tb_address: tbAddress, license_number: f.license_number });
|
|
750
|
+
if (g.ok) console.log(`[sp-applyment] oracle 引导金已入队 recipient=${tbAddress.slice(0, 14)}… enqueued=${g.json && g.json.enqueued}`);
|
|
751
|
+
else console.warn(`[sp-applyment] oracle 入队未成功 status=${g.status} (FINISHED 钩子再试)`);
|
|
752
|
+
} catch (e) {
|
|
753
|
+
console.warn("[sp-applyment] oracle 入队失败 (不阻塞进件):", e && e.message);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
691
756
|
res.json({ applyment_id: applymentId, business_code: businessCode });
|
|
692
757
|
} catch (e) {
|
|
693
758
|
return chainWrites.handleError(res, e, "/api/sp/applyment/submit");
|
|
@@ -718,6 +783,38 @@ function createSpApplymentRouter(deps) {
|
|
|
718
783
|
j.applyment_id != null ? String(j.applyment_id) : "", j.applyment_id != null ? String(j.applyment_id) : "",
|
|
719
784
|
now, bc);
|
|
720
785
|
console.log(`[sp-applyment] 状态查询 bc=${bc} state=${state}${j.sub_mchid ? " sub=" + j.sub_mchid : ""}`);
|
|
786
|
+
// M2 FINISHED 钩子 (断点 b/c): 审核通过 → ① upsert sp_sub_merchants 回调路由表 (顺带修 M3
|
|
787
|
+
// 生产无写入) ② oracle 入队引导金 (submit 时已入过一次, 这里幂等兜底)。只触发一次 (hook_fired_at)。
|
|
788
|
+
if (state === "APPLYMENT_STATE_FINISHED" && j.sub_mchid) {
|
|
789
|
+
const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(bc);
|
|
790
|
+
if (row && !row.hook_fired_at) {
|
|
791
|
+
try {
|
|
792
|
+
const snap = JSON.parse(row.subject_json || "{}");
|
|
793
|
+
db.prepare(`INSERT INTO sp_sub_merchants (sub_mchid, seller_address, shop_name, webhook_url, status, created_at)
|
|
794
|
+
VALUES (?,?,?,'','active',?)
|
|
795
|
+
ON CONFLICT(sub_mchid) DO UPDATE SET seller_address=excluded.seller_address, shop_name=excluded.shop_name`)
|
|
796
|
+
.run(String(j.sub_mchid), row.tb_address || "", String(snap.merchant_shortname || ""), now);
|
|
797
|
+
console.log(`[sp-applyment] FINISHED 钩子: sub_mchid=${j.sub_mchid} 已登记回调路由 seller=${(row.tb_address || "").slice(0, 14)}…`);
|
|
798
|
+
// M3-1: 进件通过时也确认分账接收方已注册 (启动时注册失败的兜底; 台账幂等)
|
|
799
|
+
await profitsharing.registerDefaultReceivers(db);
|
|
800
|
+
if (row.tb_address) {
|
|
801
|
+
const g = await notifyOracleGrant(deps, { tb_address: row.tb_address, license_number: snap.license_number });
|
|
802
|
+
if (g.ok) {
|
|
803
|
+
console.log(`[sp-applyment] FINISHED 钩子: oracle 入队确认 enqueued=${g.json && g.json.enqueued}`);
|
|
804
|
+
db.prepare("UPDATE sp_applyments SET hook_fired_at=? WHERE business_code=?").run(now, bc);
|
|
805
|
+
} else {
|
|
806
|
+
// oracle 暂时不可达: 不标 hook_fired_at, 下轮状态查询重试 (oracle 侧入队幂等, 重试安全)
|
|
807
|
+
console.warn(`[sp-applyment] FINISHED 钩子: oracle 入队未成功 status=${g.status} (下轮重试)`);
|
|
808
|
+
}
|
|
809
|
+
} else {
|
|
810
|
+
console.warn(`[sp-applyment] FINISHED 钩子: 台账无 tb_address bc=${bc}, 引导金无法定向 (人工补录)`);
|
|
811
|
+
db.prepare("UPDATE sp_applyments SET hook_fired_at=? WHERE business_code=?").run(now, bc);
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {
|
|
814
|
+
console.warn("[sp-applyment] FINISHED 钩子失败 (下轮状态查询重试):", e && e.message);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
721
818
|
res.json({
|
|
722
819
|
business_code: bc,
|
|
723
820
|
status: state,
|
package/routes/sp-commission.js
CHANGED
|
@@ -35,6 +35,21 @@ CREATE TABLE IF NOT EXISTS sp_commission_rates (
|
|
|
35
35
|
/** rate_bp → 人性化百分比 ("1.50%") */
|
|
36
36
|
function ratePct(rateBp) { return (rateBp / 100).toFixed(2) + "%"; }
|
|
37
37
|
|
|
38
|
+
/** M3 🟡-1: 公示默认抽佣比例 (sub_mchid='__default__' 特殊行) — 读表优先,
|
|
39
|
+
* env SP_DEFAULT_COMMISSION_BP 只作首次种子 (表里没有时落一行, 之后 PUT 覆盖生效) */
|
|
40
|
+
const DEFAULT_ROW_KEY = "__default__";
|
|
41
|
+
function getDefaultRateBp(db) {
|
|
42
|
+
ensureCommissionTables(db);
|
|
43
|
+
const row = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(DEFAULT_ROW_KEY);
|
|
44
|
+
if (row) return row.rate_bp;
|
|
45
|
+
let seed = Number(process.env.SP_DEFAULT_COMMISSION_BP || 100);
|
|
46
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > MAX_RATE_BP) seed = 100;
|
|
47
|
+
const now = nowSec();
|
|
48
|
+
db.prepare("INSERT OR IGNORE INTO sp_commission_rates (sub_mchid, provider, rate_bp, updated_by, created_at, updated_at) VALUES (?,?,?,?,?,?)")
|
|
49
|
+
.run(DEFAULT_ROW_KEY, "", seed, "env-seed", now, now);
|
|
50
|
+
return seed;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
function createSpCommissionRouter(deps) {
|
|
39
54
|
const router = express.Router();
|
|
40
55
|
const { db } = deps;
|
|
@@ -101,4 +116,4 @@ function createSpCommissionRouter(deps) {
|
|
|
101
116
|
return router;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP };
|
|
119
|
+
module.exports = { createSpCommissionRouter, ensureCommissionTables, ratePct, MAX_RATE_BP, getDefaultRateBp, DEFAULT_ROW_KEY };
|
package/routes/sp-notify.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* 6. 上链 MsgSubmitPaymentFact (失败 warn 不阻塞, chain_txhash 留空等 retryPending 补)
|
|
13
13
|
* 7. 转发商户 webhook (3s 超时, 结果记 forward_status)
|
|
14
14
|
* 8. 按登记的抽佣比例触发微信分账 (默认 mock, WXPAY_PROFITSHARING_ENABLED=true 才真调)
|
|
15
|
+
* 9. M3 (2026-10-03): 分账台账列 (ps_*) + 触发落行 pending→done/mock/failed + 5min 补扫
|
|
16
|
+
* (retryProfitsharingSweep 同步微信真实状态/同号重发) + 启动/进件时注册分账接收方
|
|
17
|
+
* (profitsharing.registerDefaultReceivers, 台账幂等)
|
|
15
18
|
*
|
|
16
19
|
* 与直连模式 (agent-pay E2) 的差异: 回调只推到服务商, 服务商验签解密后按 sub_mchid
|
|
17
20
|
* 分发 —— 上链提交支付事实 + 转发/落账给对应商户。
|
|
@@ -51,6 +54,16 @@ CREATE TABLE IF NOT EXISTS sp_sub_merchants (
|
|
|
51
54
|
created_at INTEGER NOT NULL
|
|
52
55
|
);
|
|
53
56
|
`);
|
|
57
|
+
// M3-2: 分账台账列 (老库兼容: PRAGMA 检查后 ALTER)
|
|
58
|
+
const cols = db.prepare("PRAGMA table_info(sp_payment_facts)").all().map((c) => c.name);
|
|
59
|
+
for (const [col, ddl] of [
|
|
60
|
+
["ps_out_order_no", "ALTER TABLE sp_payment_facts ADD COLUMN ps_out_order_no TEXT DEFAULT ''"],
|
|
61
|
+
["ps_status", "ALTER TABLE sp_payment_facts ADD COLUMN ps_status TEXT DEFAULT ''"], // ''/pending/mock/done/failed
|
|
62
|
+
["ps_amount", "ALTER TABLE sp_payment_facts ADD COLUMN ps_amount INTEGER"],
|
|
63
|
+
["ps_at", "ALTER TABLE sp_payment_facts ADD COLUMN ps_at INTEGER"],
|
|
64
|
+
]) {
|
|
65
|
+
if (!cols.includes(col)) db.exec(ddl);
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** 上链 MsgSubmitPaymentFact (真实通道; 异常上抛, 由调用方降级为 warn 不阻塞)
|
|
@@ -102,12 +115,73 @@ async function retryPending(db, opts) {
|
|
|
102
115
|
return { scanned: rows.length, retried, failed };
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
/** M3-2: 分账补扫 — 扫 ps_status='pending'/'failed' 的台账行:
|
|
119
|
+
* 先 queryProfitsharing 同步微信侧真实状态 (FINISHED → done); 仍 failed 的用同 out_order_no
|
|
120
|
+
* 重发一笔 (微信侧幂等)。mock 模式: pending → mock, failed → 重发 mock 后记 mock。 */
|
|
121
|
+
async function retryProfitsharingSweep(db, opts) {
|
|
122
|
+
const rows = db.prepare(`SELECT txn_id, sub_mchid, amount, ps_out_order_no, ps_amount, ps_status
|
|
123
|
+
FROM sp_payment_facts WHERE ps_status IN ('pending','failed') AND ps_out_order_no <> '' LIMIT 20`).all();
|
|
124
|
+
let synced = 0, retried = 0, failed = 0;
|
|
125
|
+
const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
try {
|
|
128
|
+
const q = await profitsharing.queryProfitsharing(row.ps_out_order_no, { subMchid: row.sub_mchid });
|
|
129
|
+
if (q && q.mock) {
|
|
130
|
+
// mock: 无真实状态可同步; failed 行重发一笔 mock 分账
|
|
131
|
+
if (row.ps_status === "failed") {
|
|
132
|
+
await profitsharing.requestProfitsharing({
|
|
133
|
+
outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
|
|
134
|
+
subMchid: row.sub_mchid,
|
|
135
|
+
receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
|
|
136
|
+
});
|
|
137
|
+
retried++;
|
|
138
|
+
} else {
|
|
139
|
+
synced++;
|
|
140
|
+
}
|
|
141
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='mock', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (q && q.status === 200 && q.json) {
|
|
145
|
+
const st = String(q.json.state || "");
|
|
146
|
+
if (/FINISHED|SUCCESS/i.test(st)) {
|
|
147
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='done', ps_at=? WHERE txn_id=?").run(nowSec(), row.txn_id);
|
|
148
|
+
synced++;
|
|
149
|
+
} else if (row.ps_status === "failed") {
|
|
150
|
+
// 微信侧未成功 → 同 out_order_no 重发 (幂等)
|
|
151
|
+
const r2 = await profitsharing.requestProfitsharing({
|
|
152
|
+
outTradeNo: row.ps_out_order_no, transactionId: row.txn_id, tradeAmount: row.amount,
|
|
153
|
+
subMchid: row.sub_mchid,
|
|
154
|
+
receivers: [{ account: receiverAccount, amount: row.ps_amount, description: "服务商抽佣" }],
|
|
155
|
+
});
|
|
156
|
+
const ok = r2 && r2.status === 200;
|
|
157
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(ok ? "pending" : "failed", nowSec(), row.txn_id);
|
|
158
|
+
if (ok) retried++; else failed++;
|
|
159
|
+
}
|
|
160
|
+
// pending 且微信侧还在处理中: 保持 pending 下轮再扫
|
|
161
|
+
} else {
|
|
162
|
+
failed++;
|
|
163
|
+
console.warn(`[sp-notify] 分账补扫查询未成功 out_order_no=${row.ps_out_order_no} status=${q && q.status}`);
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
failed++;
|
|
167
|
+
console.warn(`[sp-notify] 分账补扫失败 txn=${row.txn_id}:`, e && e.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { scanned: rows.length, synced, retried, failed };
|
|
171
|
+
}
|
|
172
|
+
|
|
105
173
|
function createSpNotifyRouter(deps) {
|
|
106
174
|
const router = express.Router();
|
|
107
175
|
const { db } = deps;
|
|
108
176
|
ensureTables(db);
|
|
109
177
|
spCommission.ensureCommissionTables(db); // 分账触发要查抽佣表
|
|
110
178
|
const injectedSendTx = deps.sendTx || null; // 测试注入 (绕过真实链)
|
|
179
|
+
// M3-1: 启动即注册分账接收方 (SP 自身 mchid; 幂等, 台账标记, mock 也落行)
|
|
180
|
+
profitsharing.registerDefaultReceivers(db)
|
|
181
|
+
.catch((e) => console.warn("[sp-notify] 分账接收方注册失败 (进件 FINISHED 钩子会再试):", e && e.message));
|
|
182
|
+
// M3-2: 分账补扫定时器 (5min; 扫 ps_status pending/failed 同步微信真实状态/重发)
|
|
183
|
+
const psTimer = setInterval(() => { retryProfitsharingSweep(db).catch((e) => console.warn("[sp-notify] 分账补扫异常:", e && e.message)); }, 5 * 60 * 1000);
|
|
184
|
+
psTimer.unref && psTimer.unref();
|
|
111
185
|
|
|
112
186
|
/** 上链统一入口: 禁用开关 > 测试注入 > 真实通道 */
|
|
113
187
|
async function sendFact(fact) {
|
|
@@ -116,7 +190,8 @@ function createSpNotifyRouter(deps) {
|
|
|
116
190
|
return submitPaymentFactToChain(fact);
|
|
117
191
|
}
|
|
118
192
|
|
|
119
|
-
/** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调)
|
|
193
|
+
/** 抽佣分账触发: 按 sp_commission_rates 登记比例, 失败只 warn (mock 默认, 不阻塞回调)
|
|
194
|
+
* M3-2: 台账落行 pending → 微信/mock 返回后 done/mock/failed; failed/pending 由 retryProfitsharingSweep 补扫 */
|
|
120
195
|
function maybeTriggerProfitsharing(f) {
|
|
121
196
|
try {
|
|
122
197
|
const rate = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(f.subMchid);
|
|
@@ -124,15 +199,22 @@ function createSpNotifyRouter(deps) {
|
|
|
124
199
|
const commission = Math.floor(f.amount * rate.rate_bp / 10000);
|
|
125
200
|
if (!(commission > 0)) return;
|
|
126
201
|
const receiverAccount = process.env.WXPAY_SP_MCHID || process.env.WXPAY_MCHID || "";
|
|
202
|
+
const outOrderNo = "ps-" + f.txnId; // 分账单号 (微信侧 out_order_no, 幂等键)
|
|
203
|
+
// 台账先落 pending (即使后续调用失败也留痕可补扫)
|
|
204
|
+
db.prepare("UPDATE sp_payment_facts SET ps_out_order_no=?, ps_amount=?, ps_status='pending', ps_at=? WHERE txn_id=?")
|
|
205
|
+
.run(outOrderNo, commission, nowSec(), f.txnId);
|
|
127
206
|
profitsharing.requestProfitsharing({
|
|
128
|
-
outTradeNo:
|
|
207
|
+
outTradeNo: outOrderNo,
|
|
129
208
|
transactionId: f.txnId,
|
|
130
209
|
tradeAmount: f.amount,
|
|
131
210
|
subMchid: f.subMchid,
|
|
132
211
|
receivers: [{ account: receiverAccount, amount: commission, description: "服务商抽佣" }],
|
|
133
212
|
}).then((r) => {
|
|
134
|
-
|
|
213
|
+
const st = (r && r.mock) ? "mock" : (r && r.status === 200 ? "done" : "failed");
|
|
214
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status=?, ps_at=? WHERE txn_id=?").run(st, nowSec(), f.txnId);
|
|
215
|
+
console.log(`[sp-notify] 分账触发 txn=${f.txnId} commission=${commission} ps_status=${st} mock=${!!(r && r.mock)}`);
|
|
135
216
|
}).catch((e) => {
|
|
217
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='failed', ps_at=? WHERE txn_id=?").run(nowSec(), f.txnId);
|
|
136
218
|
console.warn(`[sp-notify] 分账触发失败 txn=${f.txnId}:`, e && e.message);
|
|
137
219
|
});
|
|
138
220
|
} catch (e) {
|
|
@@ -249,4 +331,4 @@ function createSpNotifyRouter(deps) {
|
|
|
249
331
|
return router;
|
|
250
332
|
}
|
|
251
333
|
|
|
252
|
-
module.exports = { createSpNotifyRouter, ensureTables, retryPending, submitPaymentFactToChain };
|
|
334
|
+
module.exports = { createSpNotifyRouter, ensureTables, retryPending, retryProfitsharingSweep, submitPaymentFactToChain };
|
|
@@ -25,6 +25,7 @@ const pubKeyPath = path.join(tmpDir, "wx_sp_pub.pem");
|
|
|
25
25
|
fs.writeFileSync(pubKeyPath, wxPub.publicKey.export({ type: "spki", format: "pem" }));
|
|
26
26
|
|
|
27
27
|
process.env.WXPAY_SP_PUBLIC_KEY_PATH = pubKeyPath;
|
|
28
|
+
process.env.TB_BASE_DIR = tmpDir; // M2: applyment submit 会读本机 seller 身份 (tb_address 贯通), 隔离到临时目录
|
|
28
29
|
process.env.SP_MERCHANT_TOKEN = MERCHANT_TOKEN;
|
|
29
30
|
|
|
30
31
|
const onboard = require("./routes/onboard");
|
|
@@ -154,6 +155,7 @@ test("submit: 本地加密转发 (转发体无明文, 密文可还原) + 台账
|
|
|
154
155
|
assert.strictEqual(spCapture.submits.length, submitsBefore + 1);
|
|
155
156
|
const fwd = spCapture.submits[submitsBefore];
|
|
156
157
|
assert.strictEqual(fwd.already_encrypted, true);
|
|
158
|
+
assert.match(fwd.tb_address || "", /^tct1[a-z0-9]{20,60}$/, "M2: payload 带商户链上地址 (引导金 recipient)");
|
|
157
159
|
const fwdStr = JSON.stringify(fwd);
|
|
158
160
|
assert.ok(!fwdStr.includes(PLAINTEXT.id), "转发体不含明文身份证");
|
|
159
161
|
assert.ok(!fwdStr.includes(PLAINTEXT.card), "转发体不含明文卡号");
|
|
@@ -277,6 +279,8 @@ test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元
|
|
|
277
279
|
assert.ok(html.includes('name="sales_scene"'), "场景单选");
|
|
278
280
|
assert.ok(html.includes("SALES_SCENES_WEB") && html.includes("SALES_SCENES_MP"), "WEB/MP 场景");
|
|
279
281
|
assert.ok(html.includes("apply-err"), "错误红卡容器");
|
|
282
|
+
assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
|
|
283
|
+
assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
|
|
280
284
|
} finally { srv.close(); }
|
|
281
285
|
});
|
|
282
286
|
|
package/test-sp-applyment.js
CHANGED
|
@@ -154,6 +154,8 @@ test("submit mock: 落台账 + 敏感字段加密 (非明文且可还原) + 重
|
|
|
154
154
|
assert.ok(!row.subject_json.includes("440300199001011234"), "快照不含明文身份证");
|
|
155
155
|
assert.ok(!row.subject_json.includes("6225880212345678"), "快照不含明文卡号");
|
|
156
156
|
assert.ok(row.subject_json.includes("1234"), "脱敏后留后4位");
|
|
157
|
+
// M3 🟡-1: 进件时刻公示比例快照
|
|
158
|
+
assert.ok(JSON.parse(row.subject_json).commission_bp_at_apply >= 0, "快照含 commission_bp_at_apply");
|
|
157
159
|
|
|
158
160
|
// 敏感字段加密断言: 用同一入口 buildApplymentBody 组装, 密文非明文且测试私钥可还原
|
|
159
161
|
const pem = fs.readFileSync(pubKeyPath, "utf8");
|
|
@@ -230,24 +232,57 @@ test("GET /api/sp/info 公开名片: 无 admin key 200 + env 覆盖生效", asyn
|
|
|
230
232
|
assert.strictEqual(j.commission_rate_bp, 100, "默认 1%");
|
|
231
233
|
assert.strictEqual(j.rate_pct, "1.00%");
|
|
232
234
|
assert.ok(j.gateway.includes("127.0.0.1"), "gateway 从 req host 推断");
|
|
233
|
-
// env
|
|
235
|
+
// env 覆盖: 文本类即时生效; SP_DEFAULT_COMMISSION_BP 只作首次种子 (M3 🟡-1),
|
|
236
|
+
// 当前库已被首次调用种子为 100 → 需新库验证种子语义
|
|
234
237
|
process.env.SP_NAME = "无锡小播鼠网络科技";
|
|
235
238
|
process.env.SP_DESC = "TrustBase 官方支付服务商";
|
|
236
239
|
process.env.SP_OFFICIAL_URL = "https://trustbase.example.com";
|
|
237
240
|
process.env.SP_CONTACT = "微信: xiaomu";
|
|
238
|
-
process.env.SP_DEFAULT_COMMISSION_BP = "150";
|
|
239
241
|
process.env.SP_CHAIN_ADDR = "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7";
|
|
240
242
|
r = await fetch(base + "/api/sp/info");
|
|
241
243
|
j = await r.json();
|
|
242
244
|
assert.strictEqual(j.name, "无锡小播鼠网络科技");
|
|
243
245
|
assert.strictEqual(j.contact, "微信: xiaomu");
|
|
244
|
-
assert.strictEqual(j.commission_rate_bp,
|
|
245
|
-
assert.strictEqual(j.rate_pct, "1.50%");
|
|
246
|
+
assert.strictEqual(j.commission_rate_bp, 100, "种子已落表, env 改不动公示比例 (读表优先)");
|
|
246
247
|
assert.strictEqual(j.chain_address, "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7");
|
|
247
248
|
} finally {
|
|
248
249
|
srv.close();
|
|
249
250
|
delete process.env.SP_NAME; delete process.env.SP_DESC; delete process.env.SP_OFFICIAL_URL;
|
|
250
|
-
delete process.env.SP_CONTACT; delete process.env.
|
|
251
|
+
delete process.env.SP_CONTACT; delete process.env.SP_CHAIN_ADDR;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("M3 🟡-1: /info 公示比例 env 首种子 + 表行优先 (PUT __default__ 覆盖生效)", async () => {
|
|
256
|
+
// 新库 + env 种子 250 → 首次读 2.50% 且落表; 之后 admin PUT __default__=200 → 2.00% (env 不再生效)
|
|
257
|
+
process.env.SP_DEFAULT_COMMISSION_BP = "250";
|
|
258
|
+
const spCommission = require("./routes/sp-commission");
|
|
259
|
+
const db = new Database(":memory:");
|
|
260
|
+
const app = express();
|
|
261
|
+
app.use(express.json());
|
|
262
|
+
app.use("/api/sp", spApplyment.createSpApplymentRouter({ db }));
|
|
263
|
+
app.use("/api/sp", spCommission.createSpCommissionRouter({ db }));
|
|
264
|
+
const srv = app.listen(0);
|
|
265
|
+
await new Promise((r) => srv.once("listening", r));
|
|
266
|
+
const base = `http://127.0.0.1:${srv.address().port}`;
|
|
267
|
+
try {
|
|
268
|
+
let j = await (await fetch(base + "/api/sp/info")).json();
|
|
269
|
+
assert.strictEqual(j.commission_rate_bp, 250);
|
|
270
|
+
assert.strictEqual(j.rate_pct, "2.50%");
|
|
271
|
+
const seedRow = db.prepare("SELECT * FROM sp_commission_rates WHERE sub_mchid='__default__'").get();
|
|
272
|
+
assert.ok(seedRow, "env 首种子落表");
|
|
273
|
+
assert.strictEqual(seedRow.rate_bp, 250);
|
|
274
|
+
// admin 改公示比例 → 读表优先
|
|
275
|
+
const put = await fetch(base + "/api/sp/commission-rate", {
|
|
276
|
+
method: "PUT", headers: { "Content-Type": "application/json", "x-sp-admin-key": ADMIN_KEY },
|
|
277
|
+
body: JSON.stringify({ sub_mchid: "__default__", rate_bp: 200 }),
|
|
278
|
+
});
|
|
279
|
+
assert.strictEqual(put.status, 200, JSON.stringify(await put.json()));
|
|
280
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
281
|
+
assert.strictEqual(j.commission_rate_bp, 200, "PUT __default__ 覆盖生效");
|
|
282
|
+
assert.strictEqual(j.rate_pct, "2.00%");
|
|
283
|
+
} finally {
|
|
284
|
+
srv.close();
|
|
285
|
+
delete process.env.SP_DEFAULT_COMMISSION_BP;
|
|
251
286
|
}
|
|
252
287
|
});
|
|
253
288
|
|
|
@@ -284,3 +319,73 @@ test("business_code 直通: 调用方带号 (卖家侧生成) → SP 不新生
|
|
|
284
319
|
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_applyments").get().n, 1, "同号覆盖不新增行");
|
|
285
320
|
} finally { srv.close(); }
|
|
286
321
|
});
|
|
322
|
+
|
|
323
|
+
test("M2: tb_address 落列 + submit 即入队 oracle + FINISHED 钩子 (upsert 路由表 + 幂等一次)", async () => {
|
|
324
|
+
// oracleFetch 注入捕获 (不打真 oracle)
|
|
325
|
+
const oracleCalls = [];
|
|
326
|
+
const oracleFetch = async (url, opts) => {
|
|
327
|
+
oracleCalls.push({ url, body: JSON.parse(opts.body) });
|
|
328
|
+
return { ok: true, status: 200, json: async () => ({ ok: true, enqueued: true }) };
|
|
329
|
+
};
|
|
330
|
+
const db = new Database(":memory:");
|
|
331
|
+
const app = express();
|
|
332
|
+
app.use(express.json());
|
|
333
|
+
app.use("/api/sp", spApplyment.createSpApplymentRouter({ db, oracleFetch }));
|
|
334
|
+
const srv = app.listen(0);
|
|
335
|
+
await new Promise((r) => srv.once("listening", r));
|
|
336
|
+
const base = `http://127.0.0.1:${srv.address().port}`;
|
|
337
|
+
const TB_ADDR = "tct1qpz7x0f2fakeselleraddress0000aaaa12";
|
|
338
|
+
try {
|
|
339
|
+
const form = await fillForm(base);
|
|
340
|
+
form.tb_address = TB_ADDR;
|
|
341
|
+
const r = await fetch(base + "/api/sp/applyment/submit", { method: "POST", headers: H, body: JSON.stringify(form) });
|
|
342
|
+
const j = await r.json();
|
|
343
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
344
|
+
// tb_address 落列
|
|
345
|
+
const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
346
|
+
assert.strictEqual(row.tb_address, TB_ADDR, "M2: tb_address 落台账");
|
|
347
|
+
// submit 成功即入队 oracle (feegrant 前置: 商户 0 余额可签 KYC)
|
|
348
|
+
assert.strictEqual(oracleCalls.length, 1, "submit 成功即入队 oracle");
|
|
349
|
+
assert.strictEqual(oracleCalls[0].body.recipient, TB_ADDR);
|
|
350
|
+
const expectCh = crypto.createHash("sha256").update("license:" + form.license_number).digest("hex");
|
|
351
|
+
assert.strictEqual(oracleCalls[0].body.credential_hash, expectCh, "锚点 sha256('license:'+信用代码) 对齐 c5-glue");
|
|
352
|
+
assert.ok(oracleCalls[0].url.endsWith("/api/oracle/bootstrap-grant"));
|
|
353
|
+
|
|
354
|
+
// FINISHED 钩子: upsert sp_sub_merchants + 再入队 (幂等兜底) + hook_fired_at
|
|
355
|
+
const s1 = await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
|
|
356
|
+
assert.strictEqual(s1.status, "APPLYMENT_STATE_FINISHED");
|
|
357
|
+
const mch = db.prepare("SELECT * FROM sp_sub_merchants WHERE sub_mchid = ?").get("1600000999");
|
|
358
|
+
assert.ok(mch, "回调路由表已 upsert (M3 生产写入修复)");
|
|
359
|
+
assert.strictEqual(mch.seller_address, TB_ADDR);
|
|
360
|
+
assert.strictEqual(mch.shop_name, form.merchant_shortname);
|
|
361
|
+
assert.strictEqual(mch.status, "active");
|
|
362
|
+
const row2 = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
363
|
+
assert.ok(row2.hook_fired_at, "hook_fired_at 已标记");
|
|
364
|
+
assert.strictEqual(oracleCalls.length, 2, "FINISHED 钩子再入队一次 (oracle 侧幂等兜底)");
|
|
365
|
+
assert.strictEqual(oracleCalls[1].body.recipient, TB_ADDR);
|
|
366
|
+
// 幂等: 再查一次状态, 钩子不重火
|
|
367
|
+
await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
|
|
368
|
+
assert.strictEqual(oracleCalls.length, 2, "FINISHED 钩子只触发一次");
|
|
369
|
+
} finally { srv.close(); }
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("M2: FINISHED 钩子在 oracle 不可达时不标 hook_fired_at (下轮重试)", async () => {
|
|
373
|
+
const oracleFetch = async () => { throw new Error("connect refused"); };
|
|
374
|
+
const db = new Database(":memory:");
|
|
375
|
+
const app = express();
|
|
376
|
+
app.use(express.json());
|
|
377
|
+
app.use("/api/sp", spApplyment.createSpApplymentRouter({ db, oracleFetch }));
|
|
378
|
+
const srv = app.listen(0);
|
|
379
|
+
await new Promise((r) => srv.once("listening", r));
|
|
380
|
+
const base = `http://127.0.0.1:${srv.address().port}`;
|
|
381
|
+
try {
|
|
382
|
+
const form = await fillForm(base);
|
|
383
|
+
form.tb_address = "tct1qpz7x0f2fakeselleraddress0000aaaa12";
|
|
384
|
+
const j = await (await fetch(base + "/api/sp/applyment/submit", { method: "POST", headers: H, body: JSON.stringify(form) })).json();
|
|
385
|
+
await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
|
|
386
|
+
const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
387
|
+
assert.ok(!row.hook_fired_at, "oracle 不可达不标 hook (下轮重试)");
|
|
388
|
+
// 但回调路由表已 upsert (不依赖 oracle)
|
|
389
|
+
assert.ok(db.prepare("SELECT * FROM sp_sub_merchants WHERE sub_mchid='1600000999'").get(), "路由表仍落行");
|
|
390
|
+
} finally { srv.close(); }
|
|
391
|
+
});
|
package/test-sp-gateway.js
CHANGED
|
@@ -359,3 +359,77 @@ test("retryPending: 上链失败台账留空 → 扫出重试补 txhash", async
|
|
|
359
359
|
process.env.SP_CHAIN_DISABLED = "1";
|
|
360
360
|
}
|
|
361
361
|
});
|
|
362
|
+
|
|
363
|
+
test("M3-1: 分账接收方注册幂等 (台账标记, 已注册不重复调)", async () => {
|
|
364
|
+
const db = new Database(":memory:");
|
|
365
|
+
spNotify.ensureTables(db);
|
|
366
|
+
// 首次注册 (mock 模式, WXPAY_SP_MCHID=1000686974 已在 env)
|
|
367
|
+
const r1 = await profitsharing.registerDefaultReceivers(db);
|
|
368
|
+
assert.strictEqual(r1.registered, true);
|
|
369
|
+
assert.strictEqual(r1.mock, true);
|
|
370
|
+
// 台账落行
|
|
371
|
+
const row = db.prepare("SELECT * FROM sp_ps_receivers WHERE account='1000686974'").get();
|
|
372
|
+
assert.ok(row, "接收方台账落行");
|
|
373
|
+
assert.strictEqual(row.mock, 1);
|
|
374
|
+
// 第二次 → 幂等跳过
|
|
375
|
+
const r2 = await profitsharing.registerDefaultReceivers(db);
|
|
376
|
+
assert.strictEqual(r2.registered, false);
|
|
377
|
+
assert.strictEqual(r2.reason, "already");
|
|
378
|
+
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_ps_receivers").get().n, 1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("M3-2: 分账台账落行 (pending→mock) + 补扫重试 failed", async () => {
|
|
382
|
+
const { db, srv, merchantSrv, base } = await startServers();
|
|
383
|
+
try {
|
|
384
|
+
insertMerchant(db, SUB_MCHID, "");
|
|
385
|
+
// 登记抽佣 1% (100bp)
|
|
386
|
+
await fetch(base + "/commission-rate", {
|
|
387
|
+
method: "PUT", headers: { "content-type": "application/json", "x-sp-admin-key": ADMIN_KEY },
|
|
388
|
+
body: JSON.stringify({ sub_mchid: SUB_MCHID, rate_bp: 100 }),
|
|
389
|
+
});
|
|
390
|
+
const body = makeNotifyBody({
|
|
391
|
+
out_trade_no: "tb-ps-1", transaction_id: "4200009999000100",
|
|
392
|
+
trade_state: "SUCCESS", amount: { total: 10000, currency: "CNY" }, sub_mchid: SUB_MCHID,
|
|
393
|
+
});
|
|
394
|
+
const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
395
|
+
assert.strictEqual(r.status, 200);
|
|
396
|
+
// 分账 fire-and-forget → 等台账 ps_status 落定 (mock 秒回)
|
|
397
|
+
let row = null;
|
|
398
|
+
for (let i = 0; i < 50; i++) {
|
|
399
|
+
row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
|
|
400
|
+
if (row && row.ps_status && row.ps_status !== "pending") break;
|
|
401
|
+
await new Promise((rs) => setTimeout(rs, 20));
|
|
402
|
+
}
|
|
403
|
+
assert.strictEqual(row.ps_out_order_no, "ps-4200009999000100", "分账单号落台账");
|
|
404
|
+
assert.strictEqual(row.ps_amount, 100, "1% 抽佣 = 100 分");
|
|
405
|
+
assert.strictEqual(row.ps_status, "mock", "mock 模式落 mock");
|
|
406
|
+
assert.ok(row.ps_at > 0);
|
|
407
|
+
// 手动置 failed → 补扫重发 (mock) → 回 mock
|
|
408
|
+
db.prepare("UPDATE sp_payment_facts SET ps_status='failed' WHERE txn_id=?").run("4200009999000100");
|
|
409
|
+
const res = await spNotify.retryProfitsharingSweep(db);
|
|
410
|
+
assert.strictEqual(res.scanned, 1);
|
|
411
|
+
assert.strictEqual(res.retried, 1, "failed 行重发一笔");
|
|
412
|
+
const row2 = db.prepare("SELECT ps_status FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000100");
|
|
413
|
+
assert.strictEqual(row2.ps_status, "mock");
|
|
414
|
+
// 再扫: 无 pending/failed
|
|
415
|
+
const res2 = await spNotify.retryProfitsharingSweep(db);
|
|
416
|
+
assert.strictEqual(res2.scanned, 0);
|
|
417
|
+
} finally { srv.close(); merchantSrv.close(); }
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("M3-2: 无抽佣登记的商户回调 → 分账列留空 (不触发)", async () => {
|
|
421
|
+
const { db, srv, merchantSrv, base } = await startServers();
|
|
422
|
+
try {
|
|
423
|
+
insertMerchant(db, SUB_MCHID, "");
|
|
424
|
+
const body = makeNotifyBody({
|
|
425
|
+
out_trade_no: "tb-ps-2", transaction_id: "4200009999000101",
|
|
426
|
+
trade_state: "SUCCESS", amount: { total: 500 }, sub_mchid: SUB_MCHID,
|
|
427
|
+
});
|
|
428
|
+
const r = await fetch(base + "/notify/wechat", { method: "POST", headers: wxSignHeaders(body), body });
|
|
429
|
+
assert.strictEqual(r.status, 200);
|
|
430
|
+
await new Promise((rs) => setTimeout(rs, 100));
|
|
431
|
+
const row = db.prepare("SELECT * FROM sp_payment_facts WHERE txn_id = ?").get("4200009999000101");
|
|
432
|
+
assert.strictEqual(row.ps_status, "", "未登记抽佣不触发分账");
|
|
433
|
+
assert.strictEqual(row.ps_out_order_no, "");
|
|
434
|
+
} finally { srv.close(); merchantSrv.close(); }
|
|
435
|
+
});
|