trustbase-seller-backend 0.2.2 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -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();
@@ -75,10 +75,25 @@ CREATE TABLE IF NOT EXISTS sp_applyments (
75
75
  state_msg TEXT NOT NULL DEFAULT '', -- 微信状态描述
76
76
  audit_detail TEXT NOT NULL DEFAULT '[]', -- 驳回明细 JSON (field/field_name/reject_reason)
77
77
  subject_json TEXT NOT NULL DEFAULT '{}', -- 表单快照 (敏感字段已脱敏)
78
+ tb_address TEXT NOT NULL DEFAULT '', -- 商户链上地址 (M2: 引导金 recipient 贯通)
79
+ hook_fired_at INTEGER, -- FINISHED 钩子已触发时间 (NULL=未触发, 幂等)
78
80
  created_at INTEGER NOT NULL,
79
81
  updated_at INTEGER NOT NULL
80
82
  );
83
+ -- 回调路由表 (与 sp-notify 同构; 这里兜底建, FINISHED 钩子 upsert 写入)
84
+ CREATE TABLE IF NOT EXISTS sp_sub_merchants (
85
+ sub_mchid TEXT PRIMARY KEY,
86
+ seller_address TEXT DEFAULT '',
87
+ shop_name TEXT DEFAULT '',
88
+ webhook_url TEXT DEFAULT '',
89
+ status TEXT DEFAULT 'active',
90
+ created_at INTEGER NOT NULL
91
+ );
81
92
  `);
93
+ // 老库兼容: 缺列补列 (PRAGMA 检查后再 ALTER)
94
+ const cols = db.prepare("PRAGMA table_info(sp_applyments)").all().map((c) => c.name);
95
+ if (!cols.includes("tb_address")) db.exec("ALTER TABLE sp_applyments ADD COLUMN tb_address TEXT NOT NULL DEFAULT ''");
96
+ if (!cols.includes("hook_fired_at")) db.exec("ALTER TABLE sp_applyments ADD COLUMN hook_fired_at INTEGER");
82
97
  }
83
98
 
84
99
  /** 微信支付公钥 pem (mock 无配置时临时生成内存对, 仅测试用) */
@@ -305,6 +320,39 @@ function maskTail(s) {
305
320
  return s.length <= 4 ? "***" : "***" + s.slice(-4);
306
321
  }
307
322
 
323
+ // ============================== M2: oracle 引导金入队通知 (进件→feegrant→KYC→审核→发放 自动链) ==============================
324
+ /** oracle 配置 (懒读 env): ORACLE_URL 默认 .123; 鉴权头对齐 oracle-sync c5AdminAuth (X-API-Key/X-API-Password) */
325
+ function oracleConf() {
326
+ return {
327
+ url: (process.env.ORACLE_URL || "http://192.168.1.123:3004").trim().replace(/\/+$/, ""),
328
+ key: (process.env.ORACLE_ADMIN_API_KEY || "").trim(),
329
+ pass: (process.env.ORACLE_ADMIN_API_PASSWORD || "").trim(),
330
+ };
331
+ }
332
+ /** 入队引导金: POST {ORACLE_URL}/api/oracle/bootstrap-grant {recipient, credential_hash}
333
+ * credential_hash 锚点对齐 c5-glue enqueueBootstrapGrant: sha256("license:"+统一社会信用代码)
334
+ * (ops-console 材料入库的 recipient 哈希锚点不同, 但 enqueue 有 recipient 双去重 → 同商户只留一行)
335
+ * 幂等由 oracle 侧担保; 失败上抛由调用方降级 warn (不阻塞进件)。 */
336
+ async function notifyOracleGrant(deps, info) {
337
+ if (!info.tb_address) return { ok: false, skipped: true, reason: "no_tb_address" };
338
+ const cfg = oracleConf();
339
+ // mock 模式默认假成功 (测试不打真 oracle); 注入 deps.oracleFetch 可捕获断言
340
+ const fetcher = deps.oracleFetch || (isMock()
341
+ ? async () => ({ ok: true, status: 200, json: async () => ({ ok: true, enqueued: true, mock: true }) })
342
+ : globalThis.fetch);
343
+ const headers = { "Content-Type": "application/json" };
344
+ if (cfg.key) headers["X-API-Key"] = cfg.key;
345
+ if (cfg.pass) headers["X-API-Password"] = cfg.pass;
346
+ const credentialHash = crypto.createHash("sha256").update("license:" + String(info.license_number || "")).digest("hex");
347
+ const r = await fetcher(cfg.url + "/api/oracle/bootstrap-grant", {
348
+ method: "POST", headers,
349
+ body: JSON.stringify({ recipient: info.tb_address, credential_hash: credentialHash }),
350
+ signal: AbortSignal.timeout(8000),
351
+ });
352
+ const j = await r.json().catch(() => ({}));
353
+ return { ok: !!r.ok, status: r.status, json: j, credential_hash: credentialHash };
354
+ }
355
+
308
356
  // ============================== 手机单页表单 (内联 HTML, 无构建) ==============================
309
357
  // 注意: 内嵌脚本只用单/双引号字符串, 不用反引号模板串 (本页本身在 JS 模板串内)。
310
358
  const PAGE_HTML = `<!DOCTYPE html>
