trustbase-seller-backend 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -63,6 +63,30 @@ h1{font-size:22px;margin-bottom:4px}
63
63
  /* 结算规则只读展示框 (按主体推导, 商户不用选; settlement_id=结算规则 ID 716/719/727,
64
64
  qualification_type=行业类目 MCC — 两次微信实测错误交叉锁定后的正确口径) */
65
65
  #apply-form .arule{padding:10px 12px;border:1px dashed #D4CDBF;border-radius:8px;background:#F7F3EB;color:#6B655B;font-size:14px}
66
+ /* ===== 分步向导 stepper (2026-10-03 腾讯式分步填写): 6 步, 当前橙/完成绿/未达灰, 可回跳完成步 ===== */
67
+ #wz-bar{display:flex;gap:6px;overflow-x:auto;margin-bottom:12px;padding-bottom:4px}
68
+ #wz-bar .wz-item{display:flex;align-items:center;gap:6px;flex:none;font-size:12px;padding:6px 10px;border-radius:999px;border:1px solid #EAE3D6;background:#FFFDF9;cursor:pointer;color:#6B655B}
69
+ #wz-bar .wz-dot{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#D4CDBF;color:#fff;font-size:11px;font-weight:600}
70
+ #wz-bar .wz-item.cur{border-color:#C4633C;background:#FBF1EA;color:#C4633C;font-weight:600}
71
+ #wz-bar .wz-item.cur .wz-dot{background:#C4633C}
72
+ #wz-bar .wz-item.done{border-color:#BBF7D0;background:#F0FDF4;color:#166534}
73
+ #wz-bar .wz-item.done .wz-dot{background:#16A34A}
74
+ #wz-bar .wz-item.todo{color:#BDB5A6;cursor:default}
75
+ #wz-step-head{margin-bottom:10px}
76
+ #wz-step-head .desc{margin-top:2px}
77
+ #wz-nav{display:flex;gap:10px;margin-top:16px}
78
+ #wz-nav .sbtn{flex:1;padding:12px;text-align:center;font-size:15px}
79
+ #wz-preview .wz-grp{border:1px solid #EAE3D6;border-radius:10px;padding:10px 12px;margin-bottom:10px;background:#FFFDF9}
80
+ #wz-preview .wz-grp-t{font-size:13px;font-weight:600;color:#C4633C;margin-bottom:6px}
81
+ #wz-preview .wz-row{display:flex;justify-content:space-between;font-size:13px;padding:3px 0;border-bottom:1px dashed #EFE9DC;gap:12px}
82
+ #wz-preview .wz-row:last-child{border-bottom:none}
83
+ #wz-preview .wz-row span{color:#8A857B;flex:none}
84
+ #wz-preview .wz-row b{text-align:right;word-break:break-all}
85
+ /* 特殊资质槽 (就地位: 行业选择正下方, 紧凑矮版 — 选了餐饮下面立刻要食品经营许可证) */
86
+ #apply-form #a_up_qual_wrap{margin-top:4px}
87
+ #apply-form #a_up_qual_wrap label.sbtn{padding:10px;gap:2px}
88
+ #apply-form #a_up_qual_wrap label.sbtn::before{font-size:16px}
89
+ #apply-form #a_up_qual_wrap .upfilled .auprev{max-height:110px}
66
90
  #apply-form .afield input:focus,#apply-form .afield select:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px rgba(196,99,60,.15)}
67
91
  /* 上传卡片 (结构由 mountApplyUpload 生成: 标题label + label.sbtn>input + span#st_*) */
68
92
  #apply-form .auploads{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
@@ -99,7 +123,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
99
123
  #apply-status a{display:inline-block;background:#C4633C;color:#fff !important;border-radius:8px;padding:10px 18px;font-size:15px;font-weight:600;text-decoration:none;margin-top:8px}
100
124
  #apply-status .aqr{display:flex;align-items:center;justify-content:center;width:120px;height:120px;border:2px dashed #93C5FD;border-radius:10px;background:#fff;color:#8A857B;font-size:12px;text-align:center;margin:10px 0 2px;line-height:1.6}
101
125
  /* 经营场景选择卡 (2026-10-03 三场景) */
102
- #apply-form .ascene-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
126
+ #apply-form .ascene-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
103
127
  #apply-form .ascene{display:flex;flex-direction:column;align-items:center;gap:2px;border:2px solid #EAE3D6;border-radius:12px;background:#FFFDF9;padding:14px 8px;cursor:pointer;text-align:center;transition:border-color .15s,background .15s}
104
128
  #apply-form .ascene input{display:none}
105
129
  #apply-form .ascene .ascene-ico{font-size:24px;line-height:1}
@@ -176,12 +200,16 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
176
200
  <div class="hintbox" id="apply-msg"></div>
177
201
  <div id="apply-status" style="display:none;margin-top:12px"></div>
178
202
  <div id="apply-form" style="display:none;margin-top:12px">
179
- <div class="desc" style="margin-bottom:12px">以下资料由你的电脑<b>本地加密</b>后交给服务商,向微信申请特约商户收款账户。身份证号/银行卡号在你电脑上就已加密,任何人 (含服务商) 都看不到明文。</div>
203
+ <div class="desc" style="margin-bottom:12px">以下资料由你的电脑<b>本地加密</b>后交给服务商,向微信申请特约商户收款账户。身份证号/银行卡号在你电脑上就已加密,任何人 (含服务商) 都看不到明文。</div>
204
+ <div id="wz-bar"></div>
205
+ <div id="wz-step-head"><b id="wz-title" style="font-size:15px"></b><div class="desc" id="wz-desc"></div></div>
180
206
 
181
207
  <div class="acard">
182
208
  <div class="acard-h">经营场景 <span class="aopt">三选一, 决定第 4/5 张卡片要什么资料</span></div>
183
209
  <div class="ascene-row">
184
- <label class="ascene selected"><input type="radio" name="sales_scene" value="SALES_SCENES_STORE" checked onchange="setScene(this.value)">
210
+ <label class="ascene selected"><input type="radio" name="sales_scene" value="SALES_SCENES_APP" checked onchange="setScene(this.value)">
211
+ <span class="ascene-ico">📲</span><span class="ascene-name">APP 应用</span><span class="ascene-sub">默认: 用服务商应用, 零自备</span></label>
212
+ <label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_STORE" onchange="setScene(this.value)">
185
213
  <span class="ascene-ico">🏬</span><span class="ascene-name">线下门店</span><span class="ascene-sub">门店地址 + 门头/店内照片</span></label>
