trustbase-seller-backend 0.3.8 → 0.4.0
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 +121 -10
- package/routes/onboard.js +13 -2
- package/routes/sp-applyment.js +504 -8
- package/test-onboard-applyment.js +25 -0
- package/test-sp-applyment.js +115 -0
package/package.json
CHANGED
package/routes/onboard.html
CHANGED
|
@@ -293,6 +293,8 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
293
293
|
<div class="hint" id="a_appid_note" style="display:none;font-size:12px;color:#16A34A;margin-top:3px">✓ 由服务商提供</div></div>
|
|
294
294
|
<div class="afield"><label for="a_app_provider">应用归属</label>
|
|
295
295
|
<select id="a_app_provider"><option value="sp">服务商应用 (默认, 零自备)</option><option value="own">商户自有应用</option></select></div>
|
|
296
|
+
<div class="afield afield-full" id="a_app_select_row" style="display:none"><label for="a_app_select">选择服务商应用</label>
|
|
297
|
+
<select id="a_app_select"></select></div>
|
|
296
298
|
</div>
|
|
297
299
|
<div class="ahint" id="a_app_provided" style="display:none;margin-top:8px;background:#F0FDF4;border-color:#BBF7D0;color:#166534"></div>
|
|
298
300
|
<div class="ahint" id="a_app_missing" style="display:none;margin-top:8px">⚠️ 服务商尚未配置应用,请联系服务商配置,或改用下方"商户自有应用"填写</div>
|
|
@@ -310,13 +312,18 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
310
312
|
</div>
|
|
311
313
|
<div id="scene_fields_web" style="display:none">
|
|
312
314
|
<div class="agrid">
|
|
313
|
-
<div class="afield afield-full"
|
|
315
|
+
<div class="afield afield-full" id="a_web_sp_row" style="display:none"><label for="a_web_spapp">使用服务商提供的网站</label>
|
|
316
|
+
<select id="a_web_spapp"></select></div>
|
|
317
|
+
<div class="afield afield-full" id="a_website_field"><label for="a_website_url">网站网址</label><input id="a_website_url" placeholder="https:// 开头, 需已上线可访问"></div>
|
|
314
318
|
</div>
|
|
315
319
|
<div class="ahint">⚠️ 网站需已上线且有 ICP 备案, 微信审核会实际访问验证</div>
|
|
316
320
|
</div>
|
|
317
321
|
<div id="scene_fields_mp" style="display:none">
|
|
318
322
|
<div class="agrid">
|
|
319
|
-
<div class="afield"><label for="a_appid">公众号/小程序 AppID</label><input id="a_appid" placeholder="wx 开头"></div>
|
|
323
|
+
<div class="afield" id="a_mpid_field"><label for="a_appid">公众号/小程序 AppID</label><input id="a_appid" placeholder="wx 开头"></div>
|
|
324
|
+
<div class="afield afield-full" id="a_mp_sp_row" style="display:none"><label for="a_mp_spapp">使用服务商提供的小程序应用</label>
|
|
325
|
+
<select id="a_mp_spapp"></select>
|
|
326
|
+
<div id="a_mp_sp_note" style="font-size:12px;color:#16A34A;margin-top:4px"></div></div>
|
|
320
327
|
<div class="afield"><label for="a_appid_type">类型</label>
|
|
321
328
|
<select id="a_appid_type"><option value="mp">公众号</option><option value="mini">小程序</option></select></div>
|
|
322
329
|
</div>
|
|
@@ -700,7 +707,27 @@ function setScene(scene){
|
|
|
700
707
|
// APP 场景 (2026-10-03 UX 修正): 服务商配置完整 (AppID+4素材) → AppID 框和截图槽全隐藏,
|
|
701
708
|
// 只留绿标"应用和截图由服务商提供, 无需操作"; 未配齐 → 提示联系服务商 + 展开自有字段组;
|
|
702
709
|
// 选自有应用 → AppID 输入框 + 4 张截图槽。
|
|
710
|
+
// 服务商应用列表 (多形态多应用): 按场景 type 过滤选择
|
|
711
|
+
function spAppsOf(type){ return ((window._spInfo||{}).apps||[]).filter(function(a){return a.type===type;}); }
|
|
712
|
+
function defaultAppOf(type){
|
|
713
|
+
var l=spAppsOf(type);
|
|
714
|
+
return l.filter(function(a){return a.is_default;})[0]||l[0]||null;
|
|
715
|
+
}
|
|
716
|
+
function selectedSpApp(type, selectId){
|
|
717
|
+
var list=spAppsOf(type);
|
|
718
|
+
if(!list.length) return null;
|
|
719
|
+
var sel=selectId?document.getElementById(selectId):null;
|
|
720
|
+
if(sel&&sel.value){
|
|
721
|
+
var hit=list.filter(function(a){return String(a.id)===sel.value;})[0];
|
|
722
|
+
if(hit) return hit;
|
|
723
|
+
}
|
|
724
|
+
return defaultAppOf(type);
|
|
725
|
+
}
|
|
726
|
+
function appAssetCount(app){ return ['home','tail','inner','pay'].filter(function(k){return app&&app.assets&&app.assets[k];}).length; }
|
|
703
727
|
function appSpComplete(){
|
|
728
|
+
// 零操作 = 选中的服务商应用 AppID+4 素材全在 (旧单配置兼容: /info 的 app_appid/app_screenshots)
|
|
729
|
+
var app=selectedSpApp('app','a_app_select');
|
|
730
|
+
if(app) return !!(app.appid&&appAssetCount(app)===4);
|
|
704
731
|
var sp=window._spInfo||{}, a=sp.app_screenshots||{};
|
|
705
732
|
return !!(sp.app_appid&&a.home&&a.tail&&a.inner&&a.pay);
|
|
706
733
|
}
|
|
@@ -713,10 +740,11 @@ function paintAppScene(){
|
|
|
713
740
|
var missing=provider==='sp'&&!complete; // 服务商未配齐 → 提示+自有组
|
|
714
741
|
var fAppid=document.getElementById('a_appid_field');
|
|
715
742
|
if(fAppid) fAppid.style.display=zeroMode?'none':'';
|
|
743
|
+
var selApp=selectedSpApp('app','a_app_select');
|
|
716
744
|
var provided=document.getElementById('a_app_provided');
|
|
717
745
|
if(provided){
|
|
718
746
|
provided.style.display=zeroMode?'':'none';
|
|
719
|
-
if(zeroMode) provided.textContent='✓ 应用和截图由服务商提供 (AppID: '+maskAppid((window._spInfo||{}).app_appid)+'), 无需操作';
|
|
747
|
+
if(zeroMode) provided.textContent='✓ 应用和截图由服务商提供 ('+(selApp?selApp.name+' · ':'')+'AppID: '+maskAppid(selApp?selApp.appid:(window._spInfo||{}).app_appid)+'), 无需操作';
|
|
720
748
|
}
|
|
721
749
|
var missEl=document.getElementById('a_app_missing');
|
|
722
750
|
if(missEl) missEl.style.display=missing?'':'none';
|
|
@@ -732,6 +760,72 @@ function paintAppScene(){
|
|
|
732
760
|
appidEl.placeholder='wx 开头';
|
|
733
761
|
}
|
|
734
762
|
}
|
|
763
|
+
// ---- 多应用选择联动 (2026-10-03 服务商应用列表): APP/小程序/网站 按场景 type 选服务商应用 ----
|
|
764
|
+
function paintSceneSpRows(){
|
|
765
|
+
// APP: 应用下拉 (>1 且归属=服务商 才显示; 单个自动默认)
|
|
766
|
+
var appList=spAppsOf('app');
|
|
767
|
+
var appRow=document.getElementById('a_app_select_row');
|
|
768
|
+
var appSel=document.getElementById('a_app_select');
|
|
769
|
+
if(appRow&&appSel){
|
|
770
|
+
var provider=(document.getElementById('a_app_provider')||{}).value||'sp';
|
|
771
|
+
appRow.style.display=(appList.length>1&&provider==='sp')?'':'none';
|
|
772
|
+
if(appSel.options.length!==appList.length){
|
|
773
|
+
appSel.innerHTML=appList.map(function(a){return '<option value="'+a.id+'">'+escHtml(a.name)+(a.is_default?' ⭐默认':'' )+'</option>';}).join('');
|
|
774
|
+
var def=defaultAppOf('app'); if(def) appSel.value=String(def.id);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// MP: 服务商小程序行 (有 mini_program 应用才显示; 空值=自己填写)
|
|
778
|
+
var mpList=spAppsOf('mini_program');
|
|
779
|
+
var mpRow=document.getElementById('a_mp_sp_row');
|
|
780
|
+
var mpSel=document.getElementById('a_mp_spapp');
|
|
781
|
+
if(mpRow&&mpSel){
|
|
782
|
+
mpRow.style.display=mpList.length?'':'none';
|
|
783
|
+
if(mpSel.options.length!==mpList.length+1){
|
|
784
|
+
mpSel.innerHTML='<option value="">(自己填写)</option>'+mpList.map(function(a){return '<option value="'+a.id+'">'+escHtml(a.name)+(a.is_default?' ⭐':'')+'</option>';}).join('');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// WEB: 服务商网站行
|
|
788
|
+
var webList=spAppsOf('web');
|
|
789
|
+
var webRow=document.getElementById('a_web_sp_row');
|
|
790
|
+
var webSel=document.getElementById('a_web_spapp');
|
|
791
|
+
if(webRow&&webSel){
|
|
792
|
+
webRow.style.display=webList.length?'':'none';
|
|
793
|
+
if(webSel.options.length!==webList.length+1){
|
|
794
|
+
webSel.innerHTML='<option value="">(自己填写)</option>'+webList.map(function(a){return '<option value="'+a.id+'">'+escHtml(a.name)+(a.is_default?' ⭐':'')+'</option>';}).join('');
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
paintMpSp(); paintWebSp();
|
|
798
|
+
}
|
|
799
|
+
function paintMpSp(){
|
|
800
|
+
var selEl=document.getElementById('a_mp_spapp');
|
|
801
|
+
var chosen=!!(selEl&&selEl.value);
|
|
802
|
+
var f=document.getElementById('a_mpid_field');
|
|
803
|
+
if(f) f.style.display=chosen?'none':'';
|
|
804
|
+
var slot=document.getElementById('a_up_s1');
|
|
805
|
+
if(slot&¤tScene()==='SALES_SCENES_MP') slot.style.display=chosen?'none':'';
|
|
806
|
+
var note=document.getElementById('a_mp_sp_note');
|
|
807
|
+
if(note) note.textContent=chosen?'✓ 使用服务商应用, 截图素材复用, 无需操作':'';
|
|
808
|
+
}
|
|
809
|
+
function paintWebSp(){
|
|
810
|
+
var selEl=document.getElementById('a_web_spapp');
|
|
811
|
+
var chosen=!!(selEl&&selEl.value);
|
|
812
|
+
var inp=document.getElementById('a_website_url');
|
|
813
|
+
if(inp){
|
|
814
|
+
if(chosen){
|
|
815
|
+
var app=spAppsOf('web').filter(function(a){return String(a.id)===selEl.value;})[0];
|
|
816
|
+
if(app){ inp.value=app.appid; inp.readOnly=true; } // web 应用的 appid 字段存的是域名
|
|
817
|
+
}else inp.readOnly=false;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
(function(){
|
|
821
|
+
var s1=document.getElementById('a_app_select');
|
|
822
|
+
if(s1) s1.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
|
|
823
|
+
var s2=document.getElementById('a_mp_spapp');
|
|
824
|
+
if(s2) s2.addEventListener('change',function(){ paintMpSp(); saveApplyDraft(); });
|
|
825
|
+
var s3=document.getElementById('a_web_spapp');
|
|
826
|
+
if(s3) s3.addEventListener('change',function(){ paintWebSp(); saveApplyDraft(); });
|
|
827
|
+
})();
|
|
828
|
+
|
|
735
829
|
setScene('SALES_SCENES_APP'); // 默认 APP (普通商户零自备)
|
|
736
830
|
function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
|
|
737
831
|
function applyFormBody(){
|
|
@@ -760,8 +854,13 @@ function applyFormBody(){
|
|
|
760
854
|
}else if(scene==='SALES_SCENES_APP'){
|
|
761
855
|
var provider=aval('a_app_provider')||'sp';
|
|
762
856
|
body.appid_provider=provider; // sp=服务商应用(默认零自备) / own=商户自有应用
|
|
763
|
-
|
|
764
|
-
|
|
857
|
+
var selApp=selectedSpApp('app','a_app_select');
|
|
858
|
+
if(provider==='sp'&&selApp&&appSpComplete()){
|
|
859
|
+
// 零操作: AppID 和截图全部用所选服务商应用 (商户页面全隐藏)
|
|
860
|
+
body.appid=selApp.appid;
|
|
861
|
+
body.app_screenshots=['home','tail','inner','pay'].map(function(k){return selApp.assets[k];}).filter(Boolean);
|
|
862
|
+
}else if(provider==='sp'&&appSpComplete()){
|
|
863
|
+
// 旧单配置兜底 (无 sp_apps 行时)
|
|
765
864
|
body.appid=(window._spInfo||{}).app_appid;
|
|
766
865
|
var sa=(window._spInfo||{}).app_screenshots||{};
|
|
767
866
|
body.app_screenshots=['home','tail','inner','pay'].map(function(k){return sa[k];}).filter(Boolean);
|
|
@@ -770,8 +869,17 @@ function applyFormBody(){
|
|
|
770
869
|
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);
|
|
771
870
|
}
|
|
772
871
|
}else{
|
|
773
|
-
|
|
774
|
-
|
|
872
|
+
var mpSelEl=document.getElementById('a_mp_spapp');
|
|
873
|
+
var mpChosen=!!(mpSelEl&&mpSelEl.value);
|
|
874
|
+
var mpApp=mpChosen?spAppsOf('mini_program').filter(function(a){return String(a.id)===mpSelEl.value;})[0]:null;
|
|
875
|
+
if(mpChosen&&mpApp){
|
|
876
|
+
// 服务商小程序应用: appid+截图素材复用 (页面隐藏自有字段)
|
|
877
|
+
body.appid=mpApp.appid; body.appid_type='mp'; body.appid_provider='sp';
|
|
878
|
+
body.mp_screenshots=['home','tail','inner','pay'].map(function(k){return mpApp.assets[k];}).filter(Boolean);
|
|
879
|
+
}else{
|
|
880
|
+
body.appid=aval('a_appid');body.appid_type=aval('a_appid_type');
|
|
881
|
+
body.mp_media_id=_aMedia.mp_media_id||'';
|
|
882
|
+
}
|
|
775
883
|
}
|
|
776
884
|
return body;
|
|
777
885
|
}
|
|
@@ -1023,6 +1131,7 @@ async function loadSpInfo(){
|
|
|
1023
1131
|
window._spInfo=j; // APP 场景零自备: app_appid/app_screenshots 预填复用
|
|
1024
1132
|
var appidEl=document.getElementById('a_app_appid');
|
|
1025
1133
|
if(appidEl&&j.app_appid&&!appidEl.value) appidEl.value=j.app_appid;
|
|
1134
|
+
paintSceneSpRows(); // 多应用下拉 (app/mini_program/web 按场景)
|
|
1026
1135
|
paintAppScene(); // 三态显隐按服务商配置完整度刷新
|
|
1027
1136
|
card.style.display='block';
|
|
1028
1137
|
card.classList.toggle('offline', !!j.offline);
|
|
@@ -1171,7 +1280,8 @@ function wzValidate(n){
|
|
|
1171
1280
|
}else if(scene==='SALES_SCENES_WEB'){
|
|
1172
1281
|
r=need('a_website_url','网站网址','website_url');
|
|
1173
1282
|
}else if(scene==='SALES_SCENES_MP'){
|
|
1174
|
-
|
|
1283
|
+
var mpSelV=document.getElementById('a_mp_spapp');
|
|
1284
|
+
if(!(mpSelV&&mpSelV.value)) r=need('a_appid','公众号/小程序 AppID','appid');
|
|
1175
1285
|
}else{
|
|
1176
1286
|
// APP 场景: 服务商配置完整(零操作)时 AppID 框隐藏且用服务商值, 不校验输入框
|
|
1177
1287
|
if(!((document.getElementById('a_app_provider')||{}).value!=='own'&&appSpComplete())){
|
|
@@ -1205,7 +1315,8 @@ function wzValidate(n){
|
|
|
1205
1315
|
if(!_aMedia.web_home_media_id) return {ok:false,field:'web_home_media_id',msg:'请上传: 网站首页截图'};
|
|
1206
1316
|
if(!_aMedia.web_product_media_id) return {ok:false,field:'web_product_media_id',msg:'请上传: 商品/服务页截图'};
|
|
1207
1317
|
}else if(scene==='SALES_SCENES_MP'){
|
|
1208
|
-
|
|
1318
|
+
var mpSelV2=document.getElementById('a_mp_spapp');
|
|
1319
|
+
if(!(mpSelV2&&mpSelV2.value)&&!_aMedia.mp_media_id) return {ok:false,field:'mp_media_id',msg:'请上传: 公众号/小程序页面截图 (或选用服务商提供的应用)'};
|
|
1209
1320
|
}
|
|
1210
1321
|
return {ok:true};
|
|
1211
1322
|
}
|
|
@@ -1399,7 +1510,7 @@ async function restoreIdentity(){
|
|
|
1399
1510
|
if(stl){ stl.addEventListener('change',paintQualSlot); }
|
|
1400
1511
|
// APP 场景联动: 应用归属切换 → 字段组/截图槽显隐
|
|
1401
1512
|
var appProv=document.getElementById('a_app_provider');
|
|
1402
|
-
if(appProv) appProv.addEventListener('change',function(){ paintAppScene(); saveApplyDraft(); });
|
|
1513
|
+
if(appProv) appProv.addEventListener('change',function(){ paintSceneSpRows(); paintAppScene(); saveApplyDraft(); });
|
|
1403
1514
|
if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
|
|
1404
1515
|
// 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
|
|
1405
1516
|
var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
|
package/routes/onboard.js
CHANGED
|
@@ -623,7 +623,7 @@ function createOnboardRouter(deps) {
|
|
|
623
623
|
SALES_SCENES_APP: { fields: ["appid"], media: [] }, // 普通商户默认: 服务商应用零自备 (截图复用服务商素材)
|
|
624
624
|
SALES_SCENES_STORE: { fields: ["store_name", "store_address_code", "store_address"], media: ["store_entrance_media_id", "store_indoor_media_id"] },
|
|
625
625
|
SALES_SCENES_WEB: { fields: ["website_url"], media: [] },
|
|
626
|
-
SALES_SCENES_MP: { fields: ["appid"], media: [
|
|
626
|
+
SALES_SCENES_MP: { fields: ["appid"], media: [] }, // mp 截图: 服务商素材数组 或 商户单张 (下方手动校验)
|
|
627
627
|
};
|
|
628
628
|
const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
|
|
629
629
|
// 行业 → 特殊资质映射 (微信 qualifications: 需资质行业缺证必被拒; 与页面 QUAL_REQUIRED 同口径)
|
|
@@ -808,6 +808,14 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
|
|
|
808
808
|
return res.status(400).json({ ok: false, error: "invalid_param", field: k, message: `缺少必填字段: ${label} (${k})` });
|
|
809
809
|
}
|
|
810
810
|
}
|
|
811
|
+
// MP 场景: 截图 = 服务商素材数组 (mp_screenshots) 或商户单张 (mp_media_id), 二选一必传
|
|
812
|
+
if (scene === "SALES_SCENES_MP") {
|
|
813
|
+
const hasArr = Array.isArray(b.mp_screenshots) && b.mp_screenshots.filter(Boolean).length > 0;
|
|
814
|
+
if (!hasArr && !b.mp_media_id) {
|
|
815
|
+
return res.status(400).json({ ok: false, error: "media_missing", field: "mp_media_id",
|
|
816
|
+
message: "请上传公众号/小程序页面截图 (或选用服务商提供的应用)" });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
811
819
|
// APP 场景: 应用截图必传 (app_pics; 商户复用服务商素材或自己传, 页面已二选一)
|
|
812
820
|
if (scene === "SALES_SCENES_APP") {
|
|
813
821
|
const pics = Array.isArray(b.app_screenshots) ? b.app_screenshots.filter(Boolean) : [];
|
|
@@ -840,10 +848,13 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
|
|
|
840
848
|
for (const k of Object.keys(b)) {
|
|
841
849
|
if (typeof b[k] === "string" && b[k].trim()) payload[k] = b[k].trim();
|
|
842
850
|
}
|
|
843
|
-
// 数组字段透传 (通用 copy 只收字符串): APP 场景截图 media_id 数组
|
|
851
|
+
// 数组字段透传 (通用 copy 只收字符串): APP/MP 场景截图 media_id 数组
|
|
844
852
|
if (Array.isArray(b.app_screenshots) && b.app_screenshots.length) {
|
|
845
853
|
payload.app_screenshots = b.app_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5);
|
|
846
854
|
}
|
|
855
|
+
if (Array.isArray(b.mp_screenshots) && b.mp_screenshots.length) {
|
|
856
|
+
payload.mp_screenshots = b.mp_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5);
|
|
857
|
+
}
|
|
847
858
|
payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
|
|
848
859
|
// 特殊资质: qual_media_id → qualifications 数组 (没有则不传该字段)
|
|
849
860
|
if (payload.qual_media_id) {
|
package/routes/sp-applyment.js
CHANGED
|
@@ -203,9 +203,13 @@ function buildApplymentBody(form, enc, opts) {
|
|
|
203
203
|
mini_program_pics: [String(f.mp_media_id)],
|
|
204
204
|
};
|
|
205
205
|
} else {
|
|
206
|
+
// 截图: 服务商小程序应用素材数组 (mp_screenshots) 优先, 否则商户自传单张
|
|
207
|
+
const mpPics = (Array.isArray(f.mp_screenshots) && f.mp_screenshots.length)
|
|
208
|
+
? f.mp_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5)
|
|
209
|
+
: [String(f.mp_media_id)];
|
|
206
210
|
salesInfo.mp_info = {
|
|
207
211
|
mp_sub_appid: reqStr(f.appid, "公众号 AppID", 64), // 商家自己的已认证公众号
|
|
208
|
-
mp_pics:
|
|
212
|
+
mp_pics: mpPics,
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
215
|
}
|
|
@@ -292,6 +296,92 @@ function buildMultipart(filename, fileBuf, metaStr) {
|
|
|
292
296
|
return { boundary, body: Buffer.concat([Buffer.from(head, "utf8"), fileBuf, Buffer.from(tail, "utf8")]) };
|
|
293
297
|
}
|
|
294
298
|
|
|
299
|
+
/** 服务商配置档案 (sp_profile 键值表, /sp/config 配置页的数据源; 读表优先 env 兜底) */
|
|
300
|
+
function ensureSpProfile(db) {
|
|
301
|
+
db.exec(`CREATE TABLE IF NOT EXISTS sp_profile (
|
|
302
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
|
|
303
|
+
}
|
|
304
|
+
/** 读配置: 表里有值用表, 否则 undefined (调用方回落 env) */
|
|
305
|
+
function spProfileGet(db, key) {
|
|
306
|
+
ensureSpProfile(db);
|
|
307
|
+
const row = db.prepare("SELECT value FROM sp_profile WHERE key = ?").get(key);
|
|
308
|
+
return row && row.value !== "" ? row.value : undefined;
|
|
309
|
+
}
|
|
310
|
+
function spProfileSet(db, key, value) {
|
|
311
|
+
ensureSpProfile(db);
|
|
312
|
+
db.prepare("INSERT INTO sp_profile (key, value, updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at")
|
|
313
|
+
.run(key, String(value), nowSec());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 服务商应用列表 (sp_apps, 2026-10-03 多形态多应用): 一个服务商可有多个 APP/小程序/网站,
|
|
317
|
+
* 商户进件按场景 (app/mini_program/web) 从对应 type 的应用里选, is_default 唯一主推。 */
|
|
318
|
+
const SP_APP_TYPES = ["app", "mini_program", "web"];
|
|
319
|
+
function ensureSpApps(db) {
|
|
320
|
+
db.exec(`CREATE TABLE IF NOT EXISTS sp_apps (
|
|
321
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
322
|
+
name TEXT NOT NULL DEFAULT '',
|
|
323
|
+
appid TEXT NOT NULL DEFAULT '',
|
|
324
|
+
type TEXT NOT NULL DEFAULT 'app',
|
|
325
|
+
assets_home TEXT NOT NULL DEFAULT '',
|
|
326
|
+
assets_tail TEXT NOT NULL DEFAULT '',
|
|
327
|
+
assets_inner TEXT NOT NULL DEFAULT '',
|
|
328
|
+
assets_pay TEXT NOT NULL DEFAULT '',
|
|
329
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
330
|
+
created_at INTEGER NOT NULL,
|
|
331
|
+
updated_at INTEGER NOT NULL
|
|
332
|
+
)`);
|
|
333
|
+
// 旧单配置自动迁移: 列表为空且旧配置在 (sp_profile.app_appid 或 sp_app_assets 有素材) → 迁入为默认应用
|
|
334
|
+
const n = db.prepare("SELECT COUNT(*) n FROM sp_apps").get().n;
|
|
335
|
+
if (n === 0) {
|
|
336
|
+
const legacyAppid = spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "";
|
|
337
|
+
const legacyAssets = readAppAssets(db);
|
|
338
|
+
if (legacyAppid || Object.keys(legacyAssets).length) {
|
|
339
|
+
const now = nowSec();
|
|
340
|
+
db.prepare(`INSERT INTO sp_apps (name, appid, type, assets_home, assets_tail, assets_inner, assets_pay, is_default, created_at, updated_at)
|
|
341
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
342
|
+
.run("默认应用", legacyAppid, "app",
|
|
343
|
+
legacyAssets.home || "", legacyAssets.tail || "", legacyAssets.inner || "", legacyAssets.pay || "",
|
|
344
|
+
1, now, now);
|
|
345
|
+
console.log(`[sp-applyment] 旧应用配置已迁入 sp_apps (appid=${legacyAppid || "(空)"}, 素材 ${Object.keys(legacyAssets).length}/4)`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function appRowToJson(r) {
|
|
350
|
+
return {
|
|
351
|
+
id: r.id, name: r.name, appid: r.appid, type: r.type,
|
|
352
|
+
assets: { home: r.assets_home, tail: r.assets_tail, inner: r.assets_inner, pay: r.assets_pay },
|
|
353
|
+
is_default: !!r.is_default,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function listSpApps(db) {
|
|
357
|
+
ensureSpApps(db);
|
|
358
|
+
return db.prepare("SELECT * FROM sp_apps ORDER BY is_default DESC, id ASC").all().map(appRowToJson);
|
|
359
|
+
}
|
|
360
|
+
/** 按类型选应用: 该 type 的默认行 → 该 type 第一行 → null (调用方回落旧单配置) */
|
|
361
|
+
function pickSpApp(db, type) {
|
|
362
|
+
ensureSpApps(db);
|
|
363
|
+
let r = db.prepare("SELECT * FROM sp_apps WHERE type = ? AND is_default = 1 ORDER BY id ASC LIMIT 1").get(type);
|
|
364
|
+
if (!r) r = db.prepare("SELECT * FROM sp_apps WHERE type = ? ORDER BY is_default DESC, id ASC LIMIT 1").get(type);
|
|
365
|
+
return r ? appRowToJson(r) : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 旧单素材端点 (app-assets) 写入时同步默认应用行 — 保证 /info 兼容字段永远是最新
|
|
369
|
+
* (迁移行不能变成旧素材的只读快照) */
|
|
370
|
+
function upsertDefaultAppAsset(db, kind, mediaId) {
|
|
371
|
+
ensureSpApps(db);
|
|
372
|
+
let row = db.prepare("SELECT * FROM sp_apps WHERE type='app' AND is_default=1 ORDER BY id ASC LIMIT 1").get()
|
|
373
|
+
|| db.prepare("SELECT * FROM sp_apps WHERE is_default=1 ORDER BY id ASC LIMIT 1").get()
|
|
374
|
+
|| db.prepare("SELECT * FROM sp_apps ORDER BY id ASC LIMIT 1").get();
|
|
375
|
+
const now = nowSec();
|
|
376
|
+
if (row) {
|
|
377
|
+
db.prepare(`UPDATE sp_apps SET assets_${kind} = ?, updated_at = ? WHERE id = ?`).run(mediaId, now, row.id);
|
|
378
|
+
} else if (mediaId) {
|
|
379
|
+
const appid = spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "";
|
|
380
|
+
db.prepare(`INSERT INTO sp_apps (name, appid, type, assets_${kind}, is_default, created_at, updated_at) VALUES (?,?,?,?,1,?,?)`)
|
|
381
|
+
.run("默认应用", appid, "app", mediaId, now, now);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
295
385
|
/** 服务商应用素材: 表优先, env SP_APP_ASSETS_JSON ({home,tail,inner,pay} 各 media_id) 兜底 */
|
|
296
386
|
function readAppAssets(db) {
|
|
297
387
|
try {
|
|
@@ -312,6 +402,281 @@ function readAppAssets(db) {
|
|
|
312
402
|
} catch (_) { return {}; }
|
|
313
403
|
}
|
|
314
404
|
|
|
405
|
+
/** sp-config 段 (2026-10-03): /sp/config 服务商配置页 + GET/PUT /api/sp/config + 素材删除。
|
|
406
|
+
* 页面极简卡片风 (设计规范), 一屏三件事: 应用配置 / 服务商名片 / 状态总览。 */
|
|
407
|
+
|
|
408
|
+
/** 配齐度计算 (AppID / 4 素材 / 名片 / 费率 四块) */
|
|
409
|
+
function configCompleteness(db) {
|
|
410
|
+
const appid = spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "";
|
|
411
|
+
const assets = readAppAssets(db);
|
|
412
|
+
const name = spProfileGet(db, "sp_name") || process.env.SP_NAME || "";
|
|
413
|
+
const contact = spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "";
|
|
414
|
+
const KIND_LABEL = { home: "首页截图", tail: "尾页截图", inner: "应用内截图", pay: "支付页截图" };
|
|
415
|
+
const missing = [];
|
|
416
|
+
if (!appid) missing.push("AppID");
|
|
417
|
+
for (const k of ["home", "tail", "inner", "pay"]) if (!assets[k]) missing.push(KIND_LABEL[k]);
|
|
418
|
+
if (!name) missing.push("服务商名称");
|
|
419
|
+
if (!contact) missing.push("客服联系方式");
|
|
420
|
+
const assetCount = ["home", "tail", "inner", "pay"].filter((k) => assets[k]).length;
|
|
421
|
+
return {
|
|
422
|
+
appid: !!appid,
|
|
423
|
+
assets: assetCount, // 0-4
|
|
424
|
+
profile: !!(name && contact),
|
|
425
|
+
rate: true, // getDefaultRateBp 总有兜底
|
|
426
|
+
complete: missing.length === 0,
|
|
427
|
+
missing,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 当前全量配置 (表优先 env 兜底) */
|
|
432
|
+
function currentConfig(db) {
|
|
433
|
+
const bp = spCommission.getDefaultRateBp(db);
|
|
434
|
+
return {
|
|
435
|
+
app_appid: spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || "",
|
|
436
|
+
sp_name: spProfileGet(db, "sp_name") || process.env.SP_NAME || "",
|
|
437
|
+
sp_desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
|
|
438
|
+
sp_official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
|
|
439
|
+
sp_contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
|
|
440
|
+
commission_bp: bp,
|
|
441
|
+
rate_pct: (bp / 100).toFixed(2) + "%",
|
|
442
|
+
assets: readAppAssets(db),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const CONFIG_PAGE_HTML = `<!DOCTYPE html>
|
|
447
|
+
<html lang="zh-CN">
|
|
448
|
+
<head>
|
|
449
|
+
<meta charset="UTF-8">
|
|
450
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
451
|
+
<title>服务商配置 · TrustBase SP</title>
|
|
452
|
+
<style>
|
|
453
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
454
|
+
body{background:#F7F3EB;color:#33302B;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;line-height:1.7}
|
|
455
|
+
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 60px}
|
|
456
|
+
h1{font-size:20px;margin-bottom:4px}
|
|
457
|
+
.sub{font-size:13px;color:#8A857B;margin-bottom:16px}
|
|
458
|
+
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
|
459
|
+
.chip{font-size:12px;border-radius:999px;padding:5px 12px;border:1px solid #EAE3D6;background:#FCFAF4;color:#8A857B}
|
|
460
|
+
.chip.ok{background:#F0FDF4;border-color:#BBF7D0;color:#166534}
|
|
461
|
+
.box{background:#FCFAF4;border:1px solid #EAE3D6;border-radius:14px;padding:18px;margin-bottom:14px}
|
|
462
|
+
.box h3{font-size:15px;color:#C4633C;margin-bottom:10px}
|
|
463
|
+
label{display:block;font-size:13px;color:#6B655B;margin:10px 0 4px}
|
|
464
|
+
input,select{width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff}
|
|
465
|
+
input:focus,select:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px rgba(196,99,60,.15)}
|
|
466
|
+
.cur{font-size:12px;color:#8A857B;word-break:break-all}
|
|
467
|
+
.btn{display:inline-block;background:#C4633C;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:15px;cursor:pointer;margin-top:12px}
|
|
468
|
+
.btn.ghost{background:#8A857B;margin-left:8px}
|
|
469
|
+
.slots{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px}
|
|
470
|
+
.slot{border:2px dashed #D4CDBF;border-radius:10px;background:#FFFDF9;padding:10px;text-align:center;font-size:12px;color:#6B655B}
|
|
471
|
+
.slot .t{font-weight:600;color:#33302B;margin-bottom:6px}
|
|
472
|
+
.slot img{max-width:100%;max-height:100px;border-radius:6px;margin-bottom:6px}
|
|
473
|
+
.slot .mid{font-size:11px;color:#16A34A;word-break:break-all;margin-bottom:6px}
|
|
474
|
+
.upbtn{display:inline-block;background:#fff;border:1px solid #C4633C;color:#C4633C;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;margin:2px}
|
|
475
|
+
.upbtn.del{color:#DC2626;border-color:#FCA5A5}
|
|
476
|
+
.app-row{display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #EAE3D6;border-radius:10px;background:#FFFDF9;padding:10px 12px;margin-bottom:8px}
|
|
477
|
+
.app-info{font-size:13px;min-width:0}
|
|
478
|
+
.app-ops{flex:none;white-space:nowrap}
|
|
479
|
+
.star{color:#C4633C;font-size:12px;font-weight:600}
|
|
480
|
+
.tag{font-size:11px;border:1px solid #D4CDBF;border-radius:999px;padding:1px 8px;color:#6B655B;margin-left:4px}
|
|
481
|
+
.msg{font-size:13px;margin-top:8px}
|
|
482
|
+
.msg.err{color:#DC2626}.msg.ok{color:#16A34A}
|
|
483
|
+
@media (max-width:640px){.slots{grid-template-columns:1fr}.app-row{flex-direction:column;align-items:flex-start}}
|
|
484
|
+
</style>
|
|
485
|
+
</head>
|
|
486
|
+
<body>
|
|
487
|
+
<div class="wrap">
|
|
488
|
+
<h1>服务商配置</h1>
|
|
489
|
+
<div class="sub">TrustBase 服务商运营台 · 应用/名片/状态 一屏三件事</div>
|
|
490
|
+
|
|
491
|
+
<div class="chips" id="chips"></div>
|
|
492
|
+
|
|
493
|
+
<div class="box">
|
|
494
|
+
<h3>① 应用配置 (APP/小程序/网站 · 商户进件按场景选用)</h3>
|
|
495
|
+
<div id="apps-list"></div>
|
|
496
|
+
<h3 id="app-form-title" style="margin-top:14px">新增应用</h3>
|
|
497
|
+
<label>应用名称</label><input id="f_name" placeholder="如: TrustBase 店铺标准版">
|
|
498
|
+
<label>AppID <span style="color:#BDB5A6;font-weight:400">(web 类型填域名 https://)</span></label><input id="f_appid" placeholder="wx 开头 / https://">
|
|
499
|
+
<label>类型</label>
|
|
500
|
+
<select id="f_type"><option value="app">APP 应用</option><option value="mini_program">小程序</option><option value="web">网站</option></select>
|
|
501
|
+
<label>素材截图 <span id="f_assets_hint" style="color:#BDB5A6;font-weight:400">(APP 需 4 张; 小程序/网站 2 张即可)</span></label>
|
|
502
|
+
<div class="slots" id="f_slots"></div>
|
|
503
|
+
<label style="cursor:pointer;font-size:13px;margin-top:10px"><input type="checkbox" id="f_default" style="width:auto;margin-right:6px">设为默认主推 ⭐</label>
|
|
504
|
+
<br>
|
|
505
|
+
<button class="btn" onclick="saveApp()">保存应用</button>
|
|
506
|
+
<button class="btn ghost" id="f_cancel" style="display:none" onclick="resetForm()">取消编辑</button>
|
|
507
|
+
<div class="msg" id="app-msg"></div>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
<div class="box">
|
|
511
|
+
<h3>② 服务商名片 (商户入驻页展示)</h3>
|
|
512
|
+
<label>名称</label><input id="p_name" placeholder="如: 无锡小播鼠网络科技">
|
|
513
|
+
<label>简介</label><input id="p_desc" placeholder="一句话介绍">
|
|
514
|
+
<label>官网</label><input id="p_url" placeholder="https://">
|
|
515
|
+
<label>客服联系方式</label><input id="p_contact" placeholder="微信/电话/邮箱">
|
|
516
|
+
<label>默认抽佣费率 (bp, 100 = 1.00%)</label><input id="p_bp" type="number" min="0" max="3000">
|
|
517
|
+
<div class="cur" id="bp-cur"></div>
|
|
518
|
+
<button class="btn" onclick="saveProfile()">保存名片</button>
|
|
519
|
+
<div class="msg" id="p-msg"></div>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
<script>
|
|
524
|
+
var KINDS=[['home','首页'],['tail','尾页'],['inner','应用内'],['pay','支付页']];
|
|
525
|
+
var CFG=null, APPS=[], editingId=null, formAssets={home:'',tail:'',inner:'',pay:''};
|
|
526
|
+
function adminKey(){
|
|
527
|
+
var k=localStorage.getItem('spAdminKey')||'';
|
|
528
|
+
if(!k){k=prompt('请输入管理密钥 (x-sp-admin-key)')||''; if(k)localStorage.setItem('spAdminKey',k);}
|
|
529
|
+
return k;
|
|
530
|
+
}
|
|
531
|
+
function api(path,opts){
|
|
532
|
+
opts=opts||{};
|
|
533
|
+
opts.headers=Object.assign({'Content-Type':'application/json','Accept':'application/json','x-sp-admin-key':adminKey()},opts.headers||{});
|
|
534
|
+
return fetch(path,opts).then(function(r){
|
|
535
|
+
if(r.status===401){localStorage.removeItem('spAdminKey');}
|
|
536
|
+
return r.json().then(function(j){return{status:r.status,json:j};});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<');}
|
|
540
|
+
function maskMid(m){m=String(m||'');return m.length>16?m.slice(0,12)+'…'+m.slice(-4):m;}
|
|
541
|
+
function maskApp(a){a=String(a||'');return a.length>10?a.slice(0,6)+'****'+a.slice(-4):a;}
|
|
542
|
+
var TYPE_LABEL={app:'APP',mini_program:'小程序',web:'网站'};
|
|
543
|
+
|
|
544
|
+
function render(){
|
|
545
|
+
var c=CFG, comp=c.completeness;
|
|
546
|
+
document.getElementById('chips').innerHTML=
|
|
547
|
+
'<span class="chip '+(APPS.length?'ok':'')+'">'+(APPS.length?'✓':'○')+' 应用 '+APPS.length+'</span>'
|
|
548
|
+
+'<span class="chip '+(comp.assets===4||APPS.length?'ok':'')+'">'+(comp.assets===4||APPS.length?'✓':'○')+' 素材</span>'
|
|
549
|
+
+'<span class="chip '+(comp.profile?'ok':'')+'">'+(comp.profile?'✓':'○')+' 名片</span>'
|
|
550
|
+
+'<span class="chip '+(comp.rate?'ok':'')+'">'+(comp.rate?'✓':'○')+' 费率</span>';
|
|
551
|
+
document.getElementById('apps-list').innerHTML=APPS.length?APPS.map(function(a){
|
|
552
|
+
var cnt=['home','tail','inner','pay'].filter(function(k){return a.assets[k];}).length;
|
|
553
|
+
return '<div class="app-row"><div class="app-info"><b>'+esc(a.name)+'</b>'
|
|
554
|
+
+(a.is_default?'<span class="star"> ⭐默认</span>':'')
|
|
555
|
+
+'<span class="tag">'+(TYPE_LABEL[a.type]||a.type)+'</span><br>'
|
|
556
|
+
+'<span class="cur">'+esc(maskApp(a.appid))+' · 素材 '+cnt+'/4</span></div>'
|
|
557
|
+
+'<div class="app-ops">'
|
|
558
|
+
+'<button class="upbtn" onclick="editApp('+a.id+')">编辑</button>'
|
|
559
|
+
+(a.is_default?'':'<button class="upbtn" onclick="setDefault('+a.id+')">设默认</button>')
|
|
560
|
+
+'<button class="upbtn del" onclick="delApp('+a.id+')">删除</button>'
|
|
561
|
+
+'</div></div>';
|
|
562
|
+
}).join(''):'<div class="cur">还没有应用, 在下方新增第一个</div>';
|
|
563
|
+
renderSlots();
|
|
564
|
+
document.getElementById('p_name').value=c.sp_name||'';
|
|
565
|
+
document.getElementById('p_desc').value=c.sp_desc||'';
|
|
566
|
+
document.getElementById('p_url').value=c.sp_official_url||'';
|
|
567
|
+
document.getElementById('p_contact').value=c.sp_contact||'';
|
|
568
|
+
document.getElementById('p_bp').value=c.commission_bp;
|
|
569
|
+
document.getElementById('bp-cur').textContent='当前: '+c.commission_bp+' bp ('+c.rate_pct+')';
|
|
570
|
+
}
|
|
571
|
+
function renderSlots(){
|
|
572
|
+
document.getElementById('f_slots').innerHTML=KINDS.map(function(kk){
|
|
573
|
+
var mid=formAssets[kk[0]];
|
|
574
|
+
return '<div class="slot"><div class="t">'+kk[1]+'</div>'
|
|
575
|
+
+(mid?'<div class="mid">✓ '+esc(maskMid(mid))+'</div>':'')
|
|
576
|
+
+'<img id="img-'+kk[0]+'" style="display:none">'
|
|
577
|
+
+'<label class="upbtn">'+(mid?'替换':'点击上传')+'<input type="file" accept="image/*" style="display:none" onchange="upFormAsset(event,\x27'+kk[0]+'\x27)"></label>'
|
|
578
|
+
+(mid?'<button class="upbtn del" onclick="formAssets[\x27'+kk[0]+'\x27]=\x27\x27;renderSlots()">删除</button>':'')
|
|
579
|
+
+'</div>';
|
|
580
|
+
}).join('');
|
|
581
|
+
}
|
|
582
|
+
async function load(){
|
|
583
|
+
var r=await api('/api/sp/config');
|
|
584
|
+
if(r.status!==200){alert(r.json.message||'加载失败');return;}
|
|
585
|
+
CFG=r.json;
|
|
586
|
+
var ra=await api('/api/sp/apps');
|
|
587
|
+
APPS=(ra.json&&ra.json.apps)||[];
|
|
588
|
+
render();
|
|
589
|
+
}
|
|
590
|
+
function editApp(id){
|
|
591
|
+
var a=APPS.filter(function(x){return x.id===id;})[0];
|
|
592
|
+
if(!a) return;
|
|
593
|
+
editingId=id;
|
|
594
|
+
document.getElementById('app-form-title').textContent='编辑应用 #'+id;
|
|
595
|
+
document.getElementById('f_name').value=a.name;
|
|
596
|
+
document.getElementById('f_appid').value=a.appid;
|
|
597
|
+
document.getElementById('f_type').value=a.type;
|
|
598
|
+
document.getElementById('f_default').checked=!!a.is_default;
|
|
599
|
+
formAssets={home:a.assets.home,tail:a.assets.tail,inner:a.assets.inner,pay:a.assets.pay};
|
|
600
|
+
document.getElementById('f_cancel').style.display='';
|
|
601
|
+
renderSlots();
|
|
602
|
+
}
|
|
603
|
+
function resetForm(){
|
|
604
|
+
editingId=null;
|
|
605
|
+
document.getElementById('app-form-title').textContent='新增应用';
|
|
606
|
+
['f_name','f_appid'].forEach(function(id){document.getElementById(id).value='';});
|
|
607
|
+
document.getElementById('f_type').value='app';
|
|
608
|
+
document.getElementById('f_default').checked=false;
|
|
609
|
+
formAssets={home:'',tail:'',inner:'',pay:''};
|
|
610
|
+
document.getElementById('f_cancel').style.display='none';
|
|
611
|
+
renderSlots();
|
|
612
|
+
}
|
|
613
|
+
async function saveApp(){
|
|
614
|
+
var m=document.getElementById('app-msg');
|
|
615
|
+
var body={name:document.getElementById('f_name').value.trim(),
|
|
616
|
+
appid:document.getElementById('f_appid').value.trim(),
|
|
617
|
+
type:document.getElementById('f_type').value,
|
|
618
|
+
assets:formAssets, is_default:document.getElementById('f_default').checked};
|
|
619
|
+
if(!body.name||!body.appid){m.className='msg err';m.textContent='应用名称和 AppID 必填';return;}
|
|
620
|
+
var r=editingId?await api('/api/sp/apps/'+editingId,{method:'PUT',body:JSON.stringify(body)})
|
|
621
|
+
:await api('/api/sp/apps',{method:'POST',body:JSON.stringify(body)});
|
|
622
|
+
m.className='msg '+(r.json.ok?'ok':'err');
|
|
623
|
+
m.textContent=r.json.ok?'✓ 已保存':(r.json.message||'保存失败');
|
|
624
|
+
if(r.json.ok){resetForm();load();}
|
|
625
|
+
}
|
|
626
|
+
async function setDefault(id){
|
|
627
|
+
var r=await api('/api/sp/apps/'+id,{method:'PUT',body:JSON.stringify({is_default:true})});
|
|
628
|
+
if(r.json.ok) load();
|
|
629
|
+
}
|
|
630
|
+
async function delApp(id){
|
|
631
|
+
if(!confirm('删除这个应用? (不影响已提交的进件)')) return;
|
|
632
|
+
var r=await api('/api/sp/apps/'+id,{method:'DELETE'});
|
|
633
|
+
if(r.json.ok) load();
|
|
634
|
+
}
|
|
635
|
+
function shrink(file,cb){
|
|
636
|
+
var rd=new FileReader();
|
|
637
|
+
rd.onload=function(){
|
|
638
|
+
var img=new Image();
|
|
639
|
+
img.onload=function(){
|
|
640
|
+
var max=1600,w=img.width,h=img.height;
|
|
641
|
+
if(Math.max(w,h)>max){var k=max/Math.max(w,h);w=Math.round(w*k);h=Math.round(h*k);}
|
|
642
|
+
var cv=document.createElement('canvas');cv.width=w;cv.height=h;
|
|
643
|
+
cv.getContext('2d').drawImage(img,0,0,w,h);
|
|
644
|
+
cb(null,cv.toDataURL('image/jpeg',0.85).split(',')[1]);
|
|
645
|
+
};
|
|
646
|
+
img.src=rd.result;
|
|
647
|
+
};
|
|
648
|
+
rd.readAsDataURL(file);
|
|
649
|
+
}
|
|
650
|
+
async function upFormAsset(ev,kind){
|
|
651
|
+
var file=ev.target.files&&ev.target.files[0];
|
|
652
|
+
if(!file) return;
|
|
653
|
+
var m=document.getElementById('app-msg');
|
|
654
|
+
m.className='msg';m.textContent='上传中...';
|
|
655
|
+
shrink(file,async function(err,b64){
|
|
656
|
+
if(err){m.className='msg err';m.textContent=err.message;return;}
|
|
657
|
+
var r=await api('/api/sp/applyment/upload',{method:'POST',body:JSON.stringify({filename:kind+'.jpg',content_b64:b64})});
|
|
658
|
+
if(r.json.media_id){formAssets[kind]=r.json.media_id;m.textContent='';renderSlots();}
|
|
659
|
+
else{m.className='msg err';m.textContent=r.json.message||'上传失败';}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async function saveProfile(){
|
|
663
|
+
var bp=Number(document.getElementById('p_bp').value);
|
|
664
|
+
var r=await api('/api/sp/config',{method:'PUT',body:JSON.stringify({
|
|
665
|
+
sp_name:document.getElementById('p_name').value.trim(),
|
|
666
|
+
sp_desc:document.getElementById('p_desc').value.trim(),
|
|
667
|
+
sp_official_url:document.getElementById('p_url').value.trim(),
|
|
668
|
+
sp_contact:document.getElementById('p_contact').value.trim(),
|
|
669
|
+
commission_bp:bp})});
|
|
670
|
+
var m=document.getElementById('p-msg');
|
|
671
|
+
m.className='msg '+(r.json.ok?'ok':'err'); m.textContent=r.json.ok?'✓ 已保存 (商户端 /api/sp/info 立即生效)':(r.json.message||'保存失败');
|
|
672
|
+
if(r.json.ok) load();
|
|
673
|
+
}
|
|
674
|
+
adminKey(); load();
|
|
675
|
+
</script>
|
|
676
|
+
</body>
|
|
677
|
+
</html>`;
|
|
678
|
+
|
|
679
|
+
|
|
315
680
|
/** mock 微信应答 (SP_APPLYMENT_MOCK=1; 不碰真 API) */
|
|
316
681
|
function mockWx(method, urlPath) {
|
|
317
682
|
if (method === "POST" && urlPath === "/v3/merchant/media/upload") {
|
|
@@ -690,18 +1055,25 @@ function createSpApplymentRouter(deps) {
|
|
|
690
1055
|
* M3 🟡-1: 公示比例读 sp_commission_rates 默认行 (__default__) 优先, env 只作首次种子 */
|
|
691
1056
|
router.get("/info", (req, res) => {
|
|
692
1057
|
const bp = spCommission.getDefaultRateBp(db);
|
|
1058
|
+
// 2026-10-03 配置页落地: 名片/AppID 读 sp_profile 表优先 (env 兜底) — 配置页保存即生效
|
|
693
1059
|
res.json({
|
|
694
|
-
name: process.env.SP_NAME || "TrustBase 官方服务商",
|
|
695
|
-
desc: process.env.SP_DESC || "",
|
|
696
|
-
official_url: process.env.SP_OFFICIAL_URL || "",
|
|
697
|
-
contact: process.env.SP_CONTACT || "",
|
|
1060
|
+
name: spProfileGet(db, "sp_name") || process.env.SP_NAME || "TrustBase 官方服务商",
|
|
1061
|
+
desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
|
|
1062
|
+
official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
|
|
1063
|
+
contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
|
|
698
1064
|
commission_rate_bp: bp,
|
|
699
1065
|
rate_pct: (bp / 100).toFixed(2) + "%",
|
|
700
1066
|
chain_address: process.env.SP_CHAIN_ADDR || "",
|
|
701
1067
|
gateway: String(req.headers.host || ""), // 本网关域名 (从请求 host 推断)
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
1068
|
+
// 普通商户零自备: 多应用列表 (sp_apps) + 兼容单配置字段 (= 默认应用那套, 旧商户端不受影响)
|
|
1069
|
+
...(() => {
|
|
1070
|
+
const defApp = pickSpApp(db, "app");
|
|
1071
|
+
return {
|
|
1072
|
+
app_appid: defApp ? defApp.appid : (spProfileGet(db, "app_appid") || process.env.SP_APP_APPID || ""),
|
|
1073
|
+
app_screenshots: defApp ? defApp.assets : readAppAssets(db),
|
|
1074
|
+
apps: listSpApps(db),
|
|
1075
|
+
};
|
|
1076
|
+
})(),
|
|
705
1077
|
});
|
|
706
1078
|
});
|
|
707
1079
|
|
|
@@ -761,6 +1133,7 @@ function createSpApplymentRouter(deps) {
|
|
|
761
1133
|
const mediaId = await wxUploadMedia(filename, Buffer.from(String(a.content_b64), "base64"));
|
|
762
1134
|
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
1135
|
.run(kind, mediaId, nowSec());
|
|
1136
|
+
upsertDefaultAppAsset(db, kind, mediaId); // 同步默认应用行 (/info 兼容字段始终最新)
|
|
764
1137
|
saved[kind] = mediaId;
|
|
765
1138
|
}
|
|
766
1139
|
if (!Object.keys(saved).length) throw new HttpError(400, "invalid_param", "assets 至少给一张 (home/tail/inner/pay)");
|
|
@@ -771,6 +1144,129 @@ function createSpApplymentRouter(deps) {
|
|
|
771
1144
|
}
|
|
772
1145
|
});
|
|
773
1146
|
|
|
1147
|
+
/** GET/PUT /config — 服务商配置 (admin)。浏览器 (Accept: text/html) → /sp/config 配置页;
|
|
1148
|
+
* API (Accept: application/json) → 全量配置 JSON (表优先 env 兜底 + 配齐度) */
|
|
1149
|
+
router.get("/config", (req, res) => {
|
|
1150
|
+
const wantsHtml = String(req.headers.accept || "").includes("text/html");
|
|
1151
|
+
if (wantsHtml) { // 页面本身不鉴权 (admin key 在页面内输入, 所有 API 都要 key — 同 /sp/apply 口径)
|
|
1152
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1153
|
+
return res.send(CONFIG_PAGE_HTML);
|
|
1154
|
+
}
|
|
1155
|
+
if (!checkAdmin(req, res)) return;
|
|
1156
|
+
res.json({ ok: true, ...currentConfig(db), completeness: configCompleteness(db) });
|
|
1157
|
+
});
|
|
1158
|
+
router.put("/config", (req, res) => {
|
|
1159
|
+
if (!checkAdmin(req, res)) return;
|
|
1160
|
+
try {
|
|
1161
|
+
const b = req.body || {};
|
|
1162
|
+
const KEYMAP = { app_appid: "app_appid", sp_name: "sp_name", sp_desc: "sp_desc", sp_official_url: "sp_official_url", sp_contact: "sp_contact" };
|
|
1163
|
+
const saved = {};
|
|
1164
|
+
for (const k of Object.keys(KEYMAP)) {
|
|
1165
|
+
if (b[k] !== undefined) {
|
|
1166
|
+
const v = String(b[k]).trim().slice(0, 200);
|
|
1167
|
+
spProfileSet(db, KEYMAP[k], v);
|
|
1168
|
+
saved[k] = v;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (b.commission_bp !== undefined) {
|
|
1172
|
+
const bp = Number(b.commission_bp);
|
|
1173
|
+
if (!Number.isInteger(bp) || bp < 0 || bp > spCommission.MAX_RATE_BP) {
|
|
1174
|
+
throw new HttpError(422, "rate_out_of_range", `commission_bp 必须是 0..${spCommission.MAX_RATE_BP} 的整数 (万分比)`);
|
|
1175
|
+
}
|
|
1176
|
+
// 费率写 sp_commission_rates 默认行 (__default__) — /api/sp/info 的 getDefaultRateBp 单源
|
|
1177
|
+
spCommission.ensureCommissionTables(db);
|
|
1178
|
+
const now = nowSec();
|
|
1179
|
+
db.prepare(`INSERT INTO sp_commission_rates (sub_mchid, provider, rate_bp, updated_by, created_at, updated_at)
|
|
1180
|
+
VALUES ('__default__','',?,'config-page',?,?)
|
|
1181
|
+
ON CONFLICT(sub_mchid) DO UPDATE SET rate_bp=excluded.rate_bp, updated_by=excluded.updated_by, updated_at=excluded.updated_at`)
|
|
1182
|
+
.run(bp, now, now);
|
|
1183
|
+
saved.commission_bp = bp;
|
|
1184
|
+
}
|
|
1185
|
+
if (!Object.keys(saved).length) throw new HttpError(400, "invalid_param", "没有可保存的字段");
|
|
1186
|
+
console.log(`[sp-applyment] 配置已保存 keys=${Object.keys(saved).join(",")}`);
|
|
1187
|
+
res.json({ ok: true, saved, ...currentConfig(db), completeness: configCompleteness(db) });
|
|
1188
|
+
} catch (e) {
|
|
1189
|
+
return chainWrites.handleError(res, e, "/api/sp/config");
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
/** DELETE /app-assets/:kind — 删除一张应用素材 (admin; 配置页"删除"按钮) */
|
|
1193
|
+
router.delete("/app-assets/:kind", (req, res) => {
|
|
1194
|
+
if (!checkAdmin(req, res)) return;
|
|
1195
|
+
const kind = String(req.params.kind || "");
|
|
1196
|
+
if (!["home", "tail", "inner", "pay"].includes(kind)) {
|
|
1197
|
+
return res.status(400).json({ ok: false, error: "invalid_param", message: "kind 必须是 home/tail/inner/pay" });
|
|
1198
|
+
}
|
|
1199
|
+
db.prepare("DELETE FROM sp_app_assets WHERE kind = ?").run(kind);
|
|
1200
|
+
upsertDefaultAppAsset(db, kind, ""); // 同步清空默认应用行
|
|
1201
|
+
res.json({ ok: true, assets: readAppAssets(db) });
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
/** 服务商应用列表 CRUD (admin; 配置页"① 应用配置"的数据源) */
|
|
1205
|
+
router.get("/apps", (req, res) => {
|
|
1206
|
+
if (!checkAdmin(req, res)) return;
|
|
1207
|
+
res.json({ ok: true, apps: listSpApps(db) });
|
|
1208
|
+
});
|
|
1209
|
+
router.post("/apps", (req, res) => {
|
|
1210
|
+
if (!checkAdmin(req, res)) return;
|
|
1211
|
+
try {
|
|
1212
|
+
const b = req.body || {};
|
|
1213
|
+
const name = String(b.name || "").trim();
|
|
1214
|
+
const appid = String(b.appid || "").trim();
|
|
1215
|
+
const type = String(b.type || "app");
|
|
1216
|
+
if (!name || !appid) throw new HttpError(400, "invalid_param", "name/appid 必填");
|
|
1217
|
+
if (!SP_APP_TYPES.includes(type)) throw new HttpError(400, "invalid_param", "type 必须是 app/mini_program/web");
|
|
1218
|
+
const a = b.assets || {};
|
|
1219
|
+
const now = nowSec();
|
|
1220
|
+
// 第一个应用自动设默认; 显式 is_default 则清其它
|
|
1221
|
+
const count = db.prepare("SELECT COUNT(*) n FROM sp_apps").get().n;
|
|
1222
|
+
const isDef = b.is_default || count === 0 ? 1 : 0;
|
|
1223
|
+
if (isDef) db.prepare("UPDATE sp_apps SET is_default = 0").run();
|
|
1224
|
+
const r = db.prepare(`INSERT INTO sp_apps (name, appid, type, assets_home, assets_tail, assets_inner, assets_pay, is_default, created_at, updated_at)
|
|
1225
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
1226
|
+
.run(name, appid, type,
|
|
1227
|
+
String(a.home || ""), String(a.tail || ""), String(a.inner || ""), String(a.pay || ""),
|
|
1228
|
+
isDef, now, now);
|
|
1229
|
+
console.log(`[sp-applyment] 新增应用 #${r.lastInsertRowid} ${name} (${type})${isDef ? " 默认⭐" : ""}`);
|
|
1230
|
+
res.json({ ok: true, id: r.lastInsertRowid, apps: listSpApps(db) });
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
return chainWrites.handleError(res, e, "/api/sp/apps");
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
router.put("/apps/:id", (req, res) => {
|
|
1236
|
+
if (!checkAdmin(req, res)) return;
|
|
1237
|
+
try {
|
|
1238
|
+
const id = Number(req.params.id);
|
|
1239
|
+
const row = db.prepare("SELECT * FROM sp_apps WHERE id = ?").get(id);
|
|
1240
|
+
if (!row) throw new HttpError(404, "not_found", "应用不存在");
|
|
1241
|
+
const b = req.body || {};
|
|
1242
|
+
const now = nowSec();
|
|
1243
|
+
if (b.is_default) db.prepare("UPDATE sp_apps SET is_default = 0 WHERE id <> ?").run(id); // 默认唯一
|
|
1244
|
+
const type = b.type !== undefined ? String(b.type) : row.type;
|
|
1245
|
+
if (!SP_APP_TYPES.includes(type)) throw new HttpError(400, "invalid_param", "type 必须是 app/mini_program/web");
|
|
1246
|
+
const a = b.assets || {};
|
|
1247
|
+
db.prepare(`UPDATE sp_apps SET name=?, appid=?, type=?,
|
|
1248
|
+
assets_home=?, assets_tail=?, assets_inner=?, assets_pay=?, is_default=?, updated_at=? WHERE id=?`)
|
|
1249
|
+
.run(
|
|
1250
|
+
b.name !== undefined ? String(b.name).trim() : row.name,
|
|
1251
|
+
b.appid !== undefined ? String(b.appid).trim() : row.appid,
|
|
1252
|
+
type,
|
|
1253
|
+
a.home !== undefined ? String(a.home) : row.assets_home,
|
|
1254
|
+
a.tail !== undefined ? String(a.tail) : row.assets_tail,
|
|
1255
|
+
a.inner !== undefined ? String(a.inner) : row.assets_inner,
|
|
1256
|
+
a.pay !== undefined ? String(a.pay) : row.assets_pay,
|
|
1257
|
+
b.is_default ? 1 : row.is_default,
|
|
1258
|
+
now, id);
|
|
1259
|
+
res.json({ ok: true, apps: listSpApps(db) });
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
return chainWrites.handleError(res, e, "/api/sp/apps");
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
router.delete("/apps/:id", (req, res) => {
|
|
1265
|
+
if (!checkAdmin(req, res)) return;
|
|
1266
|
+
db.prepare("DELETE FROM sp_apps WHERE id = ?").run(Number(req.params.id));
|
|
1267
|
+
res.json({ ok: true, apps: listSpApps(db) });
|
|
1268
|
+
});
|
|
1269
|
+
|
|
774
1270
|
/** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
|
|
775
1271
|
* 幂等/重提 (2026-10-03 F1/F3 fix):
|
|
776
1272
|
* - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
|
|
@@ -822,3 +822,28 @@ test("分步向导: stepper/导航/校验/预览 元素与逻辑齐备", async (
|
|
|
822
822
|
assert.ok(html.includes("a_shortname") && html.includes("a_up_license") && html.includes("btn-apply-submit"), "既有字段/提交钮保留");
|
|
823
823
|
} finally { srv.close(); }
|
|
824
824
|
});
|
|
825
|
+
|
|
826
|
+
test("MP 场景: 服务商小程序素材数组 (mp_screenshots) 免单张上传 + 组装 mp_pics", async () => {
|
|
827
|
+
const { srv, base } = await startSeller();
|
|
828
|
+
try {
|
|
829
|
+
const form = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
830
|
+
delete form.mp_media_id; // 不传单张, 改用服务商素材数组
|
|
831
|
+
// 缺两者 → 400
|
|
832
|
+
let r = await fetch(base + "/api/onboard/applyment", {
|
|
833
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
834
|
+
});
|
|
835
|
+
assert.strictEqual(r.status, 400);
|
|
836
|
+
// 数组路径 → 200, SP 组装 mp_pics=数组
|
|
837
|
+
form.mp_screenshots = ["MP_M1", "MP_M2"];
|
|
838
|
+
const before = spCapture.submits.length;
|
|
839
|
+
r = await fetch(base + "/api/onboard/applyment", {
|
|
840
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
841
|
+
});
|
|
842
|
+
const j = await r.json();
|
|
843
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
844
|
+
const fwd = spCapture.submits[before];
|
|
845
|
+
assert.deepStrictEqual(fwd.mp_screenshots, ["MP_M1", "MP_M2"]);
|
|
846
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
847
|
+
assert.deepStrictEqual(wxBody.business_info.sales_info.mp_info.mp_pics, ["MP_M1", "MP_M2"], "mp_pics=服务商素材数组");
|
|
848
|
+
} finally { srv.close(); }
|
|
849
|
+
});
|
package/test-sp-applyment.js
CHANGED
|
@@ -425,3 +425,118 @@ test("M4-APP: /info 带 app 配置 (env + 素材表) + POST /app-assets 上传
|
|
|
425
425
|
delete process.env.SP_APP_APPID; delete process.env.SP_APP_ASSETS_JSON;
|
|
426
426
|
}
|
|
427
427
|
});
|
|
428
|
+
|
|
429
|
+
test("SP 配置页后端: config 读写 (表优先 env) + 配齐度 + info 立即生效 + 素材删除", async () => {
|
|
430
|
+
const { srv, base } = await startServer();
|
|
431
|
+
try {
|
|
432
|
+
// 无 key → 401
|
|
433
|
+
let r = await fetch(base + "/api/sp/config", { headers: { Accept: "application/json" } });
|
|
434
|
+
assert.strictEqual(r.status, 401);
|
|
435
|
+
// 页面: Accept text/html 无 key 也 200 (admin key 页面内输入)
|
|
436
|
+
r = await fetch(base + "/sp/config", { headers: { Accept: "text/html" } });
|
|
437
|
+
assert.strictEqual(r.status, 200);
|
|
438
|
+
assert.ok((await r.text()).includes("服务商配置"));
|
|
439
|
+
// 读默认 (env 兜底)
|
|
440
|
+
let j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
|
|
441
|
+
assert.strictEqual(j.ok, true);
|
|
442
|
+
assert.strictEqual(j.app_appid, "");
|
|
443
|
+
assert.strictEqual(j.completeness.appid, false);
|
|
444
|
+
assert.ok(j.completeness.missing.includes("AppID"));
|
|
445
|
+
// PUT 应用 AppID + 名片 + 费率 → 表优先, /info 立即生效
|
|
446
|
+
r = await fetch(base + "/api/sp/config", {
|
|
447
|
+
method: "PUT", headers: { ...H, Accept: "application/json" },
|
|
448
|
+
body: JSON.stringify({ app_appid: "wx_cfg_app_001", sp_name: "配置页服务商", sp_contact: "微信: cfg", commission_bp: 200 }),
|
|
449
|
+
});
|
|
450
|
+
j = await r.json();
|
|
451
|
+
assert.strictEqual(j.ok, true);
|
|
452
|
+
assert.strictEqual(j.saved.app_appid, "wx_cfg_app_001");
|
|
453
|
+
assert.strictEqual(j.saved.commission_bp, 200);
|
|
454
|
+
assert.strictEqual(j.completeness.appid, true);
|
|
455
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
456
|
+
assert.strictEqual(j.app_appid, "wx_cfg_app_001", "/info 读表立即生效");
|
|
457
|
+
assert.strictEqual(j.name, "配置页服务商");
|
|
458
|
+
assert.strictEqual(j.contact, "微信: cfg");
|
|
459
|
+
assert.strictEqual(j.commission_rate_bp, 200);
|
|
460
|
+
assert.strictEqual(j.rate_pct, "2.00%");
|
|
461
|
+
// env 设置后表仍优先 (表 > env)
|
|
462
|
+
process.env.SP_NAME = "env-不应生效";
|
|
463
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
464
|
+
assert.strictEqual(j.name, "配置页服务商", "表优先于 env");
|
|
465
|
+
delete process.env.SP_NAME;
|
|
466
|
+
// 素材上传 → 配齐度 4/4 + /info app_screenshots
|
|
467
|
+
for (const kind of ["home", "tail", "inner", "pay"]) {
|
|
468
|
+
const ur = await fetch(base + "/api/sp/app-assets", {
|
|
469
|
+
method: "POST", headers: H,
|
|
470
|
+
body: JSON.stringify({ assets: { [kind]: { filename: kind + ".jpg", content_b64: Buffer.from("fake-" + kind).toString("base64") } } }),
|
|
471
|
+
});
|
|
472
|
+
assert.strictEqual(ur.status, 200);
|
|
473
|
+
}
|
|
474
|
+
j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
|
|
475
|
+
assert.strictEqual(j.completeness.assets, 4);
|
|
476
|
+
assert.ok(j.completeness.complete, "AppID+4素材+名片全齐 → complete");
|
|
477
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
478
|
+
assert.ok(j.app_screenshots.home && j.app_screenshots.pay, "素材返回");
|
|
479
|
+
// 删除一张 → 不再 complete
|
|
480
|
+
r = await fetch(base + "/api/sp/app-assets/pay", { method: "DELETE", headers: H });
|
|
481
|
+
assert.strictEqual((await r.json()).ok, true);
|
|
482
|
+
j = await (await fetch(base + "/api/sp/config", { headers: { ...H, Accept: "application/json" } })).json();
|
|
483
|
+
assert.strictEqual(j.completeness.assets, 3);
|
|
484
|
+
assert.ok(!j.completeness.complete);
|
|
485
|
+
assert.ok(j.completeness.missing.includes("支付页截图"));
|
|
486
|
+
// 费率越界 422
|
|
487
|
+
r = await fetch(base + "/api/sp/config", {
|
|
488
|
+
method: "PUT", headers: { ...H, Accept: "application/json" },
|
|
489
|
+
body: JSON.stringify({ commission_bp: 3001 }),
|
|
490
|
+
});
|
|
491
|
+
assert.strictEqual(r.status, 422);
|
|
492
|
+
} finally { srv.close(); }
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("多应用: CRUD + 默认唯一 + 旧配置自动迁移 + /info apps 列表", async () => {
|
|
496
|
+
const { srv, base } = await startServer();
|
|
497
|
+
try {
|
|
498
|
+
// 先写旧单配置 (profile.app_appid) → 首次访问 apps 应自动迁移为默认应用
|
|
499
|
+
await fetch(base + "/api/sp/config", {
|
|
500
|
+
method: "PUT", headers: { ...H, Accept: "application/json" },
|
|
501
|
+
body: JSON.stringify({ app_appid: "wx_legacy_001" }),
|
|
502
|
+
});
|
|
503
|
+
let j = await (await fetch(base + "/api/sp/apps", { headers: H })).json();
|
|
504
|
+
assert.strictEqual(j.apps.length, 1, "旧配置自动迁移");
|
|
505
|
+
assert.strictEqual(j.apps[0].appid, "wx_legacy_001");
|
|
506
|
+
assert.strictEqual(j.apps[0].is_default, true, "迁入行为默认应用");
|
|
507
|
+
assert.strictEqual(j.apps[0].type, "app");
|
|
508
|
+
const legacyId = j.apps[0].id;
|
|
509
|
+
// 新增第二个 (不默认)
|
|
510
|
+
let r = await fetch(base + "/api/sp/apps", {
|
|
511
|
+
method: "POST", headers: H,
|
|
512
|
+
body: JSON.stringify({ name: "店铺小程序", appid: "wx_mini_002", type: "mini_program", assets: { home: "M1", tail: "M2" } }),
|
|
513
|
+
});
|
|
514
|
+
j = await r.json();
|
|
515
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
516
|
+
assert.strictEqual(j.apps.length, 2);
|
|
517
|
+
const miniId = j.id;
|
|
518
|
+
assert.strictEqual(j.apps.filter((a) => a.is_default).length, 1, "默认仍唯一");
|
|
519
|
+
// 设默认到第二个 → 唯一切换
|
|
520
|
+
r = await fetch(base + "/api/sp/apps/" + miniId, { method: "PUT", headers: H, body: JSON.stringify({ is_default: true }) });
|
|
521
|
+
j = await r.json();
|
|
522
|
+
assert.strictEqual(j.apps.filter((a) => a.is_default).length, 1);
|
|
523
|
+
assert.strictEqual(j.apps.find((a) => a.is_default).id, miniId);
|
|
524
|
+
// /info: apps 列表 + 兼容字段 = 默认应用 (mini 类型不应影响 app 兼容字段)
|
|
525
|
+
j = await (await fetch(base + "/api/sp/info")).json();
|
|
526
|
+
assert.strictEqual(j.apps.length, 2);
|
|
527
|
+
assert.strictEqual(j.app_appid, "wx_legacy_001", "兼容字段=app 类型默认/首行, 不被 mini 默认抢走");
|
|
528
|
+
// 编辑
|
|
529
|
+
r = await fetch(base + "/api/sp/apps/" + miniId, { method: "PUT", headers: H, body: JSON.stringify({ name: "店铺小程序Pro" }) });
|
|
530
|
+
assert.strictEqual((await r.json()).apps.find((a) => a.id === miniId).name, "店铺小程序Pro");
|
|
531
|
+
// 删除
|
|
532
|
+
r = await fetch(base + "/api/sp/apps/" + miniId, { method: "DELETE", headers: H });
|
|
533
|
+
j = await r.json();
|
|
534
|
+
assert.strictEqual(j.apps.length, 1);
|
|
535
|
+
// 无 key 401
|
|
536
|
+
r = await fetch(base + "/api/sp/apps");
|
|
537
|
+
assert.strictEqual(r.status, 401);
|
|
538
|
+
// 校验: type 非法 400
|
|
539
|
+
r = await fetch(base + "/api/sp/apps", { method: "POST", headers: H, body: JSON.stringify({ name: "x", appid: "y", type: "bad" }) });
|
|
540
|
+
assert.strictEqual(r.status, 400);
|
|
541
|
+
} finally { srv.close(); }
|
|
542
|
+
});
|