trustbase-seller-backend 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/onboard.html +126 -11
- package/routes/onboard.js +37 -2
- package/routes/sp-applyment.js +86 -17
- package/test-onboard-applyment.js +110 -0
- package/test-sp-applyment.js +36 -0
package/package.json
CHANGED
package/routes/onboard.html
CHANGED
|
@@ -63,6 +63,11 @@ 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
|
+
/* 特殊资质槽 (就地位: 行业选择正下方, 紧凑矮版 — 选了餐饮下面立刻要食品经营许可证) */
|
|
67
|
+
#apply-form #a_up_qual_wrap{margin-top:4px}
|
|
68
|
+
#apply-form #a_up_qual_wrap label.sbtn{padding:10px;gap:2px}
|
|
69
|
+
#apply-form #a_up_qual_wrap label.sbtn::before{font-size:16px}
|
|
70
|
+
#apply-form #a_up_qual_wrap .upfilled .auprev{max-height:110px}
|
|
66
71
|
#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
72
|
/* 上传卡片 (结构由 mountApplyUpload 生成: 标题label + label.sbtn>input + span#st_*) */
|
|
68
73
|
#apply-form .auploads{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
|
|
@@ -99,7 +104,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
99
104
|
#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
105
|
#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
106
|
/* 经营场景选择卡 (2026-10-03 三场景) */
|
|
102
|
-
#apply-form .ascene-row{display:grid;grid-template-columns:repeat(
|
|
107
|
+
#apply-form .ascene-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
|
|
103
108
|
#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
109
|
#apply-form .ascene input{display:none}
|
|
105
110
|
#apply-form .ascene .ascene-ico{font-size:24px;line-height:1}
|
|
@@ -181,7 +186,9 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
181
186
|
<div class="acard">
|
|
182
187
|
<div class="acard-h">经营场景 <span class="aopt">三选一, 决定第 4/5 张卡片要什么资料</span></div>
|
|
183
188
|
<div class="ascene-row">
|
|
184
|
-
<label class="ascene selected"><input type="radio" name="sales_scene" value="
|
|
189
|
+
<label class="ascene selected"><input type="radio" name="sales_scene" value="SALES_SCENES_APP" checked onchange="setScene(this.value)">
|
|
190
|
+
<span class="ascene-ico">📲</span><span class="ascene-name">APP 应用</span><span class="ascene-sub">默认: 用服务商应用, 零自备</span></label>
|
|
191
|
+
<label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_STORE" onchange="setScene(this.value)">
|
|
185
192
|
<span class="ascene-ico">🏬</span><span class="ascene-name">线下门店</span><span class="ascene-sub">门店地址 + 门头/店内照片</span></label>
|
|
186
193
|
<label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_WEB" onchange="setScene(this.value)">
|
|
187
194
|
<span class="ascene-ico">🌐</span><span class="ascene-name">互联网网站</span><span class="ascene-sub">网址 + 截图, 需 ICP 备案</span></label>
|
|
@@ -205,6 +212,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
205
212
|
<select id="a_industry_cat"></select></div>
|
|
206
213
|
<div class="afield"><label for="a_settlement_id">所属行业·子类 (微信行业名称)</label>
|
|
207
214
|
<select id="a_settlement_id"></select></div>
|
|
215
|
+
<div class="afield afield-full" id="a_up_qual_wrap" style="display:none"><div id="a_up_qual"></div></div>
|
|
208
216
|
</div>
|
|
209
217
|
</div>
|
|
210
218
|
|
|
@@ -258,6 +266,18 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
258
266
|
|
|
259
267
|
<div class="acard">
|
|
260
268
|
<div class="acard-h"><span class="anum">4</span><span id="acard4-title">门店信息</span></div>
|
|
269
|
+
<div id="scene_fields_app">
|
|
270
|
+
<div class="agrid">
|
|
271
|
+
<div class="afield"><label for="a_app_appid">应用 AppID</label><input id="a_app_appid" placeholder="默认由服务商提供">
|
|
272
|
+
<div class="hint" id="a_appid_note" style="display:none;font-size:12px;color:#16A34A;margin-top:3px">✓ 由服务商提供, 无需自备</div></div>
|
|
273
|
+
<div class="afield"><label for="a_app_provider">应用归属</label>
|
|
274
|
+
<select id="a_app_provider"><option value="sp">服务商应用 (默认, 零自备)</option><option value="own">商户自有应用</option></select></div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="ahint" id="a_app_reuse_row" style="display:none;margin-top:8px">
|
|
277
|
+
<label style="cursor:pointer"><input type="checkbox" id="a_app_reuse" checked style="width:auto;margin-right:6px">使用服务商提供的应用截图 ✓ (推荐, 不用自己传)</label>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="ahint" id="a_app_own_hint" style="display:none;margin-top:8px">使用自有应用: 请填自己的 AppID 并上传 4 张应用截图</div>
|
|
280
|
+
</div>
|
|
261
281
|
<div class="agrid" id="scene_fields_store">
|
|
262
282
|
<div class="afield"><label for="a_store_name">门店名称</label><input id="a_store_name" placeholder="线下场所名称"></div>
|
|
263
283
|
<div class="afield"><label for="a_store_address_code">门店省市编码</label>
|
|
@@ -291,7 +311,9 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
291
311
|
<div id="a_up_idfront"></div>
|
|
292
312
|
<div id="a_up_idback"></div>
|
|
293
313
|
<div id="a_up_s1"></div>
|
|
294
|
-
<div id="a_up_s2"></div>
|
|
314
|
+
<div id="a_up_s2"></div>
|
|
315
|
+
<div id="a_up_s3" style="display:none"></div>
|
|
316
|
+
<div id="a_up_s4" style="display:none"></div>
|
|
295
317
|
</div>
|
|
296
318
|
</div>
|
|
297
319
|
|
|
@@ -616,30 +638,54 @@ mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (
|
|
|
616
638
|
mountApplyUpload('a_up_idback','legal_id_back_media_id','身份证国徽面 (必传)');
|
|
617
639
|
// 经营场景 (2026-10-03 xiaomu 拍板三选一): 场景决定第 4 卡片字段 + 第 5 卡片场景照片槽
|
|
618
640
|
var SCENE_UPLOADS={
|
|
641
|
+
SALES_SCENES_APP:[['a_up_s1','app_home_media_id','应用首页截图 (必传)'],['a_up_s2','app_tail_media_id','应用尾页截图 (必传)'],
|
|
642
|
+
['a_up_s3','app_inner_media_id','应用内页截图 (必传)'],['a_up_s4','app_pay_media_id','应用支付页截图 (必传)']],
|
|
619
643
|
SALES_SCENES_STORE:[['a_up_s1','store_entrance_media_id','门店门头照片 (必传)'],['a_up_s2','store_indoor_media_id','门店内部照片 (必传)']],
|
|
620
644
|
SALES_SCENES_WEB:[['a_up_s1','web_home_media_id','网站首页截图 (必传)'],['a_up_s2','web_product_media_id','商品/服务页截图 (必传)']],
|
|
621
645
|
SALES_SCENES_MP:[['a_up_s1','mp_media_id','公众号/小程序页面截图 (必传)']]
|
|
622
646
|
};
|
|
623
|
-
var SCENE_TITLES={SALES_SCENES_STORE:'门店信息',SALES_SCENES_WEB:'网站信息',SALES_SCENES_MP:'公众号 / 小程序'};
|
|
647
|
+
var SCENE_TITLES={SALES_SCENES_APP:'应用信息 (APP)',SALES_SCENES_STORE:'门店信息',SALES_SCENES_WEB:'网站信息',SALES_SCENES_MP:'公众号 / 小程序'};
|
|
624
648
|
function currentScene(){
|
|
625
649
|
var r=document.querySelector('input[name="sales_scene"]:checked');
|
|
626
|
-
return r?r.value:'
|
|
650
|
+
return r?r.value:'SALES_SCENES_APP'; // 默认 APP (普通商户零自备)
|
|
627
651
|
}
|
|
628
652
|
function setScene(scene){
|
|
653
|
+
document.getElementById('scene_fields_app').style.display=scene==='SALES_SCENES_APP'?'':'none';
|
|
629
654
|
document.getElementById('scene_fields_store').style.display=scene==='SALES_SCENES_STORE'?'':'none';
|
|
630
655
|
document.getElementById('scene_fields_web').style.display=scene==='SALES_SCENES_WEB'?'':'none';
|
|
631
656
|
document.getElementById('scene_fields_mp').style.display=scene==='SALES_SCENES_MP'?'':'none';
|
|
632
|
-
document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'
|
|
657
|
+
document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'应用信息 (APP)';
|
|
633
658
|
document.querySelectorAll('.ascene').forEach(function(c){
|
|
634
659
|
c.classList.toggle('selected', c.querySelector('input').value===scene);
|
|
635
660
|
});
|
|
636
661
|
// 场景照片槽重挂 + 清掉旧场景 media_id (避免跨场景串数据)
|
|
637
|
-
['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'
|
|
638
|
-
|
|
662
|
+
['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id',
|
|
663
|
+
'app_home_media_id','app_tail_media_id','app_inner_media_id','app_pay_media_id'].forEach(function(k){delete _aMedia[k];});
|
|
664
|
+
var slots=SCENE_UPLOADS[scene]||SCENE_UPLOADS.SALES_SCENES_APP;
|
|
639
665
|
slots.forEach(function(s){var el=document.getElementById(s[0]);el.style.display='';mountApplyUpload(s[0],s[1],s[2]);});
|
|
640
|
-
|
|
666
|
+
['a_up_s2','a_up_s3','a_up_s4'].forEach(function(id){
|
|
667
|
+
if(!slots.some(function(s){return s[0]===id;})) document.getElementById(id).style.display='none';
|
|
668
|
+
});
|
|
669
|
+
paintAppScene(); // APP 场景: 服务商素材复用行显隐 + 截图槽联动
|
|
670
|
+
}
|
|
671
|
+
// APP 场景 (普通商户零自备): 服务商已配素材 → 默认勾选"使用服务商提供的应用截图", 截图槽隐藏;
|
|
672
|
+
// 取消勾选或选自有应用 → 显示 4 个截图槽自己传
|
|
673
|
+
function paintAppScene(){
|
|
674
|
+
if(currentScene()!=='SALES_SCENES_APP') return;
|
|
675
|
+
var spAssets=(window._spInfo&&window._spInfo.app_screenshots)||{};
|
|
676
|
+
var hasSp=Object.keys(spAssets).length>0;
|
|
677
|
+
var reuseRow=document.getElementById('a_app_reuse_row');
|
|
678
|
+
if(reuseRow) reuseRow.style.display=hasSp?'':'none';
|
|
679
|
+
var provider=(document.getElementById('a_app_provider')||{}).value||'sp';
|
|
680
|
+
var reuse=hasSp&&provider==='sp'&&(document.getElementById('a_app_reuse')||{}).checked;
|
|
681
|
+
['a_up_s1','a_up_s2','a_up_s3','a_up_s4'].forEach(function(id){
|
|
682
|
+
var el=document.getElementById(id);
|
|
683
|
+
if(el) el.style.display=reuse?'none':'';
|
|
684
|
+
});
|
|
685
|
+
var ownHint=document.getElementById('a_app_own_hint');
|
|
686
|
+
if(ownHint) ownHint.style.display=(provider==='own'||!reuse)&&!hasSp?'':'none';
|
|
641
687
|
}
|
|
642
|
-
setScene('
|
|
688
|
+
setScene('SALES_SCENES_APP'); // 默认 APP (普通商户零自备)
|
|
643
689
|
function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
|
|
644
690
|
function applyFormBody(){
|
|
645
691
|
var scene=currentScene();
|
|
@@ -653,6 +699,7 @@ function applyFormBody(){
|
|
|
653
699
|
account_type:aval('a_account_type'),account_name:aval('a_account_name'),account_number:aval('a_account_number'),
|
|
654
700
|
account_bank:aval('a_account_bank'),bank_name:aval('a_bank_name'),bank_address_code:aval('a_bank_address_code'),
|
|
655
701
|
legal_id_address:aval('a_legal_id_address'), // 企业主体必填 (F4), 个体户留空
|
|
702
|
+
qual_media_id:_aMedia.qual_media_id||'', // 特殊资质 (需资质行业必传 → 后端组装 qualifications 数组)
|
|
656
703
|
sales_scene:scene,
|
|
657
704
|
license_media_id:_aMedia.license_media_id||'',legal_id_front_media_id:_aMedia.legal_id_front_media_id||'',
|
|
658
705
|
legal_id_back_media_id:_aMedia.legal_id_back_media_id||''
|
|
@@ -663,6 +710,17 @@ function applyFormBody(){
|
|
|
663
710
|
}else if(scene==='SALES_SCENES_WEB'){
|
|
664
711
|
body.website_url=aval('a_website_url');
|
|
665
712
|
body.web_home_media_id=_aMedia.web_home_media_id||'';body.web_product_media_id=_aMedia.web_product_media_id||'';
|
|
713
|
+
}else if(scene==='SALES_SCENES_APP'){
|
|
714
|
+
var provider=aval('a_app_provider')||'sp';
|
|
715
|
+
body.appid=aval('a_app_appid');
|
|
716
|
+
body.appid_provider=provider; // sp=服务商应用(默认零自备) / own=商户自有应用
|
|
717
|
+
var spAssets=(window._spInfo&&window._spInfo.app_screenshots)||{};
|
|
718
|
+
var reuse=provider==='sp'&&Object.keys(spAssets).length>0&&(document.getElementById('a_app_reuse')||{}).checked;
|
|
719
|
+
if(reuse){
|
|
720
|
+
body.app_screenshots=['home','tail','inner','pay'].map(function(k){return spAssets[k];}).filter(Boolean); // 复用服务商素材
|
|
721
|
+
}else{
|
|
722
|
+
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);
|
|
723
|
+
}
|
|
666
724
|
}else{
|
|
667
725
|
body.appid=aval('a_appid');body.appid_type=aval('a_appid_type');
|
|
668
726
|
body.mp_media_id=_aMedia.mp_media_id||'';
|
|
@@ -865,7 +923,7 @@ var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',
|
|
|
865
923
|
store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
|
|
866
924
|
website_url:'a_website_url',appid:'a_appid',appid_type:'a_appid_type'};
|
|
867
925
|
var PREFILL_MEDIA=['license_media_id','legal_id_front_media_id','legal_id_back_media_id',
|
|
868
|
-
'store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'];
|
|
926
|
+
'store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id','qual_media_id'];
|
|
869
927
|
var _prefilled=false;
|
|
870
928
|
function prefillApplyForm(snap){
|
|
871
929
|
if(_prefilled||!snap||typeof snap!=='object') return;
|
|
@@ -914,6 +972,14 @@ async function loadSpInfo(){
|
|
|
914
972
|
try{
|
|
915
973
|
var j=await readJson(await fetch('/api/onboard/sp-info'));
|
|
916
974
|
if(!j||!j.name) return;
|
|
975
|
+
window._spInfo=j; // APP 场景零自备: app_appid/app_screenshots 预填复用
|
|
976
|
+
var appidEl=document.getElementById('a_app_appid');
|
|
977
|
+
if(appidEl&&j.app_appid&&!appidEl.value){
|
|
978
|
+
appidEl.value=j.app_appid;
|
|
979
|
+
var note=document.getElementById('a_appid_note');
|
|
980
|
+
if(note) note.style.display='block';
|
|
981
|
+
}
|
|
982
|
+
paintAppScene(); // 素材复用行按服务商配置刷新
|
|
917
983
|
card.style.display='block';
|
|
918
984
|
card.classList.toggle('offline', !!j.offline);
|
|
919
985
|
document.getElementById('sp-name').textContent=j.offline&&!j.stale?'服务商暂时不可达':j.name;
|
|
@@ -1076,7 +1142,56 @@ async function restoreIdentity(){
|
|
|
1076
1142
|
var def=INDUSTRY_DEFAULT_SUB[type];
|
|
1077
1143
|
stl.value=(subs.some(function(s){return s.n===cur;})&&cur)?cur
|
|
1078
1144
|
:(subs.some(function(s){return s.n===def;})?def:(subs[0]?subs[0].n:''));
|
|
1145
|
+
paintQualSlot(); // 行业变化 → 资质槽显隐
|
|
1079
1146
|
}
|
|
1147
|
+
// 行业 → 特殊资质映射 (微信 qualifications: 对照表"特殊资质"列有值的行业, 选需资质行业必传许可证)
|
|
1148
|
+
var QUAL_REQUIRED={
|
|
1149
|
+
'餐饮':'食品经营许可证 / 餐饮服务许可证 (三选一, 或门头+店内+收银台三张照片)',
|
|
1150
|
+
'食品生鲜':'食品经营许可证',
|
|
1151
|
+
'私立/民营医院/诊所':'医疗机构执业许可证',
|
|
1152
|
+
'保健器械/医疗器械/非处方药品':'药品经营许可证 / 医疗器械经营企业许可证',
|
|
1153
|
+
'教育/培训/考试缴费/学费':'办学许可证',
|
|
1154
|
+
'游艺厅/KTV/网吧':'娱乐场所经营许可证 / 网络文化经营许可证 (网吧)',
|
|
1155
|
+
'院线影城':'电影放映经营许可证',
|
|
1156
|
+
'演出赛事':'营业性演出许可证',
|
|
1157
|
+
'机票/机票代理':'航空公司营业执照 或 代理资格证',
|
|
1158
|
+
'宠物医院':'动物诊疗许可证',
|
|
1159
|
+
'旅行社':'旅行社业务经营许可证',
|
|
1160
|
+
'房地产/房产中介':'房地产五证 (建设用地规划/建设工程规划/施工/预售/产权)',
|
|
1161
|
+
'文物经营/文物复制品销售':'文物经营许可证',
|
|
1162
|
+
'拍卖典当':'拍卖经营批准证书 / 典当经营许可证',
|
|
1163
|
+
'电信运营商/宽带收费':'基础电信业务经营许可证',
|
|
1164
|
+
'共享服务':'对应行业经营许可/备案',
|
|
1165
|
+
'保健美容SPA':'卫生许可证'
|
|
1166
|
+
};
|
|
1167
|
+
var _qualIndustry='';
|
|
1168
|
+
// 选中需资质行业 → 第⑤卡动态出现"特殊资质 (必传)"上传槽 (复用上传槽双态机);
|
|
1169
|
+
// 切回不需资质行业 → 槽隐藏且清空 (不留 media_id 污染)
|
|
1170
|
+
function paintQualSlot(){
|
|
1171
|
+
var wrap=document.getElementById('a_up_qual_wrap');
|
|
1172
|
+
if(!wrap||!stl) return;
|
|
1173
|
+
var ind=stl.value;
|
|
1174
|
+
var doc=QUAL_REQUIRED[ind]||'';
|
|
1175
|
+
if(!doc){
|
|
1176
|
+
wrap.style.display='none';
|
|
1177
|
+
if(_qualIndustry&&_aSlots.qual_media_id) _aSlots.qual_media_id.clear();
|
|
1178
|
+
delete _aMedia.qual_media_id;
|
|
1179
|
+
_qualIndustry='';
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
wrap.style.display='';
|
|
1183
|
+
if(_qualIndustry!==ind){
|
|
1184
|
+
delete _aMedia.qual_media_id; // 换行业=换资质, 清旧 media
|
|
1185
|
+
mountApplyUpload('a_up_qual','qual_media_id','特殊资质 (必传): '+doc);
|
|
1186
|
+
_qualIndustry=ind;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if(stl){ stl.addEventListener('change',paintQualSlot); }
|
|
1190
|
+
// APP 场景联动: 复用勾选/应用归属切换 → 截图槽显隐
|
|
1191
|
+
var appReuse=document.getElementById('a_app_reuse');
|
|
1192
|
+
if(appReuse) appReuse.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
|
|
1193
|
+
var appProv=document.getElementById('a_app_provider');
|
|
1194
|
+
if(appProv) appProv.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
|
|
1080
1195
|
if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
|
|
1081
1196
|
// 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
|
|
1082
1197
|
var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
|
package/routes/onboard.js
CHANGED
|
@@ -620,11 +620,23 @@ 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"] },
|
|
626
627
|
};
|
|
627
|
-
const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
|
|
628
|
+
const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
|
|
629
|
+
// 行业 → 特殊资质映射 (微信 qualifications: 需资质行业缺证必被拒; 与页面 QUAL_REQUIRED 同口径)
|
|
630
|
+
const APPLY_QUAL_REQUIRED = {
|
|
631
|
+
"餐饮": "食品经营许可证/餐饮服务许可证", "食品生鲜": "食品经营许可证",
|
|
632
|
+
"私立/民营医院/诊所": "医疗机构执业许可证", "保健器械/医疗器械/非处方药品": "药品经营许可证/医疗器械经营企业许可证",
|
|
633
|
+
"教育/培训/考试缴费/学费": "办学许可证", "游艺厅/KTV/网吧": "娱乐场所经营许可证/网络文化经营许可证",
|
|
634
|
+
"院线影城": "电影放映经营许可证", "演出赛事": "营业性演出许可证",
|
|
635
|
+
"机票/机票代理": "航空公司营业执照或代理资格证", "宠物医院": "动物诊疗许可证",
|
|
636
|
+
"旅行社": "旅行社业务经营许可证", "房地产/房产中介": "房地产五证",
|
|
637
|
+
"文物经营/文物复制品销售": "文物经营许可证", "拍卖典当": "拍卖经营批准证书/典当经营许可证",
|
|
638
|
+
"电信运营商/宽带收费": "基础电信业务经营许可证", "共享服务": "对应行业经营许可/备案", "保健美容SPA": "卫生许可证",
|
|
639
|
+
};
|
|
628
640
|
|
|
629
641
|
/** SP 网关配置 (懒读 env, 测试可注入 deps.fetcher) */
|
|
630
642
|
function spGatewayUrl() {
|
|
@@ -796,6 +808,20 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
|
|
|
796
808
|
return res.status(400).json({ ok: false, error: "invalid_param", field: k, message: `缺少必填字段: ${label} (${k})` });
|
|
797
809
|
}
|
|
798
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
|
+
}
|
|
819
|
+
// 需资质行业 → 特殊资质图片必传 (微信 qualifications, 缺证必被拒)
|
|
820
|
+
const qualNeed = APPLY_QUAL_REQUIRED[String(b.qualification_type || "")];
|
|
821
|
+
if (qualNeed && !b.qual_media_id) {
|
|
822
|
+
return res.status(400).json({ ok: false, error: "media_missing", field: "qual_media_id",
|
|
823
|
+
message: `请上传: ${b.qualification_type} 所需资质 (${qualNeed})` });
|
|
824
|
+
}
|
|
799
825
|
for (const k of APPLY_MEDIA_COMMON.concat(sceneCfg.media)) {
|
|
800
826
|
if (!b[k]) {
|
|
801
827
|
const label = APPLY_LABELS[k] || k;
|
|
@@ -814,7 +840,16 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
|
|
|
814
840
|
for (const k of Object.keys(b)) {
|
|
815
841
|
if (typeof b[k] === "string" && b[k].trim()) payload[k] = b[k].trim();
|
|
816
842
|
}
|
|
817
|
-
|
|
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
|
+
}
|
|
847
|
+
payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
|
|
848
|
+
// 特殊资质: qual_media_id → qualifications 数组 (没有则不传该字段)
|
|
849
|
+
if (payload.qual_media_id) {
|
|
850
|
+
payload.qualifications = [payload.qual_media_id];
|
|
851
|
+
delete payload.qual_media_id;
|
|
852
|
+
}
|
|
818
853
|
// 通用类目资质留空 → 默认 "无" (微信通用场景接受, 避免留空被 SP 拒)
|
|
819
854
|
// settlement_id = 结算规则 ID, 按主体推导 (商户不用选; 前端不传也兜底)
|
|
820
855
|
payload.settlement_id = payload.subject_type === "SUBJECT_TYPE_ENTERPRISE" ? "716"
|
package/routes/sp-applyment.js
CHANGED
|
@@ -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") {
|
|
@@ -260,6 +269,11 @@ function buildApplymentBody(form, enc, opts) {
|
|
|
260
269
|
if (f.bank_name) body.bank_account_info.bank_name = String(f.bank_name);
|
|
261
270
|
if (f.bank_branch_id) body.bank_account_info.bank_branch_id = String(f.bank_branch_id);
|
|
262
271
|
if (f.bank_address_code) body.bank_account_info.bank_address_code = String(f.bank_address_code);
|
|
272
|
+
// 特殊资质图片数组 (微信 qualifications: 需资质行业必传, 最多 5 张; already_encrypted 直通)
|
|
273
|
+
if (Array.isArray(f.qualifications) && f.qualifications.length) {
|
|
274
|
+
body.qualifications = f.qualifications.map((x) => String(x)).filter(Boolean).slice(0, 5);
|
|
275
|
+
if (!body.qualifications.length) delete body.qualifications;
|
|
276
|
+
}
|
|
263
277
|
return body;
|
|
264
278
|
}
|
|
265
279
|
|
|
@@ -278,6 +292,26 @@ function buildMultipart(filename, fileBuf, metaStr) {
|
|
|
278
292
|
return { boundary, body: Buffer.concat([Buffer.from(head, "utf8"), fileBuf, Buffer.from(tail, "utf8")]) };
|
|
279
293
|
}
|
|
280
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
|
+
|
|
281
315
|
/** mock 微信应答 (SP_APPLYMENT_MOCK=1; 不碰真 API) */
|
|
282
316
|
function mockWx(method, urlPath) {
|
|
283
317
|
if (method === "POST" && urlPath === "/v3/merchant/media/upload") {
|
|
@@ -665,6 +699,9 @@ function createSpApplymentRouter(deps) {
|
|
|
665
699
|
rate_pct: (bp / 100).toFixed(2) + "%",
|
|
666
700
|
chain_address: process.env.SP_CHAIN_ADDR || "",
|
|
667
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),
|
|
668
705
|
});
|
|
669
706
|
});
|
|
670
707
|
|
|
@@ -674,34 +711,66 @@ function createSpApplymentRouter(deps) {
|
|
|
674
711
|
res.send(PAGE_HTML);
|
|
675
712
|
});
|
|
676
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
|
+
|
|
677
732
|
/** POST /applyment/upload — base64 JSON {filename, content_b64} → 微信 media/upload → {media_id} */
|
|
678
733
|
router.post("/applyment/upload", async (req, res) => {
|
|
679
734
|
if (!checkAdmin(req, res)) return;
|
|
680
735
|
try {
|
|
681
736
|
const b = req.body || {};
|
|
682
737
|
const filename = String(b.filename || "");
|
|
683
|
-
if (!/\.(jpe?g|png|bmp)$/i.test(filename)) {
|
|
684
|
-
throw new HttpError(400, "invalid_param", "filename 仅支持 jpg/jpeg/png/bmp 后缀");
|
|
685
|
-
}
|
|
686
738
|
const buf = Buffer.from(String(b.content_b64 || ""), "base64");
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const meta = JSON.stringify({ filename, sha256 }); // 签名串 body 用 meta JSON 串 (非整个 multipart)
|
|
691
|
-
const mp = buildMultipart(filename, buf, meta);
|
|
692
|
-
const r = await wxRequest(deps, "POST", "/v3/merchant/media/upload", {
|
|
693
|
-
signBody: meta,
|
|
694
|
-
body: mp.body,
|
|
695
|
-
contentType: "multipart/form-data; boundary=" + mp.boundary,
|
|
696
|
-
});
|
|
697
|
-
if (r.status !== 200 || !r.json.media_id) return wxError(res, r, "上传图片到微信失败");
|
|
698
|
-
console.log(`[sp-applyment] 图片已上传 file=${filename} size=${buf.length} media_id=${String(r.json.media_id).slice(0, 24)}...`);
|
|
699
|
-
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 });
|
|
700
742
|
} catch (e) {
|
|
701
743
|
return chainWrites.handleError(res, e, "/api/sp/applyment/upload");
|
|
702
744
|
}
|
|
703
745
|
});
|
|
704
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
|
+
|
|
705
774
|
/** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
|
|
706
775
|
* 幂等/重提 (2026-10-03 F1/F3 fix):
|
|
707
776
|
* - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
|
|
@@ -685,3 +685,113 @@ test("草稿页面逻辑: 自动保存/恢复优先级/FINISHED 清除/提交保
|
|
|
685
685
|
assert.ok(html.indexOf("applyment/draft") < html.indexOf("form_snapshot") || html.includes("恢复优先级"), "草稿优先于快照");
|
|
686
686
|
} finally { srv.close(); }
|
|
687
687
|
});
|
|
688
|
+
|
|
689
|
+
test("特殊资质: 餐饮无许可证 → 400 中文提示; 有证 → 转发体 qualifications 数组", async () => {
|
|
690
|
+
const { srv, base } = await startSeller();
|
|
691
|
+
try {
|
|
692
|
+
const form = await fillForm(base);
|
|
693
|
+
form.qualification_type = "餐饮"; // 需资质行业
|
|
694
|
+
// 无资质 → 400 中文提示含资质名
|
|
695
|
+
let r = await fetch(base + "/api/onboard/applyment", {
|
|
696
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
697
|
+
});
|
|
698
|
+
let j = await r.json();
|
|
699
|
+
assert.strictEqual(r.status, 400);
|
|
700
|
+
assert.strictEqual(j.error, "media_missing");
|
|
701
|
+
assert.strictEqual(j.field, "qual_media_id");
|
|
702
|
+
assert.ok(j.message.includes("餐饮") && j.message.includes("食品经营"), j.message);
|
|
703
|
+
// 上传资质 → 转发体 qualifications=["media_id"], SP 侧顶层直通
|
|
704
|
+
const up = await fetch(base + "/api/onboard/applyment/upload", {
|
|
705
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
706
|
+
body: JSON.stringify({ filename: "qual.jpg", content_b64: Buffer.from("fake-qual").toString("base64") }),
|
|
707
|
+
});
|
|
708
|
+
form.qual_media_id = (await up.json()).media_id;
|
|
709
|
+
const before = spCapture.submits.length;
|
|
710
|
+
r = await fetch(base + "/api/onboard/applyment", {
|
|
711
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
712
|
+
});
|
|
713
|
+
j = await r.json();
|
|
714
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
715
|
+
const fwd = spCapture.submits[before];
|
|
716
|
+
assert.deepStrictEqual(fwd.qualifications, [form.qual_media_id], "qualifications 数组");
|
|
717
|
+
assert.ok(!("qual_media_id" in fwd), "中间字段已转换");
|
|
718
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
719
|
+
assert.deepStrictEqual(wxBody.qualifications, [form.qual_media_id], "SP 顶层 qualifications 直通");
|
|
720
|
+
} finally { srv.close(); }
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("特殊资质页面逻辑: QUAL_REQUIRED 映射 + 动态槽位 + 双态复用", async () => {
|
|
724
|
+
const { srv, base } = await startSeller();
|
|
725
|
+
try {
|
|
726
|
+
const html = await (await fetch(base + "/onboard/")).text();
|
|
727
|
+
assert.ok(html.includes("QUAL_REQUIRED"), "行业-资质映射表");
|
|
728
|
+
assert.ok(html.includes("a_up_qual"), "资质槽位容器");
|
|
729
|
+
assert.ok(html.includes("paintQualSlot"), "槽位显隐逻辑");
|
|
730
|
+
assert.ok(html.includes("食品经营许可证"), "餐饮资质名");
|
|
731
|
+
assert.ok(html.includes("医疗机构执业许可证"), "医疗资质名");
|
|
732
|
+
assert.ok(html.includes("办学许可证"), "教育资质名");
|
|
733
|
+
assert.ok(html.includes("qual_media_id"), "资质 media_id 字段");
|
|
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
|
+
});
|
package/test-sp-applyment.js
CHANGED
|
@@ -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
|
+
});
|