186
214
  <label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_WEB" onchange="setScene(this.value)">
187
215
  <span class="ascene-ico">🌐</span><span class="ascene-name">互联网网站</span><span class="ascene-sub">网址 + 截图, 需 ICP 备案</span></label>
@@ -205,6 +233,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
205
233
  <select id="a_industry_cat"></select></div>
206
234
  <div class="afield"><label for="a_settlement_id">所属行业·子类 (微信行业名称)</label>
207
235
  <select id="a_settlement_id"></select></div>
236
+ <div class="afield afield-full" id="a_up_qual_wrap" style="display:none"><div id="a_up_qual"></div></div>
208
237
  </div>
209
238
  </div>
210
239
 
@@ -258,6 +287,18 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
258
287
 
259
288
  <div class="acard">
260
289
  <div class="acard-h"><span class="anum">4</span><span id="acard4-title">门店信息</span></div>
290
+ <div id="scene_fields_app">
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>
294
+ <div class="afield"><label for="a_app_provider">应用归属</label>
295
+ <select id="a_app_provider"><option value="sp">服务商应用 (默认, 零自备)</option><option value="own">商户自有应用</option></select></div>
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>
300
+ <div class="ahint" id="a_app_own_hint" style="display:none;margin-top:8px">使用自有应用: 请填自己的 AppID 并上传 4 张应用截图</div>
301
+ </div>
261
302
  <div class="agrid" id="scene_fields_store">
262
303
  <div class="afield"><label for="a_store_name">门店名称</label><input id="a_store_name" placeholder="线下场所名称"></div>
263
304
  <div class="afield"><label for="a_store_address_code">门店省市编码</label>
@@ -291,14 +332,24 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
291
332
  <div id="a_up_idfront"></div>
292
333
  <div id="a_up_idback"></div>
293
334
  <div id="a_up_s1"></div>
294
- <div id="a_up_s2"></div>
295
- <div id="a_up_qual_wrap" style="display:none;grid-column:1/-1"><div id="a_up_qual"></div></div>
335
+ <div id="a_up_s2"></div>
336
+ <div id="a_up_s3" style="display:none"></div>
337
+ <div id="a_up_s4" style="display:none"></div>
296
338
  </div>
297
339
  </div>
298
340
 
341
+ <div class="acard" id="wz-preview-card" style="display:none">
342
+ <div class="acard-h"><span class="anum">6</span>确认提交</div>
343
+ <div class="desc" style="margin-bottom:8px">请核对以下资料,确认无误后提交。身份证号/卡号等敏感信息已打码。</div>
344
+ <div id="wz-preview"></div>
345
+ </div>
299
346
  <div id="apply-err" style="display:none"></div>
300
347
  <button class="abtn" id="btn-apply-submit" onclick="submitApplyment()">加密并提交进件</button>
301
348
  <div class="aprivacy">🔒 资料只保存在这台电脑; 敏感字段本机加密后交服务商; 服务商暂时不可达时加密暂存本机, 可重试。</div>
349
+ <div id="wz-nav">
350
+ <button type="button" class="sbtn" id="wz-prev" onclick="wzGo(-1)">← 上一步</button>
351
+ <button type="button" class="sbtn primary" id="wz-next" onclick="wzGo(1)">下一步 →</button>
352
+ </div>
302
353
  </div>
303
354
  </div>
304
355
  </div>
@@ -617,30 +668,54 @@ mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (
617
668
  mountApplyUpload('a_up_idback','legal_id_back_media_id','身份证国徽面 (必传)');
618
669
  // 经营场景 (2026-10-03 xiaomu 拍板三选一): 场景决定第 4 卡片字段 + 第 5 卡片场景照片槽
619
670
  var SCENE_UPLOADS={
671
+ SALES_SCENES_APP:[['a_up_s1','app_home_media_id','应用首页截图 (必传)'],['a_up_s2','app_tail_media_id','应用尾页截图 (必传)'],
672
+ ['a_up_s3','app_inner_media_id','应用内页截图 (必传)'],['a_up_s4','app_pay_media_id','应用支付页截图 (必传)']],
620
673
  SALES_SCENES_STORE:[['a_up_s1','store_entrance_media_id','门店门头照片 (必传)'],['a_up_s2','store_indoor_media_id','门店内部照片 (必传)']],
621
674
  SALES_SCENES_WEB:[['a_up_s1','web_home_media_id','网站首页截图 (必传)'],['a_up_s2','web_product_media_id','商品/服务页截图 (必传)']],
622
675
  SALES_SCENES_MP:[['a_up_s1','mp_media_id','公众号/小程序页面截图 (必传)']]
623
676
  };
624
- var SCENE_TITLES={SALES_SCENES_STORE:'门店信息',SALES_SCENES_WEB:'网站信息',SALES_SCENES_MP:'公众号 / 小程序'};
677
+ var SCENE_TITLES={SALES_SCENES_APP:'应用信息 (APP)',SALES_SCENES_STORE:'门店信息',SALES_SCENES_WEB:'网站信息',SALES_SCENES_MP:'公众号 / 小程序'};
625
678
  function currentScene(){
626
679
  var r=document.querySelector('input[name="sales_scene"]:checked');
627
- return r?r.value:'SALES_SCENES_STORE';
680
+ return r?r.value:'SALES_SCENES_APP'; // 默认 APP (普通商户零自备)
628
681
  }
629
682
  function setScene(scene){
683
+ document.getElementById('scene_fields_app').style.display=scene==='SALES_SCENES_APP'?'':'none';
630
684
  document.getElementById('scene_fields_store').style.display=scene==='SALES_SCENES_STORE'?'':'none';
631
685
  document.getElementById('scene_fields_web').style.display=scene==='SALES_SCENES_WEB'?'':'none';
632
686
  document.getElementById('scene_fields_mp').style.display=scene==='SALES_SCENES_MP'?'':'none';
633
- document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'门店信息';
687
+ document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'应用信息 (APP)';
634
688
  document.querySelectorAll('.ascene').forEach(function(c){
635
689
  c.classList.toggle('selected', c.querySelector('input').value===scene);
636
690
  });
637
691
  // 场景照片槽重挂 + 清掉旧场景 media_id (避免跨场景串数据)
638
- ['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'].forEach(function(k){delete _aMedia[k];});
639
- var slots=SCENE_UPLOADS[scene]||SCENE_UPLOADS.SALES_SCENES_STORE;
692
+ ['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id',
693
+ 'app_home_media_id','app_tail_media_id','app_inner_media_id','app_pay_media_id'].forEach(function(k){delete _aMedia[k];});
694
+ var slots=SCENE_UPLOADS[scene]||SCENE_UPLOADS.SALES_SCENES_APP;
640
695
  slots.forEach(function(s){var el=document.getElementById(s[0]);el.style.display='';mountApplyUpload(s[0],s[1],s[2]);});
641
- if(!slots.some(function(s){return s[0]==='a_up_s2';})) document.getElementById('a_up_s2').style.display='none';
696
+ ['a_up_s2','a_up_s3','a_up_s4'].forEach(function(id){
697
+ if(!slots.some(function(s){return s[0]===id;})) document.getElementById(id).style.display='none';
698
+ });
699
+ paintAppScene(); // APP 场景: 服务商素材复用行显隐 + 截图槽联动
700
+ }
701
+ // APP 场景 (普通商户零自备): 服务商已配素材 → 默认勾选"使用服务商提供的应用截图", 截图槽隐藏;
702
+ // 取消勾选或选自有应用 → 显示 4 个截图槽自己传
703
+ function paintAppScene(){
704
+ 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';
709
+ var provider=(document.getElementById('a_app_provider')||{}).value||'sp';
710
+ var reuse=hasSp&&provider==='sp'&&(document.getElementById('a_app_reuse')||{}).checked;
711
+ ['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':'';
714
+ });
715
+ var ownHint=document.getElementById('a_app_own_hint');
716
+ if(ownHint) ownHint.style.display=(provider==='own'||!reuse)&&!hasSp?'':'none';
642
717
  }