@@ -680,14 +728,27 @@ function createSpApplymentRouter(deps) {
680
728
  };
681
729
  const now = nowSec();
682
730
  // upsert: 驳回重提时更新已有行 (同 business_code 覆盖), 否则插新行
731
+ // M2: tb_address 落列 (商户链上地址贯通 — 引导金 recipient)
732
+ const tbAddress = /^tct1[a-z0-9]{20,60}$/.test(String(f.tb_address || "")) ? String(f.tb_address) : "";
683
733
  db.prepare(`INSERT INTO sp_applyments
684
- (business_code, applyment_id, status, subject_json, created_at, updated_at)
685
- VALUES (?,?,?,?,?,?)
734
+ (business_code, applyment_id, status, subject_json, tb_address, created_at, updated_at)
735
+ VALUES (?,?,?,?,?,?,?)
686
736
  ON CONFLICT(business_code) DO UPDATE SET
687
737
  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}`);
738
+ subject_json=excluded.subject_json, tb_address=excluded.tb_address, updated_at=excluded.updated_at`)
739
+ .run(businessCode, applymentId, "SUBMITTED", JSON.stringify(snapshot).slice(0, 8000), tbAddress, now, now);
740
+ console.log(`[sp-applyment] 进件已提交 bc=${businessCode} applyment_id=${applymentId}${tbAddress ? " tb=" + tbAddress.slice(0, 14) + "…" : ""}`);
741
+ // M2 断点 b/d: 微信受理成功即入队 oracle 引导金 → oracle tick 先发 feegrant (N16 顺序已有测试锁定),
742
+ // 商户 0 余额也能签 KYC。失败 warn 不阻塞, FINISHED 钩子里会再入队 (oracle 侧幂等)。
743
+ if (tbAddress) {
744
+ try {
745
+ const g = await notifyOracleGrant(deps, { tb_address: tbAddress, license_number: f.license_number });
746
+ if (g.ok) console.log(`[sp-applyment] oracle 引导金已入队 recipient=${tbAddress.slice(0, 14)}… enqueued=${g.json && g.json.enqueued}`);
747
+ else console.warn(`[sp-applyment] oracle 入队未成功 status=${g.status} (FINISHED 钩子再试)`);
748
+ } catch (e) {
749
+ console.warn("[sp-applyment] oracle 入队失败 (不阻塞进件):", e && e.message);
750
+ }
751
+ }
691
752
  res.json({ applyment_id: applymentId, business_code: businessCode });
692
753
  } catch (e) {
693
754
  return chainWrites.handleError(res, e, "/api/sp/applyment/submit");
@@ -718,6 +779,36 @@ function createSpApplymentRouter(deps) {
718
779
  j.applyment_id != null ? String(j.applyment_id) : "", j.applyment_id != null ? String(j.applyment_id) : "",
719
780
  now, bc);
720
781
  console.log(`[sp-applyment] 状态查询 bc=${bc} state=${state}${j.sub_mchid ? " sub=" + j.sub_mchid : ""}`);
782
+ // M2 FINISHED 钩子 (断点 b/c): 审核通过 → ① upsert sp_sub_merchants 回调路由表 (顺带修 M3
783
+ // 生产无写入) ② oracle 入队引导金 (submit 时已入过一次, 这里幂等兜底)。只触发一次 (hook_fired_at)。
784
+ if (state === "APPLYMENT_STATE_FINISHED" && j.sub_mchid) {
785
+ const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(bc);
786
+ if (row && !row.hook_fired_at) {
787
+ try {
788
+ const snap = JSON.parse(row.subject_json || "{}");
789
+ db.prepare(`INSERT INTO sp_sub_merchants (sub_mchid, seller_address, shop_name, webhook_url, status, created_at)
790
+ VALUES (?,?,?,'','active',?)
791
+ ON CONFLICT(sub_mchid) DO UPDATE SET seller_address=excluded.seller_address, shop_name=excluded.shop_name`)
792
+ .run(String(j.sub_mchid), row.tb_address || "", String(snap.merchant_shortname || ""), now);
793
+ console.log(`[sp-applyment] FINISHED 钩子: sub_mchid=${j.sub_mchid} 已登记回调路由 seller=${(row.tb_address || "").slice(0, 14)}…`);
794
+ if (row.tb_address) {
795
+ const g = await notifyOracleGrant(deps, { tb_address: row.tb_address, license_number: snap.license_number });
796
+ if (g.ok) {
797
+ console.log(`[sp-applyment] FINISHED 钩子: oracle 入队确认 enqueued=${g.json && g.json.enqueued}`);
798
+ db.prepare("UPDATE sp_applyments SET hook_fired_at=? WHERE business_code=?").run(now, bc);
799
+ } else {
800
+ // oracle 暂时不可达: 不标 hook_fired_at, 下轮状态查询重试 (oracle 侧入队幂等, 重试安全)
801
+ console.warn(`[sp-applyment] FINISHED 钩子: oracle 入队未成功 status=${g.status} (下轮重试)`);
802
+ }
803
+ } else {
804
+ console.warn(`[sp-applyment] FINISHED 钩子: 台账无 tb_address bc=${bc}, 引导金无法定向 (人工补录)`);
805
+ db.prepare("UPDATE sp_applyments SET hook_fired_at=? WHERE business_code=?").run(now, bc);
806
+ }
807
+ } catch (e) {
808
+ console.warn("[sp-applyment] FINISHED 钩子失败 (下轮状态查询重试):", e && e.message);
809
+ }
810
+ }
811
+ }
721
812
  res.json({
722
813
  business_code: bc,
723
814
  status: state,
@@ -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
 
@@ -284,3 +284,73 @@ test("business_code 直通: 调用方带号 (卖家侧生成) → SP 不新生
284
284
  assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_applyments").get().n, 1, "同号覆盖不新增行");
285
285
  } finally { srv.close(); }
286
286
  });
287
+
288
+ test("M2: tb_address 落列 + submit 即入队 oracle + FINISHED 钩子 (upsert 路由表 + 幂等一次)", async () => {
289
+ // oracleFetch 注入捕获 (不打真 oracle)
290
+ const oracleCalls = [];
291
+ const oracleFetch = async (url, opts) => {
292
+ oracleCalls.push({ url, body: JSON.parse(opts.body) });
293
+ return { ok: true, status: 200, json: async () => ({ ok: true, enqueued: true }) };
294
+ };
295
+ const db = new Database(":memory:");
296
+ const app = express();
297
+ app.use(express.json());
298
+ app.use("/api/sp", spApplyment.createSpApplymentRouter({ db, oracleFetch }));
299
+ const srv = app.listen(0);
300
+ await new Promise((r) => srv.once("listening", r));
301
+ const base = `http://127.0.0.1:${srv.address().port}`;
302
+ const TB_ADDR = "tct1qpz7x0f2fakeselleraddress0000aaaa12";
303
+ try {
304
+ const form = await fillForm(base);
305
+ form.tb_address = TB_ADDR;
306
+ const r = await fetch(base + "/api/sp/applyment/submit", { method: "POST", headers: H, body: JSON.stringify(form) });
307
+ const j = await r.json();
308
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
309
+ // tb_address 落列
310
+ const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
311
+ assert.strictEqual(row.tb_address, TB_ADDR, "M2: tb_address 落台账");
312
+ // submit 成功即入队 oracle (feegrant 前置: 商户 0 余额可签 KYC)
313
+ assert.strictEqual(oracleCalls.length, 1, "submit 成功即入队 oracle");
314
+ assert.strictEqual(oracleCalls[0].body.recipient, TB_ADDR);
315
+ const expectCh = crypto.createHash("sha256").update("license:" + form.license_number).digest("hex");
316
+ assert.strictEqual(oracleCalls[0].body.credential_hash, expectCh, "锚点 sha256('license:'+信用代码) 对齐 c5-glue");
317
+ assert.ok(oracleCalls[0].url.endsWith("/api/oracle/bootstrap-grant"));
318
+
319
+ // FINISHED 钩子: upsert sp_sub_merchants + 再入队 (幂等兜底) + hook_fired_at
320
+ const s1 = await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
321
+ assert.strictEqual(s1.status, "APPLYMENT_STATE_FINISHED");
322
+ const mch = db.prepare("SELECT * FROM sp_sub_merchants WHERE sub_mchid = ?").get("1600000999");
323
+ assert.ok(mch, "回调路由表已 upsert (M3 生产写入修复)");
324
+ assert.strictEqual(mch.seller_address, TB_ADDR);
325
+ assert.strictEqual(mch.shop_name, form.merchant_shortname);
326
+ assert.strictEqual(mch.status, "active");
327
+ const row2 = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
328
+ assert.ok(row2.hook_fired_at, "hook_fired_at 已标记");
329
+ assert.strictEqual(oracleCalls.length, 2, "FINISHED 钩子再入队一次 (oracle 侧幂等兜底)");
330
+ assert.strictEqual(oracleCalls[1].body.recipient, TB_ADDR);
331
+ // 幂等: 再查一次状态, 钩子不重火
332
+ await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
333
+ assert.strictEqual(oracleCalls.length, 2, "FINISHED 钩子只触发一次");
334
+ } finally { srv.close(); }
335
+ });
336
+
337
+ test("M2: FINISHED 钩子在 oracle 不可达时不标 hook_fired_at (下轮重试)", async () => {
338
+ const oracleFetch = async () => { throw new Error("connect refused"); };
339
+ const db = new Database(":memory:");
340
+ const app = express();
341
+ app.use(express.json());
342
+ app.use("/api/sp", spApplyment.createSpApplymentRouter({ db, oracleFetch }));
343
+ const srv = app.listen(0);
344
+ await new Promise((r) => srv.once("listening", r));
345
+ const base = `http://127.0.0.1:${srv.address().port}`;
346
+ try {
347
+ const form = await fillForm(base);
348
+ form.tb_address = "tct1qpz7x0f2fakeselleraddress0000aaaa12";
349
+ const j = await (await fetch(base + "/api/sp/applyment/submit", { method: "POST", headers: H, body: JSON.stringify(form) })).json();
350
+ await (await fetch(base + "/api/sp/applyment/status/" + j.business_code, { headers: H })).json();
351
+ const row = db.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
352
+ assert.ok(!row.hook_fired_at, "oracle 不可达不标 hook (下轮重试)");
353
+ // 但回调路由表已 upsert (不依赖 oracle)
354
+ assert.ok(db.prepare("SELECT * FROM sp_sub_merchants WHERE sub_mchid='1600000999'").get(), "路由表仍落行");
355
+ } finally { srv.close(); }
356
+ });