trustbase-seller-backend 0.3.5 → 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 +80 -11
- package/routes/onboard.js +13 -0
- package/routes/sp-applyment.js +81 -17
- package/test-onboard-applyment.js +62 -1
- 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,8 +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>
|
|
295
|
-
<div id="
|
|
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>
|
|
296
317
|
</div>
|
|
297
318
|
</div>
|
|
298
319
|
|
|
@@ -617,30 +638,54 @@ mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (
|
|
|
617
638
|
mountApplyUpload('a_up_idback','legal_id_back_media_id','身份证国徽面 (必传)');
|
|
618
639
|
// 经营场景 (2026-10-03 xiaomu 拍板三选一): 场景决定第 4 卡片字段 + 第 5 卡片场景照片槽
|
|
619
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','应用支付页截图 (必传)']],
|
|
620
643
|
SALES_SCENES_STORE:[['a_up_s1','store_entrance_media_id','门店门头照片 (必传)'],['a_up_s2','store_indoor_media_id','门店内部照片 (必传)']],
|
|
621
644
|
SALES_SCENES_WEB:[['a_up_s1','web_home_media_id','网站首页截图 (必传)'],['a_up_s2','web_product_media_id','商品/服务页截图 (必传)']],
|
|
622
645
|
SALES_SCENES_MP:[['a_up_s1','mp_media_id','公众号/小程序页面截图 (必传)']]
|
|
623
646
|
};
|
|
624
|
-
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:'公众号 / 小程序'};
|
|
625
648
|
function currentScene(){
|
|
626
649
|
var r=document.querySelector('input[name="sales_scene"]:checked');
|
|
627
|
-
return r?r.value:'
|
|
650
|
+
return r?r.value:'SALES_SCENES_APP'; // 默认 APP (普通商户零自备)
|
|
628
651
|
}
|
|
629
652
|
function setScene(scene){
|
|
653
|
+
document.getElementById('scene_fields_app').style.display=scene==='SALES_SCENES_APP'?'':'none';
|
|
630
654
|
document.getElementById('scene_fields_store').style.display=scene==='SALES_SCENES_STORE'?'':'none';
|
|
631
655
|
document.getElementById('scene_fields_web').style.display=scene==='SALES_SCENES_WEB'?'':'none';
|
|
632
656
|
document.getElementById('scene_fields_mp').style.display=scene==='SALES_SCENES_MP'?'':'none';
|
|
633
|
-
document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'
|
|
657
|
+
document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'应用信息 (APP)';
|
|
634
658
|
document.querySelectorAll('.ascene').forEach(function(c){
|
|
635
659
|
c.classList.toggle('selected', c.querySelector('input').value===scene);
|
|
636
660
|
});
|
|
637
661
|
// 场景照片槽重挂 + 清掉旧场景 media_id (避免跨场景串数据)
|
|
638
|
-
['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'
|
|
639
|
-
|
|
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;
|
|
640
665
|
slots.forEach(function(s){var el=document.getElementById(s[0]);el.style.display='';mountApplyUpload(s[0],s[1],s[2]);});
|
|
641
|
-
|
|
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';
|
|
642
687
|
}
|
|
643
|
-
setScene('
|
|
688
|
+
setScene('SALES_SCENES_APP'); // 默认 APP (普通商户零自备)
|
|
644
689
|
function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
|
|
645
690
|
function applyFormBody(){
|
|
646
691
|
var scene=currentScene();
|
|
@@ -665,6 +710,17 @@ function applyFormBody(){
|
|
|
665
710
|
}else if(scene==='SALES_SCENES_WEB'){
|
|
666
711
|
body.website_url=aval('a_website_url');
|
|
667
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
|
+
}
|
|
668
724
|
}else{
|
|
669
725
|
body.appid=aval('a_appid');body.appid_type=aval('a_appid_type');
|
|
670
726
|
body.mp_media_id=_aMedia.mp_media_id||'';
|
|
@@ -916,6 +972,14 @@ async function loadSpInfo(){
|
|
|
916
972
|
try{
|
|
917
973
|
var j=await readJson(await fetch('/api/onboard/sp-info'));
|
|
918
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(); // 素材复用行按服务商配置刷新
|
|
919
983
|
card.style.display='block';
|
|
920
984
|
card.classList.toggle('offline', !!j.offline);
|
|
921
985
|
document.getElementById('sp-name').textContent=j.offline&&!j.stale?'服务商暂时不可达':j.name;
|
|
@@ -1123,6 +1187,11 @@ async function restoreIdentity(){
|
|
|
1123
1187
|
}
|
|
1124
1188
|
}
|
|
1125
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(); });
|
|
1126
1195
|
if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
|
|
1127
1196
|
// 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
|
|
1128
1197
|
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) {
|
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") {
|
|
@@ -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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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,67 @@ 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"), "提交字段");
|
|
735
796
|
} finally { srv.close(); }
|
|
736
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
|
+
});
|