643
- setScene('SALES_SCENES_STORE'); // 默认线下门店
718
+ setScene('SALES_SCENES_APP'); // 默认 APP (普通商户零自备)
644
719
  function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
645
720
  function applyFormBody(){
646
721
  var scene=currentScene();
@@ -665,6 +740,17 @@ function applyFormBody(){
665
740
  }else if(scene==='SALES_SCENES_WEB'){
666
741
  body.website_url=aval('a_website_url');
667
742
  body.web_home_media_id=_aMedia.web_home_media_id||'';body.web_product_media_id=_aMedia.web_product_media_id||'';
743
+ }else if(scene==='SALES_SCENES_APP'){
744
+ var provider=aval('a_app_provider')||'sp';
745
+ body.appid=aval('a_app_appid');
746
+ 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); // 复用服务商素材
751
+ }else{
752
+ 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
+ }
668
754
  }else{
669
755
  body.appid=aval('a_appid');body.appid_type=aval('a_appid_type');
670
756
  body.mp_media_id=_aMedia.mp_media_id||'';
@@ -916,6 +1002,14 @@ async function loadSpInfo(){
916
1002
  try{
917
1003
  var j=await readJson(await fetch('/api/onboard/sp-info'));
918
1004
  if(!j||!j.name) return;
1005
+ window._spInfo=j; // APP 场景零自备: app_appid/app_screenshots 预填复用
1006
+ 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(); // 素材复用行按服务商配置刷新
919
1013
  card.style.display='block';
920
1014
  card.classList.toggle('offline', !!j.offline);
921
1015
  document.getElementById('sp-name').textContent=j.offline&&!j.stale?'服务商暂时不可达':j.name;
@@ -975,6 +1069,170 @@ async function submit(){
975
1069
  loadStatus(); // 刷新进度面板
976
1070
  }catch(e){alert('网络错误: '+e.message);btn.disabled=false}
977
1071
  }
1072
+ // ============================== 分步卡片向导 (2026-10-03 腾讯式分步填写) ==============================
1073
+ // 6 步: 主体资料 → 法人与超管 → 结算账户 → 经营与行业(场景+行业+许可证) → 证件上传 → 确认提交(预览)。
1074
+ // 全部既有逻辑 (字段 id/applyFormBody/上传槽/草稿/联动/错误红卡) 原样复用, 这里只做分步显隐+导航。
1075
+ var WZ_STEPS=[
1076
+ {t:'主体资料',d:'营业执照上的主体信息,需与微信实名审核一致'},
1077
+ {t:'法人与超管',d:'法人身份将用于微信实名审核,超管接收开户通知'},
1078
+ {t:'结算账户',d:'微信收的款结算到这张卡(企业主体须对公账户)'},
1079
+ {t:'经营与行业',d:'选经营场景和所属行业,特殊行业就地传许可证'},
1080
+ {t:'证件上传',d:'拍清楚、四角完整、无反光 — 模糊是驳回首因'},
1081
+ {t:'确认提交',d:'核对资料,确认后提交微信审核'}
1082
+ ];
1083
+ var _wzCur=1,_wzMax=1,_wzGroups=[];
1084
+ function wzInit(){
1085
+ var cards=document.querySelectorAll('#apply-form > .acard');
1086
+ // 既有卡片 DOM 顺序: [经营场景卡, ①主体, ②法人, ③结算, ④场景信息, ⑤证件上传]
1087
+ _wzGroups=[
1088
+ [cards[1]],[cards[2]],[cards[3]],[cards[0],cards[4]],[cards[5]],
1089
+ [document.getElementById('wz-preview-card'),document.getElementById('btn-apply-submit'),document.querySelector('#apply-form .aprivacy')]
1090
+ ];
1091
+ var bar=document.getElementById('wz-bar');
1092
+ bar.innerHTML='';
1093
+ WZ_STEPS.forEach(function(st,i){
1094
+ var d=document.createElement('div');
1095
+ d.className='wz-item todo'; d.setAttribute('data-step',i+1);
1096
+ d.innerHTML='<span class="wz-dot">'+(i+1)+'</span><span class="wz-lbl">'+st.t+'</span>';
1097
+ d.addEventListener('click',function(){ if(i+1<=_wzMax) wzShow(i+1); }); // 只许回跳已完成步
1098
+ bar.appendChild(d);
1099
+ });
1100
+ wzShow(1);
1101
+ }
1102
+ function wzShow(n){
1103
+ _wzCur=n; if(n>_wzMax)_wzMax=n;
1104
+ _wzGroups.forEach(function(g,i){
1105
+ g.forEach(function(el){ if(el) el.style.display=(i+1===n)?'':'none'; });
1106
+ });
1107
+ document.querySelectorAll('#wz-bar .wz-item').forEach(function(it){
1108
+ var s=Number(it.getAttribute('data-step'));
1109
+ it.className='wz-item'+(s===n?' cur':(s<_wzMax?' done':' todo'));
1110
+ it.querySelector('.wz-dot').innerHTML=(s<_wzMax&&s!==n)?'✓':String(s);
1111
+ });
1112
+ var st=WZ_STEPS[n-1];
1113
+ document.getElementById('wz-title').textContent='第 '+n+'/6 步 · '+st.t;
1114
+ document.getElementById('wz-desc').textContent=st.d;
1115
+ document.getElementById('wz-prev').style.visibility=n===1?'hidden':'visible';
1116
+ document.getElementById('wz-next').style.display=n===6?'none':'';
1117
+ if(n===6) renderWzPreview();
1118
+ hideApplyErr();
1119
+ document.getElementById('apply-form').scrollIntoView({behavior:'smooth',block:'start'});
1120
+ }
1121
+ function wzGo(dir){
1122
+ if(dir>0){ var v=wzValidate(_wzCur); if(!v.ok){ showApplyErr(v.msg,v.field); return; } } // 下一步先校验当前步
1123
+ var n=_wzCur+dir;
1124
+ if(n<1||n>6) return;
1125
+ wzShow(n);
1126
+ saveApplyDraft();
1127
+ }
1128
+ // 当前步必填校验 (不过则红卡指到缺的字段, 不放行; 与后端 APPLY_* 校验同字段名, 错误定位复用)
1129
+ function wzValidate(n){
1130
+ function need(id,label,field){
1131
+ var e=document.getElementById(id);
1132
+ if(!e||!String(e.value||'').trim()) return {ok:false,field:field||id.replace(/^a_/,''),msg:'请填写: '+label};
1133
+ return null;
1134
+ }
1135
+ if(n===1){
1136
+ return need('a_shortname','商户简称','merchant_shortname')||need('a_merchant_name','营业执照名称','merchant_name')
1137
+ ||need('a_license_number','统一社会信用代码','license_number')||need('a_service_phone','客服电话','service_phone')||{ok:true};
1138
+ }
1139
+ if(n===2){
1140
+ var r=need('a_legal_name','法人/经营者姓名','legal_name')||need('a_legal_id_number','法人身份证号','legal_id_number');
1141
+ if(r) return r;
1142
+ if(document.getElementById('a_subject_type').value==='SUBJECT_TYPE_ENTERPRISE'){
1143
+ r=need('a_legal_id_address','身份证居住地址','legal_id_address'); if(r) return r;
1144
+ }
1145
+ return need('a_period_begin','身份证有效期开始','legal_id_period_begin')||need('a_period_end','身份证有效期结束','legal_id_period_end')
1146
+ ||need('a_contact_name','超管姓名','contact_name')||need('a_contact_mobile','超管手机号','contact_mobile')
1147
+ ||need('a_contact_email','联系邮箱','contact_email')||{ok:true};
1148
+ }
1149
+ if(n===3){
1150
+ return need('a_account_bank','开户银行','account_bank')||need('a_account_name','开户名称','account_name')
1151
+ ||need('a_account_number','银行账号','account_number')||{ok:true};
1152
+ }
1153
+ if(n===4){
1154
+ var scene=currentScene(), r=null;
1155
+ if(scene==='SALES_SCENES_STORE'){
1156
+ r=need('a_store_name','门店名称','store_name')||need('a_store_address_code','门店省市编码','store_address_code')||need('a_store_address','门店详细地址','store_address');
1157
+ }else if(scene==='SALES_SCENES_WEB'){
1158
+ r=need('a_website_url','网站网址','website_url');
1159
+ }else if(scene==='SALES_SCENES_MP'){
1160
+ r=need('a_appid','公众号/小程序 AppID','appid');
1161
+ }else{
1162
+ r=need('a_app_appid','应用 AppID','app_appid');
1163
+ }
1164
+ if(r) return r;
1165
+ var ind=aval('a_settlement_id');
1166
+ if(!ind) return {ok:false,field:'qualification_type',msg:'请选择: 所属行业'};
1167
+ if(typeof QUAL_REQUIRED!=='undefined'&&QUAL_REQUIRED[ind]&&!_aMedia.qual_media_id){
1168
+ return {ok:false,field:'qual_media_id',msg:'请上传: '+ind+' 所需资质 ('+QUAL_REQUIRED[ind]+')'};
1169
+ }
1170
+ if(scene==='SALES_SCENES_APP'){
1171
+ var spA=(window._spInfo&&window._spInfo.app_screenshots)||{};
1172
+ 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 张应用截图 (或勾选使用服务商提供的应用截图)'};
1176
+ }
1177
+ }
1178
+ return {ok:true};
1179
+ }
1180
+ if(n===5){
1181
+ var scene=currentScene();
1182
+ if(!_aMedia.license_media_id) return {ok:false,field:'license_media_id',msg:'请上传: 营业执照照片'};
1183
+ if(!_aMedia.legal_id_front_media_id) return {ok:false,field:'legal_id_front_media_id',msg:'请上传: 身份证人像面'};
1184
+ if(!_aMedia.legal_id_back_media_id) return {ok:false,field:'legal_id_back_media_id',msg:'请上传: 身份证国徽面'};
1185
+ if(scene==='SALES_SCENES_STORE'){
1186
+ if(!_aMedia.store_entrance_media_id) return {ok:false,field:'store_entrance_media_id',msg:'请上传: 门店门头照片'};
1187
+ if(!_aMedia.store_indoor_media_id) return {ok:false,field:'store_indoor_media_id',msg:'请上传: 门店内部照片'};
1188
+ }else if(scene==='SALES_SCENES_WEB'){
1189
+ if(!_aMedia.web_home_media_id) return {ok:false,field:'web_home_media_id',msg:'请上传: 网站首页截图'};
1190
+ if(!_aMedia.web_product_media_id) return {ok:false,field:'web_product_media_id',msg:'请上传: 商品/服务页截图'};
1191
+ }else if(scene==='SALES_SCENES_MP'){
1192
+ if(!_aMedia.mp_media_id) return {ok:false,field:'mp_media_id',msg:'请上传: 公众号/小程序页面截图'};
1193
+ }
1194
+ return {ok:true};
1195
+ }
1196
+ return {ok:true}; // 第 6 步提交由后端兜底
1197
+ }
1198
+ // 第 6 步预览: 分组只读摘要 (敏感字段 ***后4位)
1199
+ function wzMask(s){ s=String(s||''); return s.length<=4?'***':'***'+s.slice(-4); }
1200
+ function renderWzPreview(){
1201
+ var f=applyFormBody();
1202
+ var scene=currentScene();
1203
+ var rows=[];
1204
+ function grp(t,items){
1205
+ rows.push('<div class="wz-grp"><div class="wz-grp-t">'+escHtml(t)+'</div>'
1206
+ +items.map(function(it){return '<div class="wz-row"><span>'+escHtml(it[0])+'</span><b>'+escHtml(it[1]==null||it[1]===''?'—':it[1])+'</b></div>';}).join('')+'</div>');
1207
+ }
1208
+ grp('主体资料',[
1209
+ ['主体类型',(document.querySelector('#a_subject_type option:checked')||{}).textContent||f.subject_type],
1210
+ ['商户简称',f.merchant_shortname],['营业执照名称',f.merchant_name],
1211
+ ['信用代码',f.license_number],['客服电话',f.service_phone]]);
1212
+ grp('法人与超管',[
1213
+ ['法人姓名',f.legal_name],['身份证号',wzMask(f.legal_id_number)],
1214
+ ['有效期',f.legal_id_period_begin+' ~ '+f.legal_id_period_end],
1215
+ ['超管',f.contact_name+' '+wzMask(f.contact_mobile)],['邮箱',f.contact_email]]);
1216
+ grp('结算账户',[
1217
+ ['账户类型',f.account_type==='BANK_ACCOUNT_TYPE_CORPORATE'?'对公账户':'经营者个人卡'],
1218
+ ['开户行',f.account_bank],['户名',wzMask(f.account_name)],['卡号',wzMask(f.account_number)]]);
1219
+ var sceneName={SALES_SCENES_APP:'APP 应用',SALES_SCENES_STORE:'线下门店',SALES_SCENES_WEB:'互联网网站',SALES_SCENES_MP:'公众号/小程序'}[scene]||scene;
1220
+ grp('经营与行业',[
1221
+ ['经营场景',sceneName],
1222
+ ['所属行业',(document.getElementById('a_industry_cat')||{}).value+' > '+f.qualification_type],
1223
+ ['结算规则',(document.getElementById('a_settlement_rule')||{}).textContent||''],
1224
+ ['特殊资质',_aMedia.qual_media_id?'已上传 ✓':'—']]);
1225
+ var docs=[['营业执照',_aMedia.license_media_id],['身份证人像面',_aMedia.legal_id_front_media_id],['身份证国徽面',_aMedia.legal_id_back_media_id]];
1226
+ 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
+ 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){
1229
+ docs.push(['应用截图','服务商素材 ✓']);
1230
+ }
1231
+ grp('证件',docs.map(function(d){return [d[0], d[1]?(d[1]==='服务商素材 ✓'?d[1]:'已上传 ✓'):'未上传 ✗'];}));
1232
+ document.getElementById('wz-preview').innerHTML=rows.join('');
1233
+ }
1234
+ window.addEventListener('load',wzInit);
1235
+
978
1236
  // 安全卡二维码: 本机后端读 tb-keyring 出 dataURL (qrcode 包无浏览器 build, 且安全词不进 script 依赖链);
