trustbase-seller-backend 0.3.7 → 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.7",
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": {
@@ -289,14 +289,13 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
289
289
  <div class="acard-h"><span class="anum">4</span><span id="acard4-title">门店信息</span></div>
290
290
  <div id="scene_fields_app">
291
291
  <div class="agrid">
292
- <div class="afield"><label for="a_app_appid">应用 AppID</label><input id="a_app_appid" placeholder="默认由服务商提供">
293
- <div class="hint" id="a_appid_note" style="display:none;font-size:12px;color:#16A34A;margin-top:3px">✓ 由服务商提供, 无需自备</div></div>
292
+ <div class="afield" id="a_appid_field"><label for="a_app_appid">应用 AppID</label><input id="a_app_appid" placeholder="wx 开头">
293
+ <div class="hint" id="a_appid_note" style="display:none;font-size:12px;color:#16A34A;margin-top:3px">✓ 由服务商提供</div></div>
294
294
  <div class="afield"><label for="a_app_provider">应用归属</label>
295
295
  <select id="a_app_provider"><option value="sp">服务商应用 (默认, 零自备)</option><option value="own">商户自有应用</option></select></div>
296
296
  </div>
297
- <div class="ahint" id="a_app_reuse_row" style="display:none;margin-top:8px">
298
- <label style="cursor:pointer"><input type="checkbox" id="a_app_reuse" checked style="width:auto;margin-right:6px">使用服务商提供的应用截图 ✓ (推荐, 不用自己传)</label>
299
- </div>
297
+ <div class="ahint" id="a_app_provided" style="display:none;margin-top:8px;background:#F0FDF4;border-color:#BBF7D0;color:#166534"></div>
298
+ <div class="ahint" id="a_app_missing" style="display:none;margin-top:8px">⚠️ 服务商尚未配置应用,请联系服务商配置,或改用下方"商户自有应用"填写</div>
300
299
  <div class="ahint" id="a_app_own_hint" style="display:none;margin-top:8px">使用自有应用: 请填自己的 AppID 并上传 4 张应用截图</div>
301
300
  </div>
302
301
  <div class="agrid" id="scene_fields_store">
@@ -698,22 +697,40 @@ function setScene(scene){
698
697
  });
699
698
  paintAppScene(); // APP 场景: 服务商素材复用行显隐 + 截图槽联动
700
699
  }
701
- // APP 场景 (普通商户零自备): 服务商已配素材 → 默认勾选"使用服务商提供的应用截图", 截图槽隐藏;
702
- // 取消勾选或选自有应用 → 显示 4 个截图槽自己传
700
+ // APP 场景 (2026-10-03 UX 修正): 服务商配置完整 (AppID+4素材) → AppID 框和截图槽全隐藏,
701
+ // 只留绿标"应用和截图由服务商提供, 无需操作"; 未配齐 → 提示联系服务商 + 展开自有字段组;
702
+ // 选自有应用 → AppID 输入框 + 4 张截图槽。
703
+ function appSpComplete(){
704
+ var sp=window._spInfo||{}, a=sp.app_screenshots||{};
705
+ return !!(sp.app_appid&&a.home&&a.tail&&a.inner&&a.pay);
706
+ }
707
+ function maskAppid(a){ a=String(a||''); return a.length>8?a.slice(0,4)+'****'+a.slice(-4):a; }
703
708
  function paintAppScene(){
704
709
  if(currentScene()!=='SALES_SCENES_APP') return;
705
- var spAssets=(window._spInfo&&window._spInfo.app_screenshots)||{};
706
- var hasSp=Object.keys(spAssets).length>0;
707
- var reuseRow=document.getElementById('a_app_reuse_row');
708
- if(reuseRow) reuseRow.style.display=hasSp?'':'none';
710
+ var complete=appSpComplete();
709
711
  var provider=(document.getElementById('a_app_provider')||{}).value||'sp';
710
- var reuse=hasSp&&provider==='sp'&&(document.getElementById('a_app_reuse')||{}).checked;
712
+ var zeroMode=provider==='sp'&&complete; // 零操作: 全隐藏
713
+ var missing=provider==='sp'&&!complete; // 服务商未配齐 → 提示+自有组
714
+ var fAppid=document.getElementById('a_appid_field');
715
+ if(fAppid) fAppid.style.display=zeroMode?'none':'';
716
+ var provided=document.getElementById('a_app_provided');
717
+ if(provided){
718
+ provided.style.display=zeroMode?'':'none';
719
+ if(zeroMode) provided.textContent='✓ 应用和截图由服务商提供 (AppID: '+maskAppid((window._spInfo||{}).app_appid)+'), 无需操作';
720
+ }
721
+ var missEl=document.getElementById('a_app_missing');
722
+ if(missEl) missEl.style.display=missing?'':'none';
723
+ var ownHint=document.getElementById('a_app_own_hint');
724
+ if(ownHint) ownHint.style.display=provider==='own'?'':'none';
711
725
  ['a_up_s1','a_up_s2','a_up_s3','a_up_s4'].forEach(function(id){
712
- var el=document.getElementById(id);
713
- if(el) el.style.display=reuse?'none':'';
726
+ var el=document.getElementById(id); if(el) el.style.display=zeroMode?'none':'';
714
727
  });
715
- var ownHint=document.getElementById('a_app_own_hint');
716
- if(ownHint) ownHint.style.display=(provider==='own'||!reuse)&&!hasSp?'':'none';
728
+ // AppID: 服务商配了 → 预填 (隐藏提交也要用); 未配 → 不显示误导占位, 占位统一 "wx 开头"
729
+ var appidEl=document.getElementById('a_app_appid');
730
+ if(appidEl){
731
+ if((window._spInfo||{}).app_appid&&!appidEl.value) appidEl.value=(window._spInfo||{}).app_appid;
732
+ appidEl.placeholder='wx 开头';
733
+ }
717
734
  }
