trustbase-seller-backend 0.2.0 → 0.2.2
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/routes/onboard.html +45 -19
- package/routes/onboard.js +31 -4
- package/routes/sp-applyment.js +17 -0
- package/test-onboard-applyment.js +43 -0
- package/test-sp-applyment.js +32 -0
package/package.json
CHANGED
package/routes/onboard.html
CHANGED
|
@@ -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?('
|
|
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='
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
661
|
+
// 服务商名片卡 (2026-10-03): /api/onboard/sp-info 代理 SP 网关公开名片; 离线降级灰色态
|
|
662
|
+
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
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/
|
|
654
|
-
if(j.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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{
|
|
@@ -685,7 +708,10 @@ async function submit(){
|
|
|
685
708
|
document.getElementById('r-addr').textContent=j.tb_address;
|
|
686
709
|
document.getElementById('result').style.display='block';
|
|
687
710
|
document.getElementById('status').className='status-ok';
|
|
688
|
-
|
|
711
|
+
// 2026-09-26 资产安全: 助记词只在首次创建返回; 已有身份时 j.mnemonic 为空
|
|
712
|
+
document.getElementById('status').textContent=j.identity_created===false
|
|
713
|
+
? '✅ 卖家身份已存在 (助记词仅首次创建时展示过)'
|
|
714
|
+
: '✅ 卖家身份已生成 (密钥已保存在本机 tb-keyring/)';
|
|
689
715
|
if(j.mnemonic){
|
|
690
716
|
_mn=j.mnemonic;
|
|
691
717
|
var words=j.mnemonic.split(/\s+/);
|
package/routes/onboard.js
CHANGED
|
@@ -211,17 +211,21 @@ function createOnboardRouter(deps) {
|
|
|
211
211
|
db.prepare("INSERT INTO onboard_merchants (merchant_shortname, mobile_phone, category, tb_address, sub_mchid, created_at) VALUES (?,?,?,?,?,?)")
|
|
212
212
|
.run(String(b.merchant_shortname), String(b.mobile_phone || ""), String(b.category || ""), identity.address, subMchid, Math.floor(Date.now() / 1000));
|
|
213
213
|
console.log("[onboard] merchant:", b.merchant_shortname, "addr:", identity.address, identity.created ? "(新身份)" : "(已有身份)");
|
|
214
|
-
//
|
|
214
|
+
// 助记词只在首次创建身份时返回一次 (2026-09-26 资产安全修复):
|
|
215
|
+
// 已有身份时绝不回吐 — 本端点无鉴权, 重复调用返助记词 = LAN 任何人可拿走商户资产。
|
|
216
|
+
// 已有身份后重复提交幂等返回 (不含任何敏感信息)。
|
|
215
217
|
res.json({
|
|
216
218
|
ok: true,
|
|
217
219
|
sub_mchid: subMchid,
|
|
218
220
|
tb_address: identity.address,
|
|
219
|
-
mnemonic: identity.mnemonic,
|
|
221
|
+
...(identity.created ? { mnemonic: identity.mnemonic } : {}),
|
|
220
222
|
identity_created: identity.created,
|
|
221
223
|
grant_tct: 100,
|
|
222
224
|
shop_base_url: shopBaseUrl(req),
|
|
223
|
-
next_step:
|
|
224
|
-
|
|
225
|
+
next_step: identity.created
|
|
226
|
+
? "下一步: 抄下助记词 (纸密钥), 然后上架商品或完成微信认证领 100 TCT 引导金"
|
|
227
|
+
: "身份已存在 (助记词仅创建时展示过一次), 继续入驻流程即可",
|
|
228
|
+
message: identity.created ? "卖家身份已生成,完成微信认证后发放引导金" : "卖家身份已存在",
|
|
225
229
|
});
|
|
226
230
|
} catch (e) {
|
|
227
231
|
console.error("[onboard] error:", e && e.message);
|
|
@@ -537,6 +541,29 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
|
|
|
537
541
|
return s.length <= 4 ? "***" : "***" + s.slice(-4);
|
|
538
542
|
}
|
|
539
543
|
|
|
544
|
+
/** GET /sp-info — 服务商名片代理 (2026-10-03): fetch SP 网关 /api/sp/info 透传,
|
|
545
|
+
* 60s 内存缓存 (页面 15s 轮询不刷 SP); SP 不可达 → 200 降级 offline 卡片 (不报错) */
|
|
546
|
+
let spInfoCache = { at: 0, data: null };
|
|
547
|
+
router.get("/sp-info", async (req, res) => {
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
const ttl = Number(process.env.SP_INFO_CACHE_MS || 60000); // 默认 60s (测试可调)
|
|
550
|
+
if (spInfoCache.data && now - spInfoCache.at < ttl) return res.json(spInfoCache.data);
|
|
551
|
+
try {
|
|
552
|
+
const fetcher = deps.fetcher || globalThis.fetch;
|
|
553
|
+
const r = await fetcher(spGatewayUrl() + "/api/sp/info", { signal: AbortSignal.timeout(10000) });
|
|
554
|
+
const j = await r.json().catch(() => null);
|
|
555
|
+
if (r.ok && j && j.name) {
|
|
556
|
+
spInfoCache = { at: now, data: j };
|
|
557
|
+
return res.json(j);
|
|
558
|
+
}
|
|
559
|
+
throw new Error("bad_sp_info " + r.status);
|
|
560
|
+
} catch (e) {
|
|
561
|
+
// SP 不可达: 降级 offline (200 不报错, 页面显示灰色离线态; 有旧缓存先用旧的)
|
|
562
|
+
if (spInfoCache.data) return res.json({ ...spInfoCache.data, offline: true, stale: true });
|
|
563
|
+
return res.json({ name: "服务商暂时不可达", offline: true });
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
540
567
|
/** POST /applyment/upload — 证件照片转发 SP 网关 media/upload (base64 JSON 同款) */
|
|
541
568
|
router.post("/applyment/upload", async (req, res) => {
|
|
542
569
|
try {
|
package/routes/sp-applyment.js
CHANGED
|
@@ -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
|
+
});
|
package/test-sp-applyment.js
CHANGED
|
@@ -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 {
|