979
1237
  // 只在创建会话 (_mn 在内存) 拉取 — 刷新后不再展示 (与"安全词只展示一次"同口径)
980
1238
  async function loadBackupQr(){
@@ -1123,6 +1381,11 @@ async function restoreIdentity(){
1123
1381
  }
1124
1382
  }
1125
1383
  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(); });
1387
+ var appProv=document.getElementById('a_app_provider');
1388
+ if(appProv) appProv.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
1126
1389
  if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
1127
1390
  // 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
1128
1391
  var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
package/routes/onboard.js CHANGED
@@ -620,6 +620,7 @@ function createOnboardRouter(deps) {
620
620
  // 场景化必填 (2026-10-03 xiaomu 拍板三场景: 线下门店 / 互联网网站 / 公众号·小程序)
621
621
  // F2 fix: WEB 场景微信端只有 web_info.domain 无 pics — 截图降为可选 (收集进台账备用, 不进微信 body)
622
622
  const APPLY_SCENE_CFG = {
623
+ SALES_SCENES_APP: { fields: ["appid"], media: [] }, // 普通商户默认: 服务商应用零自备 (截图复用服务商素材)
623
624
  SALES_SCENES_STORE: { fields: ["store_name", "store_address_code", "store_address"], media: ["store_entrance_media_id", "store_indoor_media_id"] },
624
625
  SALES_SCENES_WEB: { fields: ["website_url"], media: [] },
625
626
  SALES_SCENES_MP: { fields: ["appid"], media: ["mp_media_id"] },
@@ -807,6 +808,14 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
807
808
  return res.status(400).json({ ok: false, error: "invalid_param", field: k, message: `缺少必填字段: ${label} (${k})` });
808
809
  }
809
810
  }