718
735
  setScene('SALES_SCENES_APP'); // 默认 APP (普通商户零自备)
719
736
  function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
@@ -742,13 +759,14 @@ function applyFormBody(){
742
759
  body.web_home_media_id=_aMedia.web_home_media_id||'';body.web_product_media_id=_aMedia.web_product_media_id||'';
743
760
  }else if(scene==='SALES_SCENES_APP'){
744
761
  var provider=aval('a_app_provider')||'sp';
745
- body.appid=aval('a_app_appid');
746
762
  body.appid_provider=provider; // sp=服务商应用(默认零自备) / own=商户自有应用
747
- var spAssets=(window._spInfo&&window._spInfo.app_screenshots)||{};
748
- var reuse=provider==='sp'&&Object.keys(spAssets).length>0&&(document.getElementById('a_app_reuse')||{}).checked;
749
- if(reuse){
750
- body.app_screenshots=['home','tail','inner','pay'].map(function(k){return spAssets[k];}).filter(Boolean); // 复用服务商素材
763
+ if(provider==='sp'&&appSpComplete()){
764
+ // 零操作: AppID 和截图全部用服务商配置 (商户页面全隐藏)
765
+ body.appid=(window._spInfo||{}).app_appid;
766
+ var sa=(window._spInfo||{}).app_screenshots||{};
767
+ body.app_screenshots=['home','tail','inner','pay'].map(function(k){return sa[k];}).filter(Boolean);
751
768
  }else{
769
+ body.appid=aval('a_app_appid');
752
770
  body.app_screenshots=[_aMedia.app_home_media_id,_aMedia.app_tail_media_id,_aMedia.app_inner_media_id,_aMedia.app_pay_media_id].filter(Boolean);
753
771
  }
754
772
  }else{
@@ -1004,12 +1022,8 @@ async function loadSpInfo(){
1004
1022
  if(!j||!j.name) return;
1005
1023
  window._spInfo=j; // APP 场景零自备: app_appid/app_screenshots 预填复用
1006
1024
  var appidEl=document.getElementById('a_app_appid');
1007
- if(appidEl&&j.app_appid&&!appidEl.value){
1008
- appidEl.value=j.app_appid;
1009
- var note=document.getElementById('a_appid_note');
1010
- if(note) note.style.display='block';
1011
- }
1012
- paintAppScene(); // 素材复用行按服务商配置刷新
1025
+ if(appidEl&&j.app_appid&&!appidEl.value) appidEl.value=j.app_appid;
1026
+ paintAppScene(); // 三态显隐按服务商配置完整度刷新
1013
1027
  card.style.display='block';
1014
1028
  card.classList.toggle('offline', !!j.offline);
1015
1029
  document.getElementById('sp-name').textContent=j.offline&&!j.stale?'服务商暂时不可达':j.name;
@@ -1159,7 +1173,10 @@ function wzValidate(n){
1159
1173
  }else if(scene==='SALES_SCENES_MP'){
1160
1174
  r=need('a_appid','公众号/小程序 AppID','appid');
1161
1175
  }else{
1162
- r=need('a_app_appid','应用 AppID','app_appid');
1176
+ // APP 场景: 服务商配置完整(零操作)时 AppID 框隐藏且用服务商值, 不校验输入框
1177
+ if(!((document.getElementById('a_app_provider')||{}).value!=='own'&&appSpComplete())){
1178
+ r=need('a_app_appid','应用 AppID','app_appid');
1179
+ }
1163
1180
  }
1164
1181
  if(r) return r;
1165
1182
  var ind=aval('a_settlement_id');
@@ -1168,11 +1185,10 @@ function wzValidate(n){
1168
1185
  return {ok:false,field:'qual_media_id',msg:'请上传: '+ind+' 所需资质 ('+QUAL_REQUIRED[ind]+')'};
1169
1186
  }
1170
1187
  if(scene==='SALES_SCENES_APP'){
1171
- var spA=(window._spInfo&&window._spInfo.app_screenshots)||{};
1172
1188
  var provider=(document.getElementById('a_app_provider')||{}).value||'sp';
1173
- var reuse=provider==='sp'&&Object.keys(spA).length>0&&(document.getElementById('a_app_reuse')||{}).checked;
1174
- if(!reuse&&!(_aMedia.app_home_media_id&&_aMedia.app_tail_media_id&&_aMedia.app_inner_media_id&&_aMedia.app_pay_media_id)){
1175
- return {ok:false,field:'app_screenshots',msg:'请上传 4 张应用截图 (或勾选使用服务商提供的应用截图)'};
1189
+ if(!(provider==='sp'&&appSpComplete())
1190
+ &&!(_aMedia.app_home_media_id&&_aMedia.app_tail_media_id&&_aMedia.app_inner_media_id&&_aMedia.app_pay_media_id)){
1191
+ return {ok:false,field:'app_screenshots',msg:'请上传 4 张应用截图 (或等服务商配置后重试)'};
1176
1192
  }
1177
1193
  }
1178
1194
  return {ok:true};
@@ -1225,7 +1241,7 @@ function renderWzPreview(){
1225
1241
  var docs=[['营业执照',_aMedia.license_media_id],['身份证人像面',_aMedia.legal_id_front_media_id],['身份证国徽面',_aMedia.legal_id_back_media_id]];
1226
1242
  var sceneDocs={store_entrance_media_id:'门店门头照',store_indoor_media_id:'门店内部照',web_home_media_id:'网站首页截图',web_product_media_id:'商品/服务页截图',mp_media_id:'公众号/小程序截图',app_home_media_id:'应用首页截图',app_tail_media_id:'应用尾页截图',app_inner_media_id:'应用内页截图',app_pay_media_id:'应用支付页截图'};
1227
1243
  for(var k in sceneDocs){ if(_aMedia[k]) docs.push([sceneDocs[k],_aMedia[k]]); }
1228
- if(scene==='SALES_SCENES_APP'&&f.app_screenshots&&f.app_screenshots.length&&(document.getElementById('a_app_reuse')||{}).checked){
1244
+ if(scene==='SALES_SCENES_APP'&&f.appid_provider==='sp'&&appSpComplete()){
1229
1245
  docs.push(['应用截图','服务商素材 ✓']);
1230
1246
  }
1231
1247
  grp('证件',docs.map(function(d){return [d[0], d[1]?(d[1]==='服务商素材 ✓'?d[1]:'已上传 ✓'):'未上传 ✗'];}));
@@ -1381,9 +1397,7 @@ async function restoreIdentity(){
1381
1397
  }
1382
1398
  }
1383
1399
  if(stl){ stl.addEventListener('change',paintQualSlot); }
1384
- // APP 场景联动: 复用勾选/应用归属切换 → 截图槽显隐
1385
- var appReuse=document.getElementById('a_app_reuse');
1386
- if(appReuse) appReuse.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
1400
+ // APP 场景联动: 应用归属切换 → 字段组/截图槽显隐
1387
1401
  var appProv=document.getElementById('a_app_provider');
1388
1402
  if(appProv) appProv.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
1389
1403
  if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
@@ -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 兜底生成
@@ -782,14 +782,18 @@ test("APP 场景零自备: 默认场景=APP + 服务商素材复用提交组装
782
782
  } finally { srv.close(); }
783
783
  });
784
784
 
785
- test("APP 场景页面: 默认选中 APP + 字段组 + 复用勾选 + 4 截图槽", async () => {
785
+ test("APP 场景页面: 默认选中 APP + 三态显隐 (配齐全隐藏/未配提示/自有展开)", async () => {
786
786
  const { srv, base } = await startSeller();
787
787
  try {
788
788
  const html = await (await fetch(base + "/onboard/")).text();
789
789
  assert.ok(html.includes('value="SALES_SCENES_APP" checked'), "默认场景=APP");
790
790
  assert.ok(html.includes("scene_fields_app"), "APP 字段组");
791
791
  assert.ok(html.includes("a_app_appid") && html.includes("a_app_provider"), "AppID + 归属");
792
- assert.ok(html.includes("a_app_reuse") && html.includes("使用服务商提供的应用截图"), "素材复用勾选");
792
+ assert.ok(html.includes("a_app_provided") && html.includes("无需操作"), "零操作绿标");
793
+ assert.ok(html.includes("a_app_missing") && html.includes("尚未配置应用"), "未配提示");
794
+ assert.ok(html.includes("appSpComplete") && html.includes("maskAppid"), "完整度判定 + AppID 打码");
795
+ assert.ok(html.includes("a_appid_field"), "AppID 字段容器 (可整体隐藏)");
796
+ assert.ok(!html.includes("a_app_reuse"), "复用勾选已移除 (零自备=默认行为)");
793
797
  assert.ok(html.includes("a_up_s3") && html.includes("a_up_s4"), "4 截图槽");
794
798
  assert.ok(html.includes("paintAppScene"), "场景联动");
795
799
  assert.ok(html.includes("app_screenshots"), "提交字段");
@@ -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
+ });