trustbase-seller-backend 0.3.8 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -292,6 +292,23 @@ function buildMultipart(filename, fileBuf, metaStr) {
292
292
  return { boundary, body: Buffer.concat([Buffer.from(head, "utf8"), fileBuf, Buffer.from(tail, "utf8")]) };
293
293
  }
294
294
 
295
+ /** 服务商配置档案 (sp_profile 键值表, /sp/config 配置页的数据源; 读表优先 env 兜底) */
296
+ function ensureSpProfile(db) {
297
+ db.exec(`CREATE TABLE IF NOT EXISTS sp_profile (
298
+ key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
299
+ }
300
+ /** 读配置: 表里有值用表, 否则 undefined (调用方回落 env) */
301
+ function spProfileGet(db, key) {
302
+ ensureSpProfile(db);
303
+ const row = db.prepare("SELECT value FROM sp_profile WHERE key = ?").get(key);
304
+ return row && row.value !== "" ? row.value : undefined;
305
+ }
306
+ function spProfileSet(db, key, value) {
307
+ ensureSpProfile(db);
308
+ db.prepare("INSERT INTO sp_profile (key, value, updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at")
309
+ .run(key, String(value), nowSec());
310
+ }
311
+
295
312
  /** 服务商应用素材: 表优先, env SP_APP_ASSETS_JSON ({home,tail,inner,pay} 各 media_id) 兜底 */
296
313
  function readAppAssets(db) {
297
314
  try {
@@ -312,6 +329,229 @@ function readAppAssets(db) {
312
329
  } catch (_) { return {}; }
313
330
  }
314
331
 
332
+ /** sp-config 段 (2026-10-03): /sp/config 服务商配置页 + GET/PUT /api/sp/config + 素材删除。
333
+ * 页面极简卡片风 (设计规范), 一屏三件事: 应用配置 / 服务商名片 / 状态总览。 */
334
+
335
+ /** 配齐度计算 (AppID / 4 素材 / 名片 / 费率 四块) */
336
+ function configCompleteness(db) {
337
+ const appid = spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "";
338
+ const assets = readAppAssets(db);
339
+ const name = spProfileGet(db, "sp_name") || process.env.SP_NAME || "";
340
+ const contact = spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "";
341
+ const KIND_LABEL = { home: "首页截图", tail: "尾页截图", inner: "应用内截图", pay: "支付页截图" };
342
+ const missing = [];
343
+ if (!appid) missing.push("AppID");
344
+ for (const k of ["home", "tail", "inner", "pay"]) if (!assets[k]) missing.push(KIND_LABEL[k]);
345
+ if (!name) missing.push("服务商名称");
346
+ if (!contact) missing.push("客服联系方式");
347
+ const assetCount = ["home", "tail", "inner", "pay"].filter((k) => assets[k]).length;
348
+ return {
349
+ appid: !!appid,
350
+ assets: assetCount, // 0-4
351
+ profile: !!(name && contact),
352
+ rate: true, // getDefaultRateBp 总有兜底
353
+ complete: missing.length === 0,
354
+ missing,
355
+ };
356
+ }
357
+
358
+ /** 当前全量配置 (表优先 env 兜底) */
359
+ function currentConfig(db) {
360
+ const bp = spCommission.getDefaultRateBp(db);
361
+ return {
362
+ app_appid: spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "",
363
+ sp_name: spProfileGet(db, "sp_name") || process.env.SP_NAME || "",
364
+ sp_desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
365
+ sp_official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
366
+ sp_contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
367
+ commission_bp: bp,
368
+ rate_pct: (bp / 100).toFixed(2) + "%",
369
+ assets: readAppAssets(db),
370
+ };
371
+ }
372
+
373
+ const CONFIG_PAGE_HTML = `<!DOCTYPE html>
374
+ <html lang="zh-CN">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>服务商配置 · TrustBase SP</title>
379
+ <style>
380
+ *{margin:0;padding:0;box-sizing:border-box}
381
+ body{background:#F7F3EB;color:#33302B;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;line-height:1.7}
382
+ .wrap{max-width:640px;margin:0 auto;padding:20px 16px 60px}
383
+ h1{font-size:20px;margin-bottom:4px}
384
+ .sub{font-size:13px;color:#8A857B;margin-bottom:16px}
385
+ .chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
386
+ .chip{font-size:12px;border-radius:999px;padding:5px 12px;border:1px solid #EAE3D6;background:#FCFAF4;color:#8A857B}
387
+ .chip.ok{background:#F0FDF4;border-color:#BBF7D0;color:#166534}
388
+ .box{background:#FCFAF4;border:1px solid #EAE3D6;border-radius:14px;padding:18px;margin-bottom:14px}
389
+ .box h3{font-size:15px;color:#C4633C;margin-bottom:10px}
390
+ label{display:block;font-size:13px;color:#6B655B;margin:10px 0 4px}
391
+ input{width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff}
392
+ input:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px rgba(196,99,60,.15)}
393
+ .cur{font-size:12px;color:#8A857B;margin-top:3px;word-break:break-all}
394
+ .btn{display:inline-block;background:#C4633C;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:15px;cursor:pointer;margin-top:12px}
395
+ .btn:disabled{background:#ccc}
396
+ .slots{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px}
397
+ .slot{border:2px dashed #D4CDBF;border-radius:10px;background:#FFFDF9;padding:10px;text-align:center;font-size:12px;color:#6B655B}
398
+ .slot .t{font-weight:600;color:#33302B;margin-bottom:6px}
399
+ .slot img{max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px}
400
+ .slot .mid{font-size:11px;color:#16A34A;word-break:break-all;margin-bottom:6px}
401
+ .upbtn{display:inline-block;background:#fff;border:1px solid #C4633C;color:#C4633C;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;margin:2px}
402
+ .upbtn.del{color:#DC2626;border-color:#FCA5A5}
403
+ .stat{font-size:13px;margin-top:10px;padding:10px 12px;border-radius:8px}
404
+ .stat.ok{background:#F0FDF4;border:1px solid #BBF7D0;color:#166534}
405
+ .stat.miss{background:#FFFBEB;border:1px solid #FDE68A;color:#92400E}
406
+ .msg{font-size:13px;margin-top:8px}
407
+ .msg.err{color:#DC2626}.msg.ok{color:#16A34A}
408
+ @media (max-width:640px){.slots{grid-template-columns:1fr}}
409
+ </style>
410
+ </head>
411
+ <body>
412
+ <div class="wrap">
413
+ <h1>服务商配置</h1>
414
+ <div class="sub">TrustBase 服务商运营台 · 应用/名片/状态 一屏三件事</div>
415
+
416
+ <div class="chips" id="chips"></div>
417
+
418
+ <div class="box">
419
+ <h3>① 应用配置 (商户 APP 场景进件零自备)</h3>
420
+ <label>服务商应用 AppID</label>
421
+ <input id="appid" placeholder="wx 开头 (微信开放平台注册的应用)">
422
+ <div class="cur" id="appid-cur"></div>
423
+ <button class="btn" onclick="saveAppid()">保存 AppID</button>
424
+ <label style="margin-top:14px">应用截图素材 (4 张)</label>
425
+ <div class="slots" id="slots"></div>
426
+ <div class="stat" id="app-stat"></div>
427
+ <div class="msg" id="app-msg"></div>
428
+ </div>
429
+
430
+ <div class="box">
431
+ <h3>② 服务商名片 (商户入驻页展示)</h3>
432
+ <label>名称</label><input id="p_name" placeholder="如: 无锡小播鼠网络科技">
433
+ <label>简介</label><input id="p_desc" placeholder="一句话介绍">
434
+ <label>官网</label><input id="p_url" placeholder="https://">
435
+ <label>客服联系方式</label><input id="p_contact" placeholder="微信/电话/邮箱">
436
+ <label>默认抽佣费率 (bp, 100 = 1.00%)</label><input id="p_bp" type="number" min="0" max="3000">
437
+ <div class="cur" id="bp-cur"></div>
438
+ <button class="btn" onclick="saveProfile()">保存名片</button>
439
+ <div class="msg" id="p-msg"></div>
440
+ </div>
441
+ </div>
442
+
443
+ <script>
444
+ var KINDS=[['home','首页'],['tail','尾页'],['inner','应用内'],['pay','支付页']];
445
+ var CFG=null;
446
+ function adminKey(){
447
+ var k=localStorage.getItem('spAdminKey')||'';
448
+ if(!k){k=prompt('请输入管理密钥 (x-sp-admin-key)')||''; if(k)localStorage.setItem('spAdminKey',k);}
449
+ return k;
450
+ }
451
+ function api(path,opts){
452
+ opts=opts||{};
453
+ opts.headers=Object.assign({'Content-Type':'application/json','Accept':'application/json','x-sp-admin-key':adminKey()},opts.headers||{});
454
+ return fetch(path,opts).then(function(r){
455
+ if(r.status===401){localStorage.removeItem('spAdminKey');}
456
+ return r.json().then(function(j){return{status:r.status,json:j};});
457
+ });
458
+ }
459
+ function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;');}
460
+ function maskMid(m){m=String(m||'');return m.length>16?m.slice(0,12)+'…'+m.slice(-4):m;}
461
+
462
+ function render(){
463
+ var c=CFG, comp=c.completeness;
464
+ // 状态总览
465
+ document.getElementById('chips').innerHTML=
466
+ '<span class="chip '+(comp.appid?'ok':'')+'">'+(comp.appid?'✓':'○')+' AppID</span>'
467
+ +'<span class="chip '+(comp.assets===4?'ok':'')+'">'+(comp.assets===4?'✓':'○')+' 素材 '+comp.assets+'/4</span>'
468
+ +'<span class="chip '+(comp.profile?'ok':'')+'">'+(comp.profile?'✓':'○')+' 名片</span>'
469
+ +'<span class="chip '+(comp.rate?'ok':'')+'">'+(comp.rate?'✓':'○')+' 费率</span>';
470
+ // ① 应用配置
471
+ document.getElementById('appid').value=c.app_appid||'';
472
+ document.getElementById('appid-cur').textContent=c.app_appid?('当前: '+c.app_appid):'未配置';
473
+ document.getElementById('slots').innerHTML=KINDS.map(function(kk){
474
+ var mid=c.assets[kk[0]];
475
+ return '<div class="slot"><div class="t">'+kk[1]+'</div>'
476
+ +(mid?'<div class="mid">✓ 已上传 ('+esc(maskMid(mid))+')</div>':'')
477
+ +'<img id="img-'+kk[0]+'" style="display:none">'
478
+ +'<label class="upbtn">'+(mid?'替换':'点击上传')+'<input type="file" accept="image/*" style="display:none" onchange="upAsset(event,\x27'+kk[0]+'\x27)"></label>'
479
+ +(mid?'<button class="upbtn del" onclick="delAsset(\x27'+kk[0]+'\x27)">删除</button>':'')
480
+ +'</div>';
481
+ }).join('');
482
+ var st=document.getElementById('app-stat');
483
+ if(comp.appid&&comp.assets===4){st.className='stat ok';st.textContent='✓ 已配齐,商户进件自动进入零自备模式';}
484
+ else{st.className='stat miss';st.textContent='还差 '+comp.missing.length+' 项:'+comp.missing.join('、');}
485
+ // ② 名片
486
+ document.getElementById('p_name').value=c.sp_name||'';
487
+ document.getElementById('p_desc').value=c.sp_desc||'';
488
+ document.getElementById('p_url').value=c.sp_official_url||'';
489
+ document.getElementById('p_contact').value=c.sp_contact||'';
490
+ document.getElementById('p_bp').value=c.commission_bp;
491
+ document.getElementById('bp-cur').textContent='当前: '+c.commission_bp+' bp ('+c.rate_pct+')';
492
+ }
493
+ async function load(){
494
+ var r=await api('/api/sp/config');
495
+ if(r.status!==200){alert(r.json.message||'加载失败');return;}
496
+ CFG=r.json; render();
497
+ }
498
+ async function saveAppid(){
499
+ var r=await api('/api/sp/config',{method:'PUT',body:JSON.stringify({app_appid:document.getElementById('appid').value.trim()})});
500
+ var m=document.getElementById('app-msg');
501
+ m.className='msg '+(r.json.ok?'ok':'err'); m.textContent=r.json.ok?'✓ 已保存':(r.json.message||'保存失败');
502
+ if(r.json.ok) load();
503
+ }
504
+ async function saveProfile(){
505
+ var bp=Number(document.getElementById('p_bp').value);
506
+ var r=await api('/api/sp/config',{method:'PUT',body:JSON.stringify({
507
+ sp_name:document.getElementById('p_name').value.trim(),
508
+ sp_desc:document.getElementById('p_desc').value.trim(),
509
+ sp_official_url:document.getElementById('p_url').value.trim(),
510
+ sp_contact:document.getElementById('p_contact').value.trim(),
511
+ commission_bp:bp})});
512
+ var m=document.getElementById('p-msg');
513
+ m.className='msg '+(r.json.ok?'ok':'err'); m.textContent=r.json.ok?'✓ 已保存 (商户端 /api/sp/info 立即生效)':(r.json.message||'保存失败');
514
+ if(r.json.ok) load();
515
+ }
516
+ function shrink(file,cb){
517
+ var rd=new FileReader();
518
+ rd.onload=function(){
519
+ var img=new Image();
520
+ img.onload=function(){
521
+ var max=1600,w=img.width,h=img.height;
522
+ if(Math.max(w,h)>max){var k=max/Math.max(w,h);w=Math.round(w*k);h=Math.round(h*k);}
523
+ var cv=document.createElement('canvas');cv.width=w;cv.height=h;
524
+ cv.getContext('2d').drawImage(img,0,0,w,h);
525
+ cb(null,cv.toDataURL('image/jpeg',0.85).split(',')[1]);
526
+ };
527
+ img.src=rd.result;
528
+ };
529
+ rd.readAsDataURL(file);
530
+ }
531
+ async function upAsset(ev,kind){
532
+ var file=ev.target.files&&ev.target.files[0];
533
+ if(!file) return;
534
+ var m=document.getElementById('app-msg');
535
+ m.className='msg';m.textContent='上传中...';
536
+ shrink(file,async function(err,b64){
537
+ if(err){m.className='msg err';m.textContent=err.message;return;}
538
+ var assets={}; assets[kind]={filename:kind+'.jpg',content_b64:b64};
539
+ var r=await api('/api/sp/app-assets',{method:'POST',body:JSON.stringify({assets:assets})});
540
+ m.className='msg '+(r.json.ok?'ok':'err');
541
+ m.textContent=r.json.ok?'✓ 已上传':(r.json.message||'上传失败');
542
+ if(r.json.ok) load();
543
+ });
544
+ }
545
+ async function delAsset(kind){
546
+ if(!confirm('删除这张素材图?')) return;
547
+ var r=await api('/api/sp/app-assets/'+kind,{method:'DELETE'});
548
+ if(r.json.ok) load();
549
+ }
550
+ adminKey(); load();
551
+ </script>
552
+ </body>
553
+ </html>`;
554
+
315
555
  /** mock 微信应答 (SP_APPLYMENT_MOCK=1; 不碰真 API) */
316
556
  function mockWx(method, urlPath) {
317
557
  if (method === "POST" && urlPath === "/v3/merchant/media/upload") {
@@ -690,17 +930,18 @@ function createSpApplymentRouter(deps) {
690
930
  * M3 🟡-1: 公示比例读 sp_commission_rates 默认行 (__default__) 优先, env 只作首次种子 */
691
931
  router.get("/info", (req, res) => {
692
932
  const bp = spCommission.getDefaultRateBp(db);
933
+ // 2026-10-03 配置页落地: 名片/AppID 读 sp_profile 表优先 (env 兜底) — 配置页保存即生效
693
934
  res.json({
694
- name: process.env.SP_NAME || "TrustBase 官方服务商",
695
- desc: process.env.SP_DESC || "",
696
- official_url: process.env.SP_OFFICIAL_URL || "",
697
- contact: process.env.SP_CONTACT || "",
935
+ name: spProfileGet(db, "sp_name") || process.env.SP_NAME || "TrustBase 官方服务商",
936
+ desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
937
+ official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
938
+ contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
698
939
  commission_rate_bp: bp,
699
940
  rate_pct: (bp / 100).toFixed(2) + "%",
700
941
  chain_address: process.env.SP_CHAIN_ADDR || "",
701
942
  gateway: String(req.headers.host || ""), // 本网关域名 (从请求 host 推断)
702
- // 普通商户 APP 场景零自备: 服务商应用 AppID + 应用截图素材 (sp_app_assets 表优先, env 兜底)
703
- app_appid: process.env.SP_APP_APPID || "",
943
+ // 普通商户 APP 场景零自备: 服务商应用 AppID + 应用截图素材 (sp_profile/素材表优先, env 兜底)
944
+ app_appid: spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "",
704
945
  app_screenshots: readAppAssets(db),
705
946
  });
706
947
  });
@@ -771,6 +1012,62 @@ function createSpApplymentRouter(deps) {
771
1012
  }
772
1013
  });
773
1014
 
1015
+ /** GET/PUT /config — 服务商配置 (admin)。浏览器 (Accept: text/html) → /sp/config 配置页;
1016
+ * API (Accept: application/json) → 全量配置 JSON (表优先 env 兜底 + 配齐度) */
1017
+ router.get("/config", (req, res) => {
1018
+ const wantsHtml = String(req.headers.accept || "").includes("text/html");
1019
+ if (wantsHtml) { // 页面本身不鉴权 (admin key 在页面内输入, 所有 API 都要 key — 同 /sp/apply 口径)
1020
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1021
+ return res.send(CONFIG_PAGE_HTML);
1022
+ }
1023
+ if (!checkAdmin(req, res)) return;
1024
+ res.json({ ok: true, ...currentConfig(db), completeness: configCompleteness(db) });
1025
+ });
1026
+ router.put("/config", (req, res) => {
1027
+ if (!checkAdmin(req, res)) return;
1028
+ try {
1029
+ const b = req.body || {};
1030
+ const KEYMAP = { app_appid: "app_appid", sp_name: "sp_name", sp_desc: "sp_desc", sp_official_url: "sp_official_url", sp_contact: "sp_contact" };
1031
+ const saved = {};
1032
+ for (const k of Object.keys(KEYMAP)) {
1033
+ if (b[k] !== undefined) {
1034
+ const v = String(b[k]).trim().slice(0, 200);
1035
+ spProfileSet(db, KEYMAP[k], v);
1036
+ saved[k] = v;
1037
+ }
1038
+ }
1039
+ if (b.commission_bp !== undefined) {
1040
+ const bp = Number(b.commission_bp);
1041
+ if (!Number.isInteger(bp) || bp < 0 || bp > spCommission.MAX_RATE_BP) {
1042
+ throw new HttpError(422, "rate_out_of_range", `commission_bp 必须是 0..${spCommission.MAX_RATE_BP} 的整数 (万分比)`);
1043
+ }
1044
+ // 费率写 sp_commission_rates 默认行 (__default__) — /api/sp/info 的 getDefaultRateBp 单源
1045
+ spCommission.ensureCommissionTables(db);
1046
+ const now = nowSec();
1047
+ db.prepare(`INSERT INTO sp_commission_rates (sub_mchid, provider, rate_bp, updated_by, created_at, updated_at)
1048
+ VALUES ('__default__','',?,'config-page',?,?)
1049
+ ON CONFLICT(sub_mchid) DO UPDATE SET rate_bp=excluded.rate_bp, updated_by=excluded.updated_by, updated_at=excluded.updated_at`)
1050
+ .run(bp, now, now);
1051
+ saved.commission_bp = bp;
1052
+ }
1053
+ if (!Object.keys(saved).length) throw new HttpError(400, "invalid_param", "没有可保存的字段");
1054
+ console.log(`[sp-applyment] 配置已保存 keys=${Object.keys(saved).join(",")}`);
1055
+ res.json({ ok: true, saved, ...currentConfig(db), completeness: configCompleteness(db) });
1056
+ } catch (e) {
1057
+ return chainWrites.handleError(res, e, "/api/sp/config");
1058
+ }
1059
+ });
1060
+ /** DELETE /app-assets/:kind — 删除一张应用素材 (admin; 配置页"删除"按钮) */
1061
+ router.delete("/app-assets/:kind", (req, res) => {
1062
+ if (!checkAdmin(req, res)) return;
1063
+ const kind = String(req.params.kind || "");
1064
+ if (!["home", "tail", "inner", "pay"].includes(kind)) {
1065
+ return res.status(400).json({ ok: false, error: "invalid_param", message: "kind 必须是 home/tail/inner/pay" });
1066
+ }
1067
+ db.prepare("DELETE FROM sp_app_assets WHERE kind = ?").run(kind);
1068
+ res.json({ ok: true, assets: readAppAssets(db) });
1069
+ });
1070
+
774
1071
  /** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
775
1072
  * 幂等/重提 (2026-10-03 F1/F3 fix):
776
1073
  * - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
@@ -425,3 +425,69 @@ test("M4-APP: /info 带 app 配置 (env + 素材表) + POST /app-assets 上传
425
425
  delete process.env.SP_APP_APPID; delete process.env.SP_APP_ASSETS_JSON;
426
426
  }
427
427
  });
428
+
429
+ test("SP 配置页后端: config 读写 (表优先 env) + 配齐度 + info 立即生效 + 素材删除", async () => {
430
+ const { srv, base } = await startServer();
431
+ try {
432
+ // 无 key → 401
433
+ let r = await fetch(base + "/api/sp/config", { headers: { Accept: "application/json" } });
434
+ assert.strictEqual(r.status, 401);
435
+ // 页面: Accept text/html 无 key 也 200 (admin key 页面内输入)
436
+ r = await fetch(base + "/sp/config", { headers: { Accept: "text/html" } });
437
+ assert.strictEqual(r.status, 200);
438
+ assert.ok((await r.text()).includes("服务商配置"));
439
+ // 读默认 (env 兜底)
440
+ let j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
441
+ assert.strictEqual(j.ok, true);
442
+ assert.strictEqual(j.app_appid, "");
443
+ assert.strictEqual(j.completeness.appid, false);
444
+ assert.ok(j.completeness.missing.includes("AppID"));
445
+ // PUT 应用 AppID + 名片 + 费率 → 表优先, /info 立即生效
446
+ r = await fetch(base + "/api/sp/config", {
447
+ method: "PUT", headers: { ...H, Accept: "application/json" },
448
+ body: JSON.stringify({ app_appid: "wx_cfg_app_001", sp_name: "配置页服务商", sp_contact: "微信: cfg", commission_bp: 200 }),
449
+ });
450
+ j = await r.json();
451
+ assert.strictEqual(j.ok, true);
452
+ assert.strictEqual(j.saved.app_appid, "wx_cfg_app_001");
453
+ assert.strictEqual(j.saved.commission_bp, 200);
454
+ assert.strictEqual(j.completeness.appid, true);
455
+ j = await (await fetch(base + "/api/sp/info")).json();
456
+ assert.strictEqual(j.app_appid, "wx_cfg_app_001", "/info 读表立即生效");
457
+ assert.strictEqual(j.name, "配置页服务商");
458
+ assert.strictEqual(j.contact, "微信: cfg");
459
+ assert.strictEqual(j.commission_rate_bp, 200);
460
+ assert.strictEqual(j.rate_pct, "2.00%");
461
+ // env 设置后表仍优先 (表 > env)
462
+ process.env.SP_NAME = "env-不应生效";
463
+ j = await (await fetch(base + "/api/sp/info")).json();
464
+ assert.strictEqual(j.name, "配置页服务商", "表优先于 env");
465
+ delete process.env.SP_NAME;
466
+ // 素材上传 → 配齐度 4/4 + /info app_screenshots
467
+ for (const kind of ["home", "tail", "inner", "pay"]) {
468
+ const ur = await fetch(base + "/api/sp/app-assets", {
469
+ method: "POST", headers: H,
470
+ body: JSON.stringify({ assets: { [kind]: { filename: kind + ".jpg", content_b64: Buffer.from("fake-" + kind).toString("base64") } } }),
471
+ });
472
+ assert.strictEqual(ur.status, 200);
473
+ }
474
+ j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
475
+ assert.strictEqual(j.completeness.assets, 4);
476
+ assert.ok(j.completeness.complete, "AppID+4素材+名片全齐 → complete");
477
+ j = await (await fetch(base + "/api/sp/info")).json();
478
+ assert.ok(j.app_screenshots.home && j.app_screenshots.pay, "素材返回");
479
+ // 删除一张 → 不再 complete
480
+ r = await fetch(base + "/api/sp/app-assets/pay", { method: "DELETE", headers: H });
481
+ assert.strictEqual((await r.json()).ok, true);
482
+ j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
483
+ assert.strictEqual(j.completeness.assets, 3);
484
+ assert.ok(!j.completeness.complete);
485
+ assert.ok(j.completeness.missing.includes("支付页截图"));
486
+ // 费率越界 422
487
+ r = await fetch(base + "/api/sp/config", {
488
+ method: "PUT", headers: { ...H, Accept: "application/json" },
489
+ body: JSON.stringify({ commission_bp: 3001 }),
490
+ });
491
+ assert.strictEqual(r.status, 422);
492
+ } finally { srv.close(); }
493
+ });