trustbase-seller-backend 0.2.0 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -103,6 +103,14 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
103
103
  #apply-form .auploads{grid-template-columns:1fr}
104
104
  #apply-form .ascene-row{grid-template-columns:1fr}
105
105
  }
106
+ /* ===== 服务商名片卡 (2026-10-03 xiaomu: 入驻页显示服务商是谁) ===== */
107
+ .spcard{background:#FCFAF4;border:1px solid #EAE3D6;border-radius:14px;padding:16px 18px;margin-bottom:18px}
108
+ .spcard.offline{opacity:.65;background:#F4F1EA}
109
+ .spcard-h{display:flex;align-items:center;gap:8px;font-size:15px;color:#33302B}
110
+ .spbadge{background:#C4633C;color:#fff;font-size:11px;border-radius:999px;padding:2px 10px;margin-left:auto}
111
+ .spcard-desc{font-size:13px;color:#6B655B;margin-top:6px;line-height:1.7}
112
+ .spcard-foot{font-size:12px;color:#8A857B;margin-top:8px;line-height:1.9}
113
+ .spcard-foot a{color:#C4633C;text-decoration:none}
106
114
  @media print{
107
115
  body *{visibility:hidden}
108
116
  #print-area,#print-area *{visibility:visible}
@@ -116,6 +124,12 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
116
124
  <h1>TrustBase 商家入驻</h1>
117
125
  <p class="sub">在你的电脑上完成入驻,密钥永不出门</p>
118
126
 
127
+ <div class="spcard" id="spcard" style="display:none">
128
+ <div class="spcard-h"><span>🏪</span><span id="sp-name" style="font-weight:600">服务商</span><span class="spbadge" id="sp-rate" style="display:none"></span></div>
129
+ <div class="spcard-desc" id="sp-desc"></div>
130
+ <div class="spcard-foot" id="sp-foot"></div>
131
+ </div>
132
+
119
133
  <div class="progress" id="progress">
120
134
  <h3>入驻进度 <span id="status-meta" style="font-size:12px;color:#8A857B;font-weight:400"></span><button class="sbtn noprint" style="float:right;margin-top:-2px" onclick="loadStatus()">刷新状态</button></h3>
121
135
  <div class="step done" id="st-identity">
@@ -133,7 +147,6 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
133
147
  <div class="step" id="st-verification">
134
148
  <span class="mark">⬜</span>
135
149
  <div class="body">③ 微信收款认证
136
- <div class="ops"><button class="sbtn" id="btn-verify" onclick="submitVerification()" title="链上身份认证由服务商在进件审核通过后自动完成, 一般无需手动">提交实名认证 (一般无需手动)</button></div>
137
150
  <div class="desc" id="verify-desc">提交后由服务商审核微信特约商户收款账户; 审核通过后自动发放 100 TCT 引导金 (无需重复提交)</div>
138
151
  <div class="hintbox" id="verify-msg"></div>
139
152
  <div class="ops noprint"><button class="sbtn" id="btn-apply-toggle" onclick="toggleApplyForm()">填写进件资料并上传证件</button></div>
@@ -351,7 +364,7 @@ function renderProgress(s){
351
364
  // 其余 → 引导提交
352
365
  var vs=s.verification||{submitted:false,status:'none'};
353
366
  var qat=vs.queried_at?(' · 查询于 '+new Date(vs.queried_at*1000).toLocaleTimeString()):'';
354
- var chainTxt=vs.raw_status?('链上状态 '+vs.raw_status):'链上状态未知';
367
+ var chainTxt=vs.raw_status?('审核状态 '+vs.raw_status):'审核状态未知';
355
368
  var vStep=document.getElementById('st-verification');
356
369
  var ap=s.applyment||null;
357
370
  var apDone=!!(ap&&ap.done);
@@ -359,11 +372,10 @@ function renderProgress(s){
359
372
  var applyForm=document.getElementById('apply-form');
360
373
  if(apDone){
361
374
  vStep.className='step done'; vStep.querySelector('.mark').textContent='✅';
362
- document.getElementById('btn-verify').style.display='none';
363
375
  if(applyForm) applyForm.style.display='none';
364
376
  document.getElementById('verify-desc').textContent='微信收款进件已完成 (sub_mchid='+(ap.sub_mchid||'')+'), 收款通道就绪'+qat;
365
377
  } else if(kycOk){
366
- document.getElementById('verify-desc').textContent='链上身份认证已过 ✅ · 微信收款进件未完成 — 请填写下方进件资料, 微信审核通过后收款通道才开通'+qat;
378
+ document.getElementById('verify-desc').textContent='身份认证已通过 ✅ · 微信收款进件未完成 — 请填写下方进件资料, 微信审核通过后收款通道才开通'+qat;
367
379
  } else if(vs.submitted){
368
380
  document.getElementById('verify-desc').textContent='实名已提交, 等待服务商审核 ('+chainTxt+qat+')';
369
381
  } else {
@@ -646,22 +658,33 @@ function markStepDone(id){
646
658
  var el=document.getElementById(id);
647
659
  if(el){el.className='step done';var m=el.querySelector('.mark');if(m)m.textContent='✅'}
648
660
  }
649
- async function submitVerification(){
650
- var btn=document.getElementById('btn-verify');btn.disabled=true;btn.textContent='提交中...';
651
- var msg=document.getElementById('verify-msg');
661
+ // 服务商名片卡 (2026-10-03): /api/onboard/sp-info 代理 SP 网关公开名片; 离线降级灰色态
662
+ function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
663
+ async function loadSpInfo(){
664
+ var card=document.getElementById('spcard');
665
+ if(!card) return;
652
666
  try{
653
- var j=await readJson(await fetch('/onboard/submit-verification',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}));
654
- if(j.ok){
655
- msg.style.display='block';msg.textContent='✅ 实名已提交, 等待服务商审核, 通过后自动发放引导金'+(j.duplicate?' (此前已提交过, 幂等忽略'+(j.txhash?', tx '+String(j.txhash).slice(0,12)+'…':'')+')':'');
656
- loadStatus(); // 立刻拉一次链上状态, 不等 15s 轮询
657
- } else if(j.error==='module_not_live'){
658
- msg.style.display='block';msg.textContent='ℹ️ '+(j.message||'链上实名模块未上线')+' — 自动通道将在集群升级后开放, 当前请由服务商协助办理微信特约商户认证';
659
- } else {
660
- msg.style.display='block';msg.textContent='提交失败: '+(j.message||j.error||'未知错误');
661
- }
662
- }catch(e){msg.style.display='block';msg.textContent='提交失败: 服务暂时不可用,请刷新重试 ('+e.message+')'}
663
- btn.disabled=false;btn.textContent='提交实名认证';
667
+ var j=await readJson(await fetch('/api/onboard/sp-info'));
668
+ if(!j||!j.name) return;
669
+ card.style.display='block';
670
+ card.classList.toggle('offline', !!j.offline);
671
+ document.getElementById('sp-name').textContent=j.offline&&!j.stale?'服务商暂时不可达':j.name;
672
+ var rb=document.getElementById('sp-rate');
673
+ if(j.rate_pct){rb.style.display='';rb.textContent='抽佣 '+j.rate_pct;}else{rb.style.display='none';}
674
+ document.getElementById('sp-desc').textContent=j.offline&&!j.stale
675
+ ?'资料仍可填写,提交时会加密暂存本机,服务商恢复后可重试'
676
+ :(j.desc||'');
677
+ var foot=[];
678
+ if(j.official_url) foot.push('<a href="'+escHtml(j.official_url)+'" target="_blank">官网</a>');
679
+ if(j.contact) foot.push('客服: '+escHtml(j.contact));
680
+ if(j.chain_address) foot.push('服务商账号: <span style="font-family:Consolas,monospace;font-size:11px">'+escHtml(j.chain_address)+'</span>');
681
+ // 迁移保障 (核心卖点, 必须写; 面向商户禁用"链上"术语, 用大白话)
682
+ foot.push('服务商由你安装时选定,可随时更换 (你的资产保存在你自己的电脑上,不被锁定)');
683
+ if(j.offline&&j.stale) foot.push('当前离线, 展示缓存信息');
684
+ document.getElementById('sp-foot').innerHTML=foot.join(' · ');
685
+ }catch(e){ /* 名片接口挂了就保持隐藏, 不挡入驻主流程 */ }
664
686
  }
687
+ window.addEventListener('load',loadSpInfo);
665
688
  async function loadStatus(){
666
689
  var meta=document.getElementById('status-meta');
667
690
  try{
package/routes/onboard.js CHANGED
@@ -537,6 +537,29 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
537
537
  return s.length <= 4 ? "***" : "***" + s.slice(-4);
538
538
  }
539
539
 
540
+ /** GET /sp-info — 服务商名片代理 (2026-10-03): fetch SP 网关 /api/sp/info 透传,
541
+ * 60s 内存缓存 (页面 15s 轮询不刷 SP); SP 不可达 → 200 降级 offline 卡片 (不报错) */
542
+ let spInfoCache = { at: 0, data: null };
543
+ router.get("/sp-info", async (req, res) => {
544
+ const now = Date.now();
545
+ const ttl = Number(process.env.SP_INFO_CACHE_MS || 60000); // 默认 60s (测试可调)
546
+ if (spInfoCache.data && now - spInfoCache.at < ttl) return res.json(spInfoCache.data);
547
+ try {
548
+ const fetcher = deps.fetcher || globalThis.fetch;
549
+ const r = await fetcher(spGatewayUrl() + "/api/sp/info", { signal: AbortSignal.timeout(10000) });
550
+ const j = await r.json().catch(() => null);
551
+ if (r.ok && j && j.name) {
552
+ spInfoCache = { at: now, data: j };
553
+ return res.json(j);
554
+ }
555
+ throw new Error("bad_sp_info " + r.status);
556
+ } catch (e) {
557
+ // SP 不可达: 降级 offline (200 不报错, 页面显示灰色离线态; 有旧缓存先用旧的)
558
+ if (spInfoCache.data) return res.json({ ...spInfoCache.data, offline: true, stale: true });
559
+ return res.json({ name: "服务商暂时不可达", offline: true });
560
+ }
561
+ });
562
+
540
563
  /** POST /applyment/upload — 证件照片转发 SP 网关 media/upload (base64 JSON 同款) */
541
564
  router.post("/applyment/upload", async (req, res) => {
542
565
  try {
@@ -581,6 +581,23 @@ function createSpApplymentRouter(deps) {
581
581
  return true;
582
582
  }
583
583
 
584
+ /** GET /info — 服务商公开名片 (2026-10-03 xiaomu: 卖家入驻页要显示服务商是谁)
585
+ * 公开只读, 不要 admin key — 名片本来就是给商户看的公开信息 */
586
+ router.get("/info", (req, res) => {
587
+ const rateBp = Number(process.env.SP_DEFAULT_COMMISSION_BP || 100); // 默认 1%
588
+ const bp = Number.isInteger(rateBp) && rateBp >= 0 ? rateBp : 100;
589
+ res.json({
590
+ name: process.env.SP_NAME || "TrustBase 官方服务商",
591
+ desc: process.env.SP_DESC || "",
592
+ official_url: process.env.SP_OFFICIAL_URL || "",
593
+ contact: process.env.SP_CONTACT || "",
594
+ commission_rate_bp: bp,
595
+ rate_pct: (bp / 100).toFixed(2) + "%",
596
+ chain_address: process.env.SP_CHAIN_ADDR || "",
597
+ gateway: String(req.headers.host || ""), // 本网关域名 (从请求 host 推断)
598
+ });
599
+ });
600
+
584
601
  /** GET /apply — 手机单页表单 (页面本身不鉴权, API 全部要 key) */
585
602
  router.get("/apply", (req, res) => {
586
603
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -41,11 +41,16 @@ async function startFakeSp() {
41
41
  app.use(express.json());
42
42
  // 凭证校验 (卖家带 x-sp-admin-key = SP_MERCHANT_TOKEN)
43
43
  app.use((req, res, next) => {
44
+ if (req.path === "/api/sp/info") return next();
44
45
  if (req.headers["x-sp-admin-key"] !== MERCHANT_TOKEN) {
45
46
  return res.status(401).json({ error: "unauthorized", message: "x-sp-admin-key 无效" });
46
47
  }
47
48
  next();
48
49
  });
50
+ app.get("/api/sp/info", (req, res) => {
51
+ spCapture.infoQueries = (spCapture.infoQueries || 0) + 1;
52
+ res.json({ name: "无锡小播鼠网络科技", desc: "TrustBase 官方支付服务商", commission_rate_bp: 100, rate_pct: "1.00%", gateway: "fake-sp" });
53
+ });
49
54
  app.post("/api/sp/applyment/upload", (req, res) => {
50
55
  spCapture.uploads.push(req.body);
51
56
  res.json({ media_id: "FAKE_MEDIA_" + crypto.randomBytes(6).toString("hex") });
@@ -521,3 +526,41 @@ test("F4: 企业主体 id_card_address 必填 + 本机加密 + SP 组装进 id_c
521
526
  assert.ok(!("id_card_address" in wxBodyInd.subject_info.identity_info.id_card_info), "个体户无 id_card_address");
522
527
  } finally { srv.close(); }
523
528
  });
529
+
530
+ test("sp-info 代理: 透传 SP 名片 + 60s 缓存 + SP 不可达降级", async () => {
531
+ const { srv, base } = await startSeller();
532
+ try {
533
+ // 透传
534
+ let j = await (await fetch(base + "/api/onboard/sp-info")).json();
535
+ assert.strictEqual(j.name, "无锡小播鼠网络科技");
536
+ assert.strictEqual(j.rate_pct, "1.00%");
537
+ const q1 = spCapture.infoQueries;
538
+ // 缓存: 第二次不打 SP
539
+ j = await (await fetch(base + "/api/onboard/sp-info")).json();
540
+ assert.strictEqual(spCapture.infoQueries, q1, "60s 缓存生效, 不重复打 SP");
541
+ // SP 挂了: 缓存已过期 (测试把 TTL 调成 1ms) → offline+stale 展示缓存
542
+ process.env.SP_INFO_CACHE_MS = "1";
543
+ await new Promise((r) => setTimeout(r, 5));
544
+ process.env.SP_GATEWAY_URL = "http://127.0.0.1:1";
545
+ j = await (await fetch(base + "/api/onboard/sp-info")).json();
546
+ assert.strictEqual(j.offline, true);
547
+ assert.strictEqual(j.stale, true);
548
+ assert.strictEqual(j.name, "无锡小播鼠网络科技", "stale 缓存仍给名字");
549
+ } finally { srv.close(); process.env.SP_GATEWAY_URL = spBase; delete process.env.SP_INFO_CACHE_MS; }
550
+ });
551
+
552
+ test("sp-info 代理: 全新实例 SP 不可达 → 200 降级 offline (不报错)", async () => {
553
+ process.env.SP_GATEWAY_URL = "http://127.0.0.1:1";
554
+ const { srv, base } = await startSeller();
555
+ try {
556
+ const r = await fetch(base + "/api/onboard/sp-info");
557
+ const j = await r.json();
558
+ assert.strictEqual(r.status, 200, "降级不报错");
559
+ assert.strictEqual(j.offline, true);
560
+ assert.ok(j.name.includes("不可达"), j.name);
561
+ // 页面含服务商卡片元素
562
+ const html = await (await fetch(base + "/onboard/")).text();
563
+ assert.ok(html.includes("spcard") && html.includes("sp-info"), "服务商卡片 + 数据源");
564
+ assert.ok(html.includes("可随时更换"), "迁移保障卖点文案");
565
+ } finally { srv.close(); process.env.SP_GATEWAY_URL = spBase; }
566
+ });
@@ -219,6 +219,38 @@ test("status 查询 mock: FINISHED + sign_url + sub_mchid, 台账同步", async
219
219
  } finally { srv.close(); }
220
220
  });
221
221
 
222
+ test("GET /api/sp/info 公开名片: 无 admin key 200 + env 覆盖生效", async () => {
223
+ const { srv, base } = await startServer();
224
+ try {
225
+ // 默认 (无 key 也要 200 — 名片是公开信息)
226
+ let r = await fetch(base + "/api/sp/info");
227
+ let j = await r.json();
228
+ assert.strictEqual(r.status, 200, "公开端点不要 admin key");
229
+ assert.strictEqual(j.name, "TrustBase 官方服务商");
230
+ assert.strictEqual(j.commission_rate_bp, 100, "默认 1%");
231
+ assert.strictEqual(j.rate_pct, "1.00%");
232
+ assert.ok(j.gateway.includes("127.0.0.1"), "gateway 从 req host 推断");
233
+ // env 覆盖
234
+ process.env.SP_NAME = "无锡小播鼠网络科技";
235
+ process.env.SP_DESC = "TrustBase 官方支付服务商";
236
+ process.env.SP_OFFICIAL_URL = "https://trustbase.example.com";
237
+ process.env.SP_CONTACT = "微信: xiaomu";
238
+ process.env.SP_DEFAULT_COMMISSION_BP = "150";
239
+ process.env.SP_CHAIN_ADDR = "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7";
240
+ r = await fetch(base + "/api/sp/info");
241
+ j = await r.json();
242
+ assert.strictEqual(j.name, "无锡小播鼠网络科技");
243
+ assert.strictEqual(j.contact, "微信: xiaomu");
244
+ assert.strictEqual(j.commission_rate_bp, 150);
245
+ assert.strictEqual(j.rate_pct, "1.50%");
246
+ assert.strictEqual(j.chain_address, "tct1zg7wa9v2gkwp3yvmxrjlmwgfu0tw9vwcuhwwg7");
247
+ } finally {
248
+ srv.close();
249
+ 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.SP_DEFAULT_COMMISSION_BP; delete process.env.SP_CHAIN_ADDR;
251
+ }
252
+ });
253
+
222
254
  test("business_code 直通: 调用方带号 (卖家侧生成) → SP 不新生成; REJECTED 同号重提", async () => {
223
255
  const { db, srv, base } = await startServer();
224
256
  try {