811
+ // APP 场景: 应用截图必传 (app_pics; 商户复用服务商素材或自己传, 页面已二选一)
812
+ if (scene === "SALES_SCENES_APP") {
813
+ const pics = Array.isArray(b.app_screenshots) ? b.app_screenshots.filter(Boolean) : [];
814
+ if (!pics.length) {
815
+ return res.status(400).json({ ok: false, error: "media_missing", field: "app_screenshots",
816
+ message: "请上传应用截图 (或勾选使用服务商提供的应用截图)" });
817
+ }
818
+ }
810
819
  // 需资质行业 → 特殊资质图片必传 (微信 qualifications, 缺证必被拒)
811
820
  const qualNeed = APPLY_QUAL_REQUIRED[String(b.qualification_type || "")];
812
821
  if (qualNeed && !b.qual_media_id) {
@@ -831,6 +840,10 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
831
840
  for (const k of Object.keys(b)) {
832
841
  if (typeof b[k] === "string" && b[k].trim()) payload[k] = b[k].trim();
833
842
  }
843
+ // 数组字段透传 (通用 copy 只收字符串): APP 场景截图 media_id 数组
844
+ if (Array.isArray(b.app_screenshots) && b.app_screenshots.length) {
845
+ payload.app_screenshots = b.app_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5);
846
+ }
834
847
  payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
835
848
  // 特殊资质: qual_media_id → qualifications 数组 (没有则不传该字段)
836
849
  if (payload.qual_media_id) {
@@ -156,7 +156,7 @@ function buildApplymentBody(form, enc, opts) {
156
156
  const f = form || {};
157
157
  const sensLen = (opts && opts.alreadyEncrypted) ? 2048 : 0; // 0 = 用明文默认上限
158
158
  // 经营场景 (2026-10-03 xiaomu 拍板三选一): 线下门店(默认) / 互联网网站 / 公众号·小程序
159
- const scene = (f.sales_scene === "SALES_SCENES_WEB" || f.sales_scene === "SALES_SCENES_MP") ? f.sales_scene : "SALES_SCENES_STORE";
159
+ const scene = (f.sales_scene === "SALES_SCENES_WEB" || f.sales_scene === "SALES_SCENES_MP" || f.sales_scene === "SALES_SCENES_APP") ? f.sales_scene : "SALES_SCENES_STORE";
160
160
  // 必备照片 media_id 校验 (通用 3 张 + 场景照片, 缺哪张报哪张)
161
161
  const mediaNeed = [
162
162
  ["license_media_id", "营业执照照片"],
@@ -185,6 +185,15 @@ function buildApplymentBody(form, enc, opts) {
185
185
  const url = reqStr(f.website_url, "网站网址", 256);
186
186
  if (!/^https?:\/\//i.test(url)) throw new HttpError(400, "invalid_param", "网站网址须以 http(s):// 开头 (需已上线且有 ICP 备案)", "website_url");
187
187
  salesInfo.web_info = { domain: url }; // 微信官方字段是 domain; web_authorisation/web_appid 选填先不传
188
+ } else if (scene === "SALES_SCENES_APP") {
189
+ // 普通商户零自备 (xiaomu 2026-10-03): 默认走服务商应用 — AppID 用服务商的 (app_appid),
190
+ // 截图复用服务商素材 (sp_app_assets 4 张); 商户自有应用则 app_sub_appid + 自己传截图
191
+ const pics = Array.isArray(f.app_screenshots) ? f.app_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5) : [];
192
+ if (!pics.length) throw new HttpError(400, "media_missing", "应用截图未提供 (应用场景必传 app_pics)", "app_screenshots");
193
+ const appid = reqStr(f.appid, "应用 AppID", 64);
194
+ salesInfo.app_info = f.appid_provider === "own"
195
+ ? { app_sub_appid: appid, app_pics: pics } // 商户自有应用
196
+ : { app_appid: appid, app_pics: pics }; // 服务商应用 (默认零自备)
188
197
  } else {
189
198
  // 公众号/小程序二选一: 用户只有小程序 appid 时用 mini_program_info
190
199
  if (f.appid_type === "mini") {
@@ -283,6 +292,26 @@ function buildMultipart(filename, fileBuf, metaStr) {
283
292
  return { boundary, body: Buffer.concat([Buffer.from(head, "utf8"), fileBuf, Buffer.from(tail, "utf8")]) };
284
293
  }
285
294
 
295
+ /** 服务商应用素材: 表优先, env SP_APP_ASSETS_JSON ({home,tail,inner,pay} 各 media_id) 兜底 */
296
+ function readAppAssets(db) {
297
+ try {
298
+ db.exec(`CREATE TABLE IF NOT EXISTS sp_app_assets (
299
+ kind TEXT PRIMARY KEY, media_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
300
+ const rows = db.prepare("SELECT kind, media_id FROM sp_app_assets WHERE media_id <> ''").all();
301
+ if (rows.length) {
302
+ const out = {};
303
+ for (const r of rows) out[r.kind] = r.media_id;
304
+ return out;
305
+ }
306
+ } catch (_) { /* 表问题走 env */ }
307
+ try {
308
+ const j = JSON.parse(process.env.SP_APP_ASSETS_JSON || "{}");
309
+ const out = {};
310
+ for (const k of ["home", "tail", "inner", "pay"]) if (j[k]) out[k] = String(j[k]);
311
+ return out;
312
+ } catch (_) { return {}; }
313
+ }
314
+
286
315
  /** mock 微信应答 (SP_APPLYMENT_MOCK=1; 不碰真 API) */
287
316
  function mockWx(method, urlPath) {
288
317
  if (method === "POST" && urlPath === "/v3/merchant/media/upload") {
@@ -670,6 +699,9 @@ function createSpApplymentRouter(deps) {
670
699
  rate_pct: (bp / 100).toFixed(2) + "%",
671
700
  chain_address: process.env.SP_CHAIN_ADDR || "",
672
701
  gateway: String(req.headers.host || ""), // 本网关域名 (从请求 host 推断)
702
+ // 普通商户 APP 场景零自备: 服务商应用 AppID + 应用截图素材 (sp_app_assets 表优先, env 兜底)
703
+ app_appid: process.env.SP_APP_APPID || "",
704
+ app_screenshots: readAppAssets(db),
673
705
  });
674
706
  });
675
707
 
@@ -679,34 +711,66 @@ function createSpApplymentRouter(deps) {
679
711
  res.send(PAGE_HTML);
680
712
  });
681
713
 
714
+ /** 微信 media/upload 公共通道 (进件照片 / 服务商应用素材共用) */
715
+ async function wxUploadMedia(filename, buf) {
716
+ if (!/\.(jpe?g|png|bmp)$/i.test(filename)) throw new HttpError(400, "invalid_param", "filename 仅支持 jpg/jpeg/png/bmp 后缀");
717
+ if (!buf.length) throw new HttpError(400, "invalid_param", "content_b64 为空");
718
+ if (buf.length > 5 * 1024 * 1024) throw new HttpError(413, "file_too_large", "图片不能超过 5MB (微信上限)");
719
+ const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
720
+ const meta = JSON.stringify({ filename, sha256 }); // 签名串 body 用 meta JSON 串 (非整个 multipart)
721
+ const mp = buildMultipart(filename, buf, meta);
722
+ const r = await wxRequest(deps, "POST", "/v3/merchant/media/upload", {
723
+ signBody: meta, body: mp.body, contentType: "multipart/form-data; boundary=" + mp.boundary,
724
+ });
725
+ if (r.status !== 200 || !r.json.media_id) {
726
+ const e = new HttpError(502, "wx_error", "上传图片到微信失败: " + (r.json.message || ("HTTP " + r.status)));
727
+ e.wxStatus = r.status; throw e;
728
+ }
729
+ return r.json.media_id;
730
+ }
731
+
682
732
  /** POST /applyment/upload — base64 JSON {filename, content_b64} → 微信 media/upload → {media_id} */
683
733
  router.post("/applyment/upload", async (req, res) => {
684
734
  if (!checkAdmin(req, res)) return;
685
735
  try {
686
736
  const b = req.body || {};
687
737
  const filename = String(b.filename || "");
688
- if (!/\.(jpe?g|png|bmp)$/i.test(filename)) {
689
- throw new HttpError(400, "invalid_param", "filename 仅支持 jpg/jpeg/png/bmp 后缀");
690
- }
691
738
  const buf = Buffer.from(String(b.content_b64 || ""), "base64");
692
- if (!buf.length) throw new HttpError(400, "invalid_param", "content_b64 为空");
693
- if (buf.length > 5 * 1024 * 1024) throw new HttpError(413, "file_too_large", "图片不能超过 5MB (微信上限)");
694
- const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
695
- const meta = JSON.stringify({ filename, sha256 }); // 签名串 body 用 meta JSON 串 (非整个 multipart)
696
- const mp = buildMultipart(filename, buf, meta);
697
- const r = await wxRequest(deps, "POST", "/v3/merchant/media/upload", {
698
- signBody: meta,
699
- body: mp.body,
700
- contentType: "multipart/form-data; boundary=" + mp.boundary,
701
- });
702
- if (r.status !== 200 || !r.json.media_id) return wxError(res, r, "上传图片到微信失败");
703
- console.log(`[sp-applyment] 图片已上传 file=${filename} size=${buf.length} media_id=${String(r.json.media_id).slice(0, 24)}...`);
704
- res.json({ media_id: r.json.media_id });
739
+ const mediaId = await wxUploadMedia(filename, buf);
740
+ console.log(`[sp-applyment] 图片已上传 file=${filename} size=${buf.length} media_id=${String(mediaId).slice(0, 24)}...`);
741
+ res.json({ media_id: mediaId });
705
742
  } catch (e) {
706
743
  return chainWrites.handleError(res, e, "/api/sp/applyment/upload");
707
744
  }
708
745
  });
709
746
 
747
+ /** POST /app-assets — 服务商应用截图素材配置 (admin; 4 张 base64 → media/upload → sp_app_assets 表,
748
+ * 普通商户 APP 场景零自备的素材源: 商户进件默认复用这套 media_id, 不用自己传) */
749
+ const APP_ASSET_KINDS = ["home", "tail", "inner", "pay"]; // 首页/尾页/应用内/支付页
750
+ router.post("/app-assets", async (req, res) => {
751
+ if (!checkAdmin(req, res)) return;
752
+ try {
753
+ db.exec(`CREATE TABLE IF NOT EXISTS sp_app_assets (
754
+ kind TEXT PRIMARY KEY, media_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
755
+ const assets = (req.body || {}).assets || {};
756
+ const saved = {};
757
+ for (const kind of APP_ASSET_KINDS) {
758
+ const a = assets[kind];
759
+ if (!a || !a.content_b64) continue;
760
+ const filename = String(a.filename || ("app-" + kind + ".jpg"));
761
+ const mediaId = await wxUploadMedia(filename, Buffer.from(String(a.content_b64), "base64"));
762
+ db.prepare("INSERT INTO sp_app_assets (kind, media_id, updated_at) VALUES (?,?,?) ON CONFLICT(kind) DO UPDATE SET media_id=excluded.media_id, updated_at=excluded.updated_at")
763
+ .run(kind, mediaId, nowSec());
764
+ saved[kind] = mediaId;
765
+ }
766
+ if (!Object.keys(saved).length) throw new HttpError(400, "invalid_param", "assets 至少给一张 (home/tail/inner/pay)");
767
+ console.log(`[sp-applyment] 应用素材已更新 kinds=${Object.keys(saved).join(",")}`);
768
+ res.json({ ok: true, saved, assets: readAppAssets(db) });
769
+ } catch (e) {
770
+ return chainWrites.handleError(res, e, "/api/sp/app-assets");
771
+ }
772
+ });
773
+
710
774
  /** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
711
775
  * 幂等/重提 (2026-10-03 F1/F3 fix):
712
776
  * - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
@@ -731,6 +731,90 @@ test("特殊资质页面逻辑: QUAL_REQUIRED 映射 + 动态槽位 + 双态复
731
731
  assert.ok(html.includes("医疗机构执业许可证"), "医疗资质名");
732
732
  assert.ok(html.includes("办学许可证"), "教育资质名");
733
733
  assert.ok(html.includes("qual_media_id"), "资质 media_id 字段");
734
- assert.ok(html.includes("特殊资质 (必传)"), "槽位必传标注");
734
+ assert.ok(html.includes("特殊资质 (必传)"), "槽位必传标注");
735
+ // 位置 (xiaomu 实测): 资质槽在行业二级联动正下方 (第①卡), 不在第⑤卡证件上传区
736
+ const posQual = html.indexOf("a_up_qual_wrap");
737
+ const posCard2 = html.indexOf('anum">2');
738
+ const posCard5 = html.indexOf('anum">5');
739
+ assert.ok(posQual > 0 && posQual < posCard2, "资质槽在第①卡 (行业区)");
740
+ assert.ok(posQual < posCard5, "资质槽不在第⑤卡");
741
+ assert.ok(html.includes("特殊资质槽 (就地位"), "紧凑样式");
742
+ } finally { srv.close(); }
743
+ });
744
+
745
+ test("APP 场景零自备: 默认场景=APP + 服务商素材复用提交组装 + 缺截图 400", async () => {
746
+ const { srv, base } = await startSeller();
747
+ try {
748
+ // 后端: APP 场景缺截图 → 400
749
+ const form = await fillForm(base);
750
+ form.sales_scene = "SALES_SCENES_APP";
751
+ delete form.store_name; delete form.store_address_code; delete form.store_address;
752
+ delete form.store_entrance_media_id; delete form.store_indoor_media_id;
753
+ form.appid = "wx_sp_appid_001";
754
+ form.appid_provider = "sp";
755
+ let r = await fetch(base + "/api/onboard/applyment", {
756
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
757
+ });
758
+ let j = await r.json();
759
+ assert.strictEqual(r.status, 400);
760
+ assert.strictEqual(j.field, "app_screenshots");
761
+ assert.ok(j.message.includes("应用截图"), j.message);
762
+ // 复用服务商素材 (页面从 sp-info 拿到的 4 个 media_id 直接带)
763
+ form.app_screenshots = ["SP_HOME_MID", "SP_TAIL_MID", "SP_INNER_MID", "SP_PAY_MID"];
764
+ const before = spCapture.submits.length;
765
+ r = await fetch(base + "/api/onboard/applyment", {
766
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
767
+ });
768
+ j = await r.json();
769
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
770
+ const fwd = spCapture.submits[before];
771
+ assert.strictEqual(fwd.sales_scene, "SALES_SCENES_APP");
772
+ assert.strictEqual(fwd.appid, "wx_sp_appid_001");
773
+ assert.deepStrictEqual(fwd.app_screenshots, ["SP_HOME_MID", "SP_TAIL_MID", "SP_INNER_MID", "SP_PAY_MID"], "复用服务商素材 media_id");
774
+ // SP 侧组装: app_info.app_appid (服务商应用) + app_pics
775
+ const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
776
+ assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_APP"]);
777
+ assert.strictEqual(wxBody.business_info.sales_info.app_info.app_appid, "wx_sp_appid_001");
778
+ assert.strictEqual(wxBody.business_info.sales_info.app_info.app_pics.length, 4);
779
+ // 自有应用 → app_sub_appid
780
+ const wxBody2 = spApplymentLib.buildApplymentBody({ ...fwd, appid_provider: "own", business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
781
+ assert.strictEqual(wxBody2.business_info.sales_info.app_info.app_sub_appid, "wx_sp_appid_001");
782
+ } finally { srv.close(); }
783
+ });
784
+
785
+ test("APP 场景页面: 默认选中 APP + 字段组 + 复用勾选 + 4 截图槽", async () => {
786
+ const { srv, base } = await startSeller();
787
+ try {
788
+ const html = await (await fetch(base + "/onboard/")).text();
789
+ assert.ok(html.includes('value="SALES_SCENES_APP" checked'), "默认场景=APP");
790
+ assert.ok(html.includes("scene_fields_app"), "APP 字段组");
791
+ assert.ok(html.includes("a_app_appid") && html.includes("a_app_provider"), "AppID + 归属");
792
+ assert.ok(html.includes("a_app_reuse") && html.includes("使用服务商提供的应用截图"), "素材复用勾选");
793
+ assert.ok(html.includes("a_up_s3") && html.includes("a_up_s4"), "4 截图槽");
794
+ assert.ok(html.includes("paintAppScene"), "场景联动");
795
+ assert.ok(html.includes("app_screenshots"), "提交字段");
796
+ } finally { srv.close(); }
797
+ });
798
+
799
+ test("分步向导: stepper/导航/校验/预览 元素与逻辑齐备", async () => {
800
+ const { srv, base } = await startSeller();
801
+ try {
802
+ const html = await (await fetch(base + "/onboard/")).text();
803
+ // stepper + 6 步 + 导航
804
+ assert.ok(html.includes("wz-bar") && html.includes("WZ_STEPS"), "进度条");
805
+ assert.ok(html.includes("确认提交"), "第 6 步");
806
+ assert.ok(html.includes("wz-prev") && html.includes("wz-next"), "上一步/下一步");
807
+ assert.ok(html.includes("wzGo") && html.includes("wzShow"), "导航函数");
808
+ // 校验: 下一步拦 + 上一步不校验 + 回跳完成步
809
+ assert.ok(html.includes("wzValidate"), "当前步校验");
810
+ assert.ok(html.includes("if(dir>0){ var v=wzValidate(_wzCur); if(!v.ok)"), "上一步不校验");
811
+ assert.ok(html.includes("if(i+1<=_wzMax) wzShow(i+1)"), "只许回跳已完成步");
812
+ // 第 6 步预览: 分组 + 脱敏
813
+ assert.ok(html.includes("wz-preview") && html.includes("renderWzPreview"), "预览渲染");
814
+ assert.ok(html.includes("wzMask") && html.includes("***"), "敏感字段脱敏");
815
+ assert.ok(html.includes("主体资料") && html.includes("法人与超管") && html.includes("结算账户")
816
+ && html.includes("经营与行业") && html.includes("证件上传"), "6 步标题");
817
+ // 旧结构未破坏: 既有卡片/字段 id 都在
818
+ assert.ok(html.includes("a_shortname") && html.includes("a_up_license") && html.includes("btn-apply-submit"), "既有字段/提交钮保留");
735
819
  } finally { srv.close(); }
736
820
  });
@@ -389,3 +389,39 @@ test("M2: FINISHED 钩子在 oracle 不可达时不标 hook_fired_at (下轮重
389
389
  assert.ok(db.prepare("SELECT * FROM sp_sub_merchants WHERE sub_mchid='1600000999'").get(), "路由表仍落行");
390
390
  } finally { srv.close(); }
391
391
  });
392
+
393
+ test("M4-APP: /info 带 app 配置 (env + 素材表) + POST /app-assets 上传落表", async () => {
394
+ const { db, srv, base } = await startServer();
395
+ try {
396
+ // env 兜底
397
+ process.env.SP_APP_APPID = "wx_service_app_001";
398
+ process.env.SP_APP_ASSETS_JSON = JSON.stringify({ home: "ENV_HOME_MID", pay: "ENV_PAY_MID" });
399
+ let j = await (await fetch(base + "/api/sp/info")).json();
400
+ assert.strictEqual(j.app_appid, "wx_service_app_001");
401
+ assert.strictEqual(j.app_screenshots.home, "ENV_HOME_MID");
402
+ // app-assets 上传 (mock media) → 表优先于 env
403
+ const r = await fetch(base + "/api/sp/app-assets", {
404
+ method: "POST", headers: H,
405
+ body: JSON.stringify({ assets: {
406
+ home: { filename: "home.jpg", content_b64: Buffer.from("fake-home").toString("base64") },
407
+ pay: { filename: "pay.jpg", content_b64: Buffer.from("fake-pay").toString("base64") },
408
+ } }),
409
+ });
410
+ j = await r.json();
411
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
412
+ assert.ok(j.saved.home.startsWith("MOCK_MEDIA_") && j.saved.pay.startsWith("MOCK_MEDIA_"));
413
+ const savedIds = j.saved; // 留着对照 (j 下面要复用)
414
+ j = await (await fetch(base + "/api/sp/info")).json();
415
+ assert.strictEqual(j.app_screenshots.home, savedIds.home, "表优先于 env");
416
+ assert.strictEqual(j.app_screenshots.pay, savedIds.pay);
417
+ // 鉴权: 无 key 401
418
+ const r2 = await fetch(base + "/api/sp/app-assets", {
419
+ method: "POST", headers: { "Content-Type": "application/json" },
420
+ body: JSON.stringify({ assets: { home: { filename: "a.jpg", content_b64: "AAAA" } } }),
421
+ });
422
+ assert.strictEqual(r2.status, 401);
423
+ } finally {
424
+ srv.close();
425
+ delete process.env.SP_APP_APPID; delete process.env.SP_APP_ASSETS_JSON;
426
+ }
427
+ });