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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -63,6 +63,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(3,1fr);gap:10px}
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="SALES_SCENES_STORE" checked onchange="setScene(this.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="a_up_qual_wrap" style="display:none;grid-column:1/-1"><div id="a_up_qual"></div></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>
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:'SALES_SCENES_STORE';
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'].forEach(function(k){delete _aMedia[k];});
639
- var slots=SCENE_UPLOADS[scene]||SCENE_UPLOADS.SALES_SCENES_STORE;
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
- if(!slots.some(function(s){return s[0]==='a_up_s2';})) document.getElementById('a_up_s2').style.display='none';
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('SALES_SCENES_STORE'); // 默认线下门店
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) {
@@ -156,7 +156,7 @@ function buildApplymentBody(form, enc, opts) {
156
156
  const f = form || {};
157
157
  const sensLen = (opts && opts.alreadyEncrypted) ? 2048 : 0; // 0 = 用明文默认上限
158
158
  // 经营场景 (2026-10-03 xiaomu 拍板三选一): 线下门店(默认) / 互联网网站 / 公众号·小程序
159
- const scene = (f.sales_scene === "SALES_SCENES_WEB" || f.sales_scene === "SALES_SCENES_MP") ? f.sales_scene : "SALES_SCENES_STORE";
159
+ const scene = (f.sales_scene === "SALES_SCENES_WEB" || f.sales_scene === "SALES_SCENES_MP" || f.sales_scene === "SALES_SCENES_APP") ? f.sales_scene : "SALES_SCENES_STORE";
160
160
  // 必备照片 media_id 校验 (通用 3 张 + 场景照片, 缺哪张报哪张)
161
161
  const mediaNeed = [
162
162
  ["license_media_id", "营业执照照片"],
@@ -185,6 +185,15 @@ function buildApplymentBody(form, enc, opts) {
185
185
  const url = reqStr(f.website_url, "网站网址", 256);
186
186
  if (!/^https?:\/\//i.test(url)) throw new HttpError(400, "invalid_param", "网站网址须以 http(s):// 开头 (需已上线且有 ICP 备案)", "website_url");
187
187
  salesInfo.web_info = { domain: url }; // 微信官方字段是 domain; web_authorisation/web_appid 选填先不传
188
+ } else if (scene === "SALES_SCENES_APP") {
189
+ // 普通商户零自备 (xiaomu 2026-10-03): 默认走服务商应用 — AppID 用服务商的 (app_appid),
190
+ // 截图复用服务商素材 (sp_app_assets 4 张); 商户自有应用则 app_sub_appid + 自己传截图
191
+ const pics = Array.isArray(f.app_screenshots) ? f.app_screenshots.map((x) => String(x)).filter(Boolean).slice(0, 5) : [];
192
+ if (!pics.length) throw new HttpError(400, "media_missing", "应用截图未提供 (应用场景必传 app_pics)", "app_screenshots");
193
+ const appid = reqStr(f.appid, "应用 AppID", 64);
194
+ salesInfo.app_info = f.appid_provider === "own"
195
+ ? { app_sub_appid: appid, app_pics: pics } // 商户自有应用
196
+ : { app_appid: appid, app_pics: pics }; // 服务商应用 (默认零自备)
188
197
  } else {
189
198
  // 公众号/小程序二选一: 用户只有小程序 appid 时用 mini_program_info
190
199
  if (f.appid_type === "mini") {
@@ -283,6 +292,26 @@ function buildMultipart(filename, fileBuf, metaStr) {
283
292
  return { boundary, body: Buffer.concat([Buffer.from(head, "utf8"), fileBuf, Buffer.from(tail, "utf8")]) };
284
293
  }
285
294
 
295
+ /** 服务商应用素材: 表优先, env SP_APP_ASSETS_JSON ({home,tail,inner,pay} 各 media_id) 兜底 */
296
+ function readAppAssets(db) {
297
+ try {
298
+ db.exec(`CREATE TABLE IF NOT EXISTS sp_app_assets (
299
+ kind TEXT PRIMARY KEY, media_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
300
+ const rows = db.prepare("SELECT kind, media_id FROM sp_app_assets WHERE media_id <> ''").all();
301
+ if (rows.length) {
302
+ const out = {};
303
+ for (const r of rows) out[r.kind] = r.media_id;
304
+ return out;
305
+ }
306
+ } catch (_) { /* 表问题走 env */ }
307
+ try {
308
+ const j = JSON.parse(process.env.SP_APP_ASSETS_JSON || "{}");
309
+ const out = {};
310
+ for (const k of ["home", "tail", "inner", "pay"]) if (j[k]) out[k] = String(j[k]);
311
+ return out;
312
+ } catch (_) { return {}; }
313
+ }
314
+
286
315
  /** mock 微信应答 (SP_APPLYMENT_MOCK=1; 不碰真 API) */
287
316
  function mockWx(method, urlPath) {
288
317
  if (method === "POST" && urlPath === "/v3/merchant/media/upload") {
@@ -670,6 +699,9 @@ function createSpApplymentRouter(deps) {
670
699
  rate_pct: (bp / 100).toFixed(2) + "%",
671
700
  chain_address: process.env.SP_CHAIN_ADDR || "",
672
701
  gateway: String(req.headers.host || ""), // 本网关域名 (从请求 host 推断)
702
+ // 普通商户 APP 场景零自备: 服务商应用 AppID + 应用截图素材 (sp_app_assets 表优先, env 兜底)
703
+ app_appid: process.env.SP_APP_APPID || "",
704
+ app_screenshots: readAppAssets(db),
673
705
  });
674
706
  });
675
707
 
@@ -679,34 +711,66 @@ function createSpApplymentRouter(deps) {
679
711
  res.send(PAGE_HTML);
680
712
  });
681
713
 
714
+ /** 微信 media/upload 公共通道 (进件照片 / 服务商应用素材共用) */
715
+ async function wxUploadMedia(filename, buf) {
716
+ if (!/\.(jpe?g|png|bmp)$/i.test(filename)) throw new HttpError(400, "invalid_param", "filename 仅支持 jpg/jpeg/png/bmp 后缀");
717
+ if (!buf.length) throw new HttpError(400, "invalid_param", "content_b64 为空");
718
+ if (buf.length > 5 * 1024 * 1024) throw new HttpError(413, "file_too_large", "图片不能超过 5MB (微信上限)");
719
+ const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
720
+ const meta = JSON.stringify({ filename, sha256 }); // 签名串 body 用 meta JSON 串 (非整个 multipart)
721
+ const mp = buildMultipart(filename, buf, meta);
722
+ const r = await wxRequest(deps, "POST", "/v3/merchant/media/upload", {
723
+ signBody: meta, body: mp.body, contentType: "multipart/form-data; boundary=" + mp.boundary,
724
+ });
725
+ if (r.status !== 200 || !r.json.media_id) {
726
+ const e = new HttpError(502, "wx_error", "上传图片到微信失败: " + (r.json.message || ("HTTP " + r.status)));
727
+ e.wxStatus = r.status; throw e;
728
+ }
729
+ return r.json.media_id;
730
+ }
731
+
682
732
  /** POST /applyment/upload — base64 JSON {filename, content_b64} → 微信 media/upload → {media_id} */
683
733
  router.post("/applyment/upload", async (req, res) => {
684
734
  if (!checkAdmin(req, res)) return;
685
735
  try {
686
736
  const b = req.body || {};
687
737
  const filename = String(b.filename || "");
688
- if (!/\.(jpe?g|png|bmp)$/i.test(filename)) {
689
- throw new HttpError(400, "invalid_param", "filename 仅支持 jpg/jpeg/png/bmp 后缀");
690
- }
691
738
  const buf = Buffer.from(String(b.content_b64 || ""), "base64");
692
- if (!buf.length) throw new HttpError(400, "invalid_param", "content_b64 为空");
693
- if (buf.length > 5 * 1024 * 1024) throw new HttpError(413, "file_too_large", "图片不能超过 5MB (微信上限)");
694
- const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
695
- const meta = JSON.stringify({ filename, sha256 }); // 签名串 body 用 meta JSON 串 (非整个 multipart)
696
- const mp = buildMultipart(filename, buf, meta);
697
- const r = await wxRequest(deps, "POST", "/v3/merchant/media/upload", {
698
- signBody: meta,
699
- body: mp.body,
700
- contentType: "multipart/form-data; boundary=" + mp.boundary,
701
- });
702
- if (r.status !== 200 || !r.json.media_id) return wxError(res, r, "上传图片到微信失败");
703
- console.log(`[sp-applyment] 图片已上传 file=${filename} size=${buf.length} media_id=${String(r.json.media_id).slice(0, 24)}...`);
704
- res.json({ media_id: r.json.media_id });
739
+ const mediaId = await wxUploadMedia(filename, buf);
740
+ console.log(`[sp-applyment] 图片已上传 file=${filename} size=${buf.length} media_id=${String(mediaId).slice(0, 24)}...`);
741
+ res.json({ media_id: mediaId });
705
742
  } catch (e) {
706
743
  return chainWrites.handleError(res, e, "/api/sp/applyment/upload");
707
744
  }
708
745
  });
709
746
 
747
+ /** POST /app-assets — 服务商应用截图素材配置 (admin; 4 张 base64 → media/upload → sp_app_assets 表,
748
+ * 普通商户 APP 场景零自备的素材源: 商户进件默认复用这套 media_id, 不用自己传) */
749
+ const APP_ASSET_KINDS = ["home", "tail", "inner", "pay"]; // 首页/尾页/应用内/支付页
750
+ router.post("/app-assets", async (req, res) => {
751
+ if (!checkAdmin(req, res)) return;
752
+ try {
753
+ db.exec(`CREATE TABLE IF NOT EXISTS sp_app_assets (
754
+ kind TEXT PRIMARY KEY, media_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL)`);
755
+ const assets = (req.body || {}).assets || {};
756
+ const saved = {};
757
+ for (const kind of APP_ASSET_KINDS) {
758
+ const a = assets[kind];
759
+ if (!a || !a.content_b64) continue;
760
+ const filename = String(a.filename || ("app-" + kind + ".jpg"));
761
+ const mediaId = await wxUploadMedia(filename, Buffer.from(String(a.content_b64), "base64"));
762
+ db.prepare("INSERT INTO sp_app_assets (kind, media_id, updated_at) VALUES (?,?,?) ON CONFLICT(kind) DO UPDATE SET media_id=excluded.media_id, updated_at=excluded.updated_at")
763
+ .run(kind, mediaId, nowSec());
764
+ saved[kind] = mediaId;
765
+ }
766
+ if (!Object.keys(saved).length) throw new HttpError(400, "invalid_param", "assets 至少给一张 (home/tail/inner/pay)");
767
+ console.log(`[sp-applyment] 应用素材已更新 kinds=${Object.keys(saved).join(",")}`);
768
+ res.json({ ok: true, saved, assets: readAppAssets(db) });
769
+ } catch (e) {
770
+ return chainWrites.handleError(res, e, "/api/sp/app-assets");
771
+ }
772
+ });
773
+
710
774
  /** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
711
775
  * 幂等/重提 (2026-10-03 F1/F3 fix):
712
776
  * - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
@@ -731,6 +731,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
  });
@@ -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
+ });