trustbase-seller-backend 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -201,13 +201,9 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
201
201
  <div class="afield"><label for="a_service_phone">客服电话</label><input id="a_service_phone" placeholder="交易记录向买家展示"></div>
202
202
  <div class="afield"><label>结算规则 (按主体自动确定, 不用选)</label>
203
203
  <div class="arule" id="a_settlement_rule">719 · 个体户通用(0.6% T+1)</div></div>
204
- <div class="afield"><label for="a_settlement_id">行业类目 (微信 MCC 编码)</label>
205
- <input id="a_settlement_filter" placeholder="搜索: 编码或名称 (如 5732 / 电子)" style="margin-bottom:6px">
206
- <select id="a_settlement_id">
207
- <option value="">请选择行业类目</option>
208
- <option value="__MANUAL__">手动输入编码</option>
209
- </select>
210
- <input id="a_settlement_manual" class="amanual" placeholder="4 位 MCC 编码, 如: 5311" style="display:none"></div>
204
+ <div class="afield"><label for="a_settlement_id">所属行业 (微信行业名称)</label>
205
+ <input id="a_settlement_filter" placeholder="搜索行业 (如 零售 / 餐饮)" style="margin-bottom:6px">
206
+ <select id="a_settlement_id"><option value="">请选择所属行业</option></select></div>
211
207
  </div>
212
208
  </div>
213
209
 
@@ -590,7 +586,7 @@ function mountApplyUpload(slotId,field,label){
590
586
  stat.textContent=o.stat||'';
591
587
  }
592
588
  _aSlots[field]={
593
- prefill:function(mediaId){ _aMedia[field]=mediaId; toFilled({stat:'✓ 已上传过, 无需重传'}); },
589
+ prefill:function(mediaId){ _aMedia[field]=mediaId; toFilled({stat:'✓ 已上传过, 无需重传'}); saveApplyDraft(); },
594
590
  clear:toEmpty
595
591
  };
596
592
  function onFile(input){
@@ -603,14 +599,14 @@ function mountApplyUpload(slotId,field,label){
603
599
  toFilled({previewUrl:previewUrl,name:file.name,stat:'上传中...'});
604
600
  fetch('/api/onboard/applyment/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
605
601
  .then(readJson).then(function(j){
606
- if(j.ok&&j.media_id){ _aMedia[field]=j.media_id; toFilled({previewUrl:previewUrl,name:file.name,stat:'✓ 已上传'}); }
602
+ if(j.ok&&j.media_id){ _aMedia[field]=j.media_id; toFilled({previewUrl:previewUrl,name:file.name,stat:'✓ 已上传'}); saveApplyDraft(); }
607
603
  else toFilled({previewUrl:previewUrl,name:file.name,stat:'上传失败: '+(j.message||j.error||'未知错误'),err:true});
608
604
  }).catch(function(e){ toFilled({previewUrl:previewUrl,name:file.name,stat:'网络错误: '+e.message,err:true}); });
609
605
  });
610
606
  }
611
607
  zone.querySelector('input[type=file]').addEventListener('change',function(){ onFile(this); });
612
608
  filled.querySelector('.upops input[type=file]').addEventListener('change',function(){ onFile(this); });
613
- filled.querySelector('.updel').addEventListener('click',toEmpty);
609
+ filled.querySelector('.updel').addEventListener('click',function(){ toEmpty(); saveApplyDraft(); });
614
610
  }
615
611
  mountApplyUpload('a_up_license','license_media_id','营业执照照片 (必传)');
616
612
  mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (必传)');
@@ -753,7 +749,7 @@ async function submitApplyment(){
753
749
  clearTimeout(timer);
754
750
  if(j.ok){
755
751
  msg.style.display='block';
756
- msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'');
752
+ msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'')+'; 填写内容已保留在本机, 修改后可直接重提';
757
753
  document.getElementById('apply-form').style.display='none';
758
754
  queryApplyment();
759
755
  } else {
@@ -781,15 +777,75 @@ async function queryApplyment(){
781
777
  try{
782
778
  var j=await readJson(await fetch('/api/onboard/applyment/status'));
783
779
  renderApplyment(j);
784
- if(j && j.form_snapshot) prefillApplyForm(j.form_snapshot); // F1: 驳回/暂存后预填
780
+ // 草稿生命周期: FINISHED (业务完成) → 清草稿, 不再恢复
781
+ if(j&&j.status==='APPLYMENT_STATE_FINISHED'){ clearApplyDraft(); return; }
782
+ // 恢复优先级: 本机草稿 (未提交编辑态, 含敏感明文) > form_snapshot (已提交脱敏快照) > 空表单
783
+ var d=await readJson(await fetch('/api/onboard/applyment/draft'));
784
+ if(d&&d.ok&&d.fields){ restoreApplyDraft(d.fields); return; }
785
+ if(j&&j.form_snapshot) prefillApplyForm(j.form_snapshot);
785
786
  }catch(e){}
786
787
  }
788
+ // ---- 本机自动草稿 (2026-10-03 xiaomu: 重进重填敏感字段) ----
789
+ // 隐私口径: 明文只存本机 sqlite (证件原件本来就在本机); 发出时照旧本机加密 (草稿不直接外发)。
790
+ var _draftRestored=false, _draftTimer=null;
791
+ function saveApplyDraft(){
792
+ clearTimeout(_draftTimer);
793
+ _draftTimer=setTimeout(async function(){ // 防抖 800ms
794
+ try{
795
+ await fetch('/api/onboard/applyment/draft',{method:'POST',headers:{'Content-Type':'application/json'},
796
+ body:JSON.stringify({fields:applyFormBody()})}); // 场景切换不丢: 按当前场景存全量字段
797
+ }catch(e){ /* 草稿失败静默, 不挡表单 */ }
798
+ },800);
799
+ }
800
+ async function clearApplyDraft(){
801
+ try{ await fetch('/api/onboard/applyment/draft',{method:'DELETE'}); }catch(e){}
802
+ }
803
+ function restoreApplyDraft(f){
804
+ if(_draftRestored||!f||typeof f!=='object') return;
805
+ _draftRestored=true;
806
+ // 场景先切 (setScene 重挂场景字段/上传槽)
807
+ if(f.sales_scene){
808
+ var r=document.querySelector('input[name="sales_scene"][value="'+f.sales_scene+'"]');
809
+ if(r){r.checked=true;setScene(f.sales_scene);}
810
+ }
811
+ // 主体联动先触发 (居住地址/UBO 显示 + 结算规则/类目默认), 再写草稿值
812
+ var subj=document.getElementById('a_subject_type');
813
+ if(subj&&f.subject_type){subj.value=f.subject_type;subj.dispatchEvent(new Event('change'));}
814
+ for(var k in PREFILL_MAP){
815
+ if(k==='subject_type') continue;
816
+ var v=f[k];
817
+ if(!v||String(v).indexOf('***')===0) continue;
818
+ var e=document.getElementById(PREFILL_MAP[k]);
819
+ if(e&&!e.value) e.value=v;
820
+ }
821
+ // 照片 media_id 复用 → 槽位已传双态
822
+ PREFILL_MEDIA.forEach(function(k){
823
+ if(f[k]&&_aSlots[k]) _aSlots[k].prefill(f[k]);
824
+ else if(f[k]) _aMedia[k]=f[k];
825
+ });
826
+ var msg=document.getElementById('apply-msg');
827
+ msg.style.display='block';
828
+ msg.textContent='ℹ️ 已恢复上次填写 (自动保存于本机)';
829
+ // 草稿恢复了编辑态 → 展开表单
830
+ document.getElementById('apply-form').style.display='block';
831
+ }
832
+ // 任何字段 input/change → 防抖自动保存 (含场景切换时的字段集; file 选择也走 change)
833
+ (function(){
834
+ var form=document.getElementById('apply-form');
835
+ if(form){
836
+ form.addEventListener('input',saveApplyDraft);
837
+ form.addEventListener('change',saveApplyDraft);
838
+ }
839
+ })();
787
840
  // F1 预填: 台账快照回填非敏感字段 + 照片 media_id 复用 (敏感字段是密文/脱敏, 需重填)
788
841
  var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',merchant_name:'a_merchant_name',
789
842
  license_number:'a_license_number',service_phone:'a_service_phone',
790
- contact_email:'a_contact_email',legal_name:'a_legal_name',
843
+ contact_name:'a_contact_name',contact_mobile:'a_contact_mobile',contact_email:'a_contact_email',
844
+ legal_name:'a_legal_name',legal_id_number:'a_legal_id_number',legal_id_address:'a_legal_id_address',
791
845
  legal_id_period_begin:'a_period_begin',legal_id_period_end:'a_period_end',
792
- account_type:'a_account_type',account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
846
+ account_type:'a_account_type',account_name:'a_account_name',account_number:'a_account_number',
847
+ account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
848
+ qualification_type:'a_settlement_id', // 行业类目 MCC (draft 路径用; snapshot 路径走 mcc 兼容段)
793
849
  store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
794
850
  website_url:'a_website_url',appid:'a_appid',appid_type:'a_appid_type'};
795
851
  var PREFILL_MEDIA=['license_media_id','legal_id_front_media_id','legal_id_back_media_id',
@@ -814,15 +870,13 @@ function prefillApplyForm(snap){
814
870
  var e=document.getElementById(PREFILL_MAP[k]);
815
871
  if(e&&!e.value) e.value=v;
816
872
  }
817
- // 行业类目 (MCC) 预填: 新口径 qualification_type=MCC; 旧台账曾把 MCC 误存进 settlement_id
818
- // → 兼容回填 (旧值若是 4 位 MCC 而非 716/719/727, 填回"行业类目"框; 结算规则按主体重新推导)
819
- var mcc=snap.qualification_type;
820
- if((!mcc||mcc==='无'||mcc==='零售') && snap.settlement_id && ['716','719','727'].indexOf(String(snap.settlement_id))<0){
821
- mcc=snap.settlement_id;
822
- }
823
- if(mcc && mcc!=='无' && String(mcc).indexOf('***')!==0){
873
+ // 所属行业预填 (第三次字段修正口径): qualification_type=中文行业名称 (封闭集合);
874
+ // 旧快照若是 MCC 数字 (5722 等) 或 "无"/"零售" → 识别为非法, 清空让商户重选 (不报错);
875
+ // 合法行业名称则回填 (不在当前主体选项集时 select 置空, 商户重选)
876
+ var ind=snap.qualification_type;
877
+ if(ind && String(ind).indexOf('***')!==0 && !/^\d+$/.test(String(ind)) && ind!=='无' && ind!=='零售'){
824
878
  var se=document.getElementById('a_settlement_id');
825
- if(se&&!se.value) se.value=mcc; // 不在码表 (手填值) 时 select 置空, 商户重选/手填
879
+ if(se) se.value=ind; // renderIndustries 已按主体建好选项集; 不在集合内 value 置空即"未选"
826
880
  }
827
881
  // 照片复用: media_id 还在微信侧有效 → 槽位走"已上传"双态 (占位图+替换/删除, 无第二个空框)
828
882
  var reused=0;
@@ -946,44 +1000,50 @@ async function restoreIdentity(){
946
1000
  ① 编码字段下拉+联动 ② 状态卡片着色 + 签约二维码占位。
947
1001
  (证件缩略图预览已于二重框修复时并入主脚本 mountApplyUpload 槽位双态) ===== */
948
1002
  (function(){
949
- // ⓪ 编码字段下拉 (2026-10-03 xiaomu: 手填编码易被微信秒拒 → 下拉)
950
- // 经营类目 ↔ 主体类型联动 — MCC 真实编码 (V3 进件 settlement_id 用《商户行业编码》4 位 MCC,
951
- // 之前 716/719/727 是 V2 结算规则 ID, 被微信拒 "所属行业不存在"。编码主体通用,
952
- // 联动只影响默认选中: 企业→5311 百货, 个体户→5331 杂货便利店)
953
- var MCC=[['5311','百货商店'],['5331','各类杂货店便利店'],['5411','大型仓储式超级市场'],
954
- ['5611','男装店'],['5621','妇女成衣店'],['5641','婴儿儿童服装店'],['5651','家庭服装商店'],
955
- ['5655','运动服饰商店'],['5661','鞋店'],['5691','成人成衣店'],['5699','各类服装及饰物店'],
956
- ['5712','家具家用设备零售商'],['5722','家用电器商店'],['5732','电子设备商店'],['5734','计算机软件商店'],
957
- ['5921','瓶装酒零售店'],['5942','书店'],['5943','文具办公用品商店'],['5944','珠宝钟表银器店'],
958
- ['5945','玩具游戏店'],['5948','箱包皮具店'],['5949','纺织品针织品零售'],['5970','工艺美术商店'],
959
- ['5977','化妆品商店'],['5995','宠物商店及用品'],['5999','其他专门零售店'],
960
- ['5812','就餐场所和餐馆'],['5814','快餐店'],['5499','各类食品店'],['5441','糖果坚果商店'],
961
- ['5451','乳制品冷饮店'],['5462','面包房糕点商店'],['5422','各类肉类零售商'],['5912','药店药房'],
962
- ['4816','计算机网络信息服务'],['7011','住宿服务'],['4722','旅行社'],['4733','票务'],
963
- ['7230','美容理发店'],['7298','保健美容SPA'],['7295','家政服务'],['7699','各类维修店'],
964
- ['8249','贸易职业学校(职业技能培训)'],['8299','其他学校和教育服务']];
1003
+ // ⓪ 所属行业下拉 (2026-10-03 第三次字段修正, 微信文档实锤):
1004
+ // qualification_type【所属行业】=《费率结算规则对照表》"行业"列的**中文行业名称** (封闭集合),
1005
+ // 不是 MCC 数字编码 (填 5722 被微信拒 "所属行业不存在")。行业子项按主体联动 (716/719/727 各自的表)。
1006
+ var INDUSTRIES={
1007
+ SUBJECT_TYPE_ENTERPRISE:['餐饮','食品生鲜','电信运营商/宽带收费','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1008
+ '游艺厅/KTV/网吧','机票/机票代理','宠物医院','旅行社','宗教组织','房地产/房产中介','共享服务',
1009
+ '文物经营/文物复制品销售','拍卖典当','教育/培训/考试缴费/学费','零售批发/生活娱乐/网上商城/其他'],
1010
+ SUBJECT_TYPE_INDIVIDUAL:['餐饮','食品生鲜','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1011
+ '游艺厅/KTV/网吧','机票/机票代理','宠物医院','教育/培训/考试缴费/学费','零售批发/生活娱乐/其他'],
1012
+ SUBJECT_TYPE_OTHERS:['宗教组织','机票/机票代理','私立/民营医院/诊所','咨询/娱乐票务/其他']
1013
+ };
1014
+ var INDUSTRY_DEFAULT={SUBJECT_TYPE_ENTERPRISE:'零售批发/生活娱乐/网上商城/其他',
1015
+ SUBJECT_TYPE_INDIVIDUAL:'零售批发/生活娱乐/其他',SUBJECT_TYPE_OTHERS:'咨询/娱乐票务/其他'};
1016
+ // 需要许可证的特殊行业 (选项后加 ⚠️需许可证 提示; value 仍是行业名称原文)
1017
+ var INDUSTRY_LICENSE=['餐饮','食品生鲜','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1018
+ '游艺厅/KTV/网吧','教育/培训/考试缴费/学费','宠物医院','旅行社','拍卖典当','文物经营/文物复制品销售',
1019
+ '房地产/房产中介','电信运营商/宽带收费','共享服务'];
965
1020
  var subj=document.getElementById('a_subject_type');
966
1021
  var stl=document.getElementById('a_settlement_id');
967
- if(stl){
968
- // 填充 MCC 选项 (手动输入项保持在最后)
969
- var manualOpt=stl.querySelector('option[value="__MANUAL__"]');
970
- MCC.forEach(function(c){
1022
+ function renderIndustries(){
1023
+ if(!stl) return;
1024
+ var type=(subj&&INDUSTRIES[subj.value])?subj.value:'SUBJECT_TYPE_INDIVIDUAL';
1025
+ var list=INDUSTRIES[type];
1026
+ var cur=stl.value; // 尽量保留已选 (主体没换集合内同名行业时)
1027
+ stl.innerHTML='<option value="">请选择所属行业</option>';
1028
+ list.forEach(function(name){
971
1029
  var o=document.createElement('option');
972
- o.value=c[0];o.textContent=c[0]+' · '+c[1];
973
- stl.insertBefore(o,manualOpt);
1030
+ o.value=name; // 提交直接带行业名称原文 (含斜杠)
1031
+ o.textContent=name+(INDUSTRY_LICENSE.indexOf(name)>=0?' ⚠️需许可证':'');
1032
+ stl.appendChild(o);
974
1033
  });
975
- stl.value='5311'; // 默认 百货商店 (综合电商最高频)
976
- // 搜索过滤 (45 个选项手翻太长)
1034
+ stl.value=(list.indexOf(cur)>=0&&cur)?cur:(INDUSTRY_DEFAULT[type]||list[0]);
1035
+ }
1036
+ if(stl){
1037
+ renderIndustries();
1038
+ // 搜索过滤 (行业多, 按名称子串过滤)
977
1039
  var filt=document.getElementById('a_settlement_filter');
978
1040
  if(filt) filt.addEventListener('input',function(){
979
1041
  var q=filt.value.trim().toLowerCase();
980
1042
  Array.prototype.forEach.call(stl.options,function(o){
981
- if(o.value===''||o.value==='__MANUAL__') return;
1043
+ if(o.value==='') return;
982
1044
  o.hidden = !!(q && o.textContent.toLowerCase().indexOf(q)<0);
983
1045
  });
984
1046
  });
985
- // 手动输入接管 (沿用 id 移交逻辑)
986
- linkSelectManual('a_settlement_id','a_settlement_manual','__MANUAL__');
987
1047
  }
988
1048
  // 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
989
1049
  var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
@@ -995,7 +1055,7 @@ async function restoreIdentity(){
995
1055
  paintSettleRule();
996
1056
  if(subj&&stl){
997
1057
  subj.addEventListener('change',function(){
998
- stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '5311' : '5331';
1058
+ renderIndustries(); // 主体切换 → 行业选项集联动刷新 + 重选默认
999
1059
  paintSettleRule();
1000
1060
  // F4: 企业主体显示身份证居住地址(必填) + UBO 说明; 个体户隐藏
1001
1061
  var ent = subj.value==='SUBJECT_TYPE_ENTERPRISE';
package/routes/onboard.js CHANGED
@@ -606,7 +606,7 @@ function createOnboardRouter(deps) {
606
606
  account_name: "开户名称", account_number: "银行账号", account_bank: "开户银行",
607
607
  store_name: "门店名称", store_address_code: "门店省市编码", store_address: "门店详细地址",
608
608
  website_url: "网站网址", appid: "公众号/小程序 AppID",
609
- service_phone: "客服电话", qualification_type: "行业类目 (MCC 编码)",
609
+ service_phone: "客服电话", qualification_type: "所属行业 (微信行业名称)",
610
610
  license_media_id: "营业执照照片", legal_id_front_media_id: "身份证人像面", legal_id_back_media_id: "身份证国徽面",
611
611
  store_entrance_media_id: "门店门头照片", store_indoor_media_id: "门店内部照片",
612
612
  web_home_media_id: "网站首页截图", web_product_media_id: "商品/服务页截图", mp_media_id: "公众号/小程序页面截图",
@@ -699,6 +699,55 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
699
699
  }
700
700
  });
701
701
 
702
+ // ---------- 进件表单本机草稿 (2026-10-03 xiaomu 实测: 每次重进重填敏感字段) ----------
703
+ // 隐私口径: 商户资料只存本机 (证件原件本来就在本机 materials 目录), 本机明文存草稿合理;
704
+ // 发出时照旧本机加密 (加密边界不变, 草稿明文永不直接外发)。
705
+ db.exec(`
706
+ CREATE TABLE IF NOT EXISTS onboard_draft (
707
+ id INTEGER PRIMARY KEY CHECK(id=1), -- 单行 (一台机器一个卖家)
708
+ fields TEXT NOT NULL DEFAULT '{}', -- 整表单字段 JSON (含敏感字段明文, 刻意)
709
+ updated_at INTEGER NOT NULL
710
+ );
711
+ `);
712
+ /** POST /applyment/draft {fields:{...}} — 保存草稿 (单行 upsert; 敏感字段明文是本机刻意口径) */
713
+ router.post("/applyment/draft", (req, res) => {
714
+ try {
715
+ const b = req.body || {};
716
+ if (!b.fields || typeof b.fields !== "object" || Array.isArray(b.fields)) {
717
+ return res.status(400).json({ ok: false, error: "invalid_param", message: "fields 必填且为对象" });
718
+ }
719
+ const raw = JSON.stringify(b.fields);
720
+ if (raw.length > 200 * 1024) {
721
+ return res.status(413).json({ ok: false, error: "draft_too_large", message: "草稿超过 200KB" });
722
+ }
723
+ const now = Math.floor(Date.now() / 1000);
724
+ db.prepare("INSERT INTO onboard_draft (id, fields, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET fields=excluded.fields, updated_at=excluded.updated_at")
725
+ .run(raw, now);
726
+ res.json({ ok: true, updated_at: now });
727
+ } catch (e) {
728
+ console.error("[onboard] draft save error:", e && e.message);
729
+ res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
730
+ }
731
+ });
732
+ /** GET /applyment/draft — 读草稿 (无缓存; 没有草稿返回 fields:null) */
733
+ router.get("/applyment/draft", (req, res) => {
734
+ try {
735
+ const row = db.prepare("SELECT fields, updated_at FROM onboard_draft WHERE id=1").get();
736
+ res.json({ ok: true, fields: row ? JSON.parse(row.fields) : null, updated_at: row ? row.updated_at : null });
737
+ } catch (e) {
738
+ res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
739
+ }
740
+ });
741
+ /** DELETE /applyment/draft — 清除草稿 (FINISHED 后业务完成; 或商户想重来) */
742
+ router.delete("/applyment/draft", (req, res) => {
743
+ try {
744
+ db.prepare("DELETE FROM onboard_draft WHERE id=1").run();
745
+ res.json({ ok: true });
746
+ } catch (e) {
747
+ res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
748
+ }
749
+ });
750
+
702
751
  /** POST /applyment/upload — 证件照片转发 SP 网关 media/upload (base64 JSON 同款) */
703
752
  router.post("/applyment/upload", async (req, res) => {
704
753
  try {
@@ -111,7 +111,7 @@ async function fillForm(base) {
111
111
  account_type: "BANK_ACCOUNT_TYPE_PERSONAL",
112
112
  account_name: "张三", account_number: PLAINTEXT.card, account_bank: "招商银行",
113
113
  store_name: "张三餐饮店南山店", store_address_code: "440300", store_address: "南山区xx路1号",
114
- service_phone: "0755-12345678", qualification_type: "5331", // 行业类目 MCC (settlement_id 结算规则由后端按主体推导)
114
+ service_phone: "0755-12345678", qualification_type: "零售批发/生活娱乐/其他", // 所属行业=中文行业名称 (settlement_id 结算规则由后端按主体推导)
115
115
  ...media,
116
116
  };
117
117
  }
@@ -287,14 +287,17 @@ test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元
287
287
  assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
288
288
  assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
289
289
  // 2026-10-03 MCC 修正: 经营类目下拉是真实 MCC 编码 (716/719/727 是 V2 结算规则 ID, 被微信拒)
290
- assert.ok(html.includes("5311") && html.includes("5732"), "MCC 编码选项");
291
- assert.ok(html.includes("百货商店") && html.includes("就餐场所和餐馆"), "MCC 名称");
292
- assert.ok(!html.includes('value="719"') && !html.includes('value="716"') && !html.includes('value="727"'), "V2 错误编码已清除");
293
- assert.ok(html.includes("a_settlement_filter"), "类目搜索过滤");
294
- assert.ok(html.includes("a_settlement_manual") && html.includes("__MANUAL__"), "手动输入接管保留");
295
- // 2026-10-03 字段语义最终修正: 双字段 (结算规则只读展示 + 行业类目 MCC 下拉)
290
+ // 2026-10-03 第三次字段修正: qualification_type = 中文行业名称 (封闭集合, 非 MCC)
291
+ assert.ok(html.includes("零售批发/生活娱乐/网上商城/其他"), "企业行业子项");
292
+ assert.ok(html.includes("零售批发/生活娱乐/其他") && html.includes("咨询/娱乐票务/其他"), "个体户/其他组织行业子项");
293
+ assert.ok(html.includes("⚠️需许可证"), "特殊行业许可证标记");
294
+ assert.ok(html.includes("INDUSTRIES"), "主体联动选项集");
295
+ assert.ok(html.includes("a_settlement_filter"), "行业搜索过滤");
296
+ assert.ok(!html.includes("a_settlement_manual"), "行业手输框已删 (封闭集合不允许手输; 省市编码的 __MANUAL__ 保留)");
297
+ assert.ok(!/['"]5311['"]/.test(html) && !/['"]5722['"]/.test(html), "MCC 数字编码已清除");
298
+ // 双字段 (结算规则只读展示 + 所属行业下拉)
296
299
  assert.ok(html.includes("a_settlement_rule"), "结算规则只读展示框");
297
- assert.ok(html.includes("结算规则") && html.includes("行业类目"), "双字段标签");
300
+ assert.ok(html.includes("结算规则") && html.includes("所属行业"), "双字段标签");
298
301
  assert.ok(!html.includes("a_qualification_type"), "旧资质描述字段已移除");
299
302
  assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
300
303
  assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
@@ -357,17 +360,18 @@ test("字段语义最终修正: settlement_id=结算规则按主体推导 + qual
357
360
  let j = await r.json();
358
361
  assert.strictEqual(r.status, 400);
359
362
  assert.strictEqual(j.field, "qualification_type");
360
- assert.ok(j.message.includes("行业类目"), j.message);
363
+ assert.ok(j.message.includes("所属行业"), j.message);
361
364
  // 个体户 → settlement_id=719 (前端没传, 后端推导) + qualification_type=5331 透传
362
365
  const before = spCapture.submits.length;
366
+ // 补回行业名称再提交
367
+ form.qualification_type = "零售批发/生活娱乐/其他";
363
368
  r = await fetch(base + "/api/onboard/applyment", {
364
- method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form2ok(form) ),
369
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
365
370
  });
366
- function form2ok(f){ const c={...f, qualification_type:"5331"}; return c; }
367
371
  j = await r.json();
368
372
  assert.strictEqual(r.status, 200, JSON.stringify(j));
369
373
  assert.strictEqual(spCapture.submits[before].settlement_id, "719", "个体户 → 结算规则 719 (后端推导)");
370
- assert.strictEqual(spCapture.submits[before].qualification_type, "5331", "行业类目 MCC 透传");
374
+ assert.strictEqual(spCapture.submits[before].qualification_type, "零售批发/生活娱乐/其他", "所属行业=中文行业名称透传");
371
375
  } finally { srv.close(); }
372
376
  });
373
377
 
@@ -376,7 +380,7 @@ test("字段语义: 企业主体 → settlement_id=716 + qualification_type=MCC
376
380
  try {
377
381
  const form = await fillForm(base);
378
382
  form.subject_type = "SUBJECT_TYPE_ENTERPRISE";
379
- form.qualification_type = "5311";
383
+ form.qualification_type = "零售批发/生活娱乐/网上商城/其他";
380
384
  form.legal_id_address = "广东省深圳市南山区xx路1号101室"; // 企业必填 (F4)
381
385
  const before = spCapture.submits.length;
382
386
  const r = await fetch(base + "/api/onboard/applyment", {
@@ -386,11 +390,11 @@ test("字段语义: 企业主体 → settlement_id=716 + qualification_type=MCC
386
390
  assert.strictEqual(r.status, 200, JSON.stringify(j));
387
391
  const fwd = spCapture.submits[before];
388
392
  assert.strictEqual(fwd.settlement_id, "716", "企业 → 结算规则 716");
389
- assert.strictEqual(fwd.qualification_type, "5311", "行业类目 MCC");
393
+ assert.strictEqual(fwd.qualification_type, "零售批发/生活娱乐/网上商城/其他", "所属行业=中文行业名称");
390
394
  // SP 侧组装: settlement_info 两字段正确分工
391
395
  const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
392
396
  assert.strictEqual(wxBody.settlement_info.settlement_id, "716");
393
- assert.strictEqual(wxBody.settlement_info.qualification_type, "5311");
397
+ assert.strictEqual(wxBody.settlement_info.qualification_type, "零售批发/生活娱乐/网上商城/其他");
394
398
  } finally { srv.close(); }
395
399
  });
396
400
 
@@ -619,3 +623,55 @@ test("sp-info 代理: 全新实例 SP 不可达 → 200 降级 offline (不报
619
623
  assert.ok(html.includes("可随时更换"), "迁移保障卖点文案");
620
624
  } finally { srv.close(); process.env.SP_GATEWAY_URL = spBase; }
621
625
  });
626
+
627
+ test("草稿: POST 保存 → GET 读取 (含敏感字段明文, 本机刻意口径) → DELETE 清除", async () => {
628
+ const { srv, base } = await startSeller();
629
+ try {
630
+ // 空草稿
631
+ let j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
632
+ assert.strictEqual(j.fields, null);
633
+ // 保存 (含敏感字段明文 — 本机=商户自己电脑, 刻意)
634
+ const fields = { contact_name: "张三", legal_id_number: "440300199001011234", account_number: "6225880212345678", qualification_type: "5311", sales_scene: "SALES_SCENES_STORE" };
635
+ let r = await fetch(base + "/api/onboard/applyment/draft", {
636
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
637
+ });
638
+ assert.strictEqual(r.status, 200);
639
+ // 读回 (无缓存, upsert 覆盖)
640
+ j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
641
+ assert.deepStrictEqual(j.fields.legal_id_number, "440300199001011234");
642
+ assert.ok(j.updated_at > 0);
643
+ // upsert 覆盖 (单行)
644
+ await fetch(base + "/api/onboard/applyment/draft", {
645
+ method: "POST", headers: { "Content-Type": "application/json" },
646
+ body: JSON.stringify({ fields: { contact_name: "李四" } }),
647
+ });
648
+ j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
649
+ assert.strictEqual(j.fields.contact_name, "李四");
650
+ assert.strictEqual(j.fields.legal_id_number, undefined, "upsert 整行覆盖");
651
+ // 删除
652
+ r = await fetch(base + "/api/onboard/applyment/draft", { method: "DELETE" });
653
+ assert.strictEqual((await r.json()).ok, true);
654
+ j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
655
+ assert.strictEqual(j.fields, null, "删除后为空");
656
+ // 参数校验
657
+ r = await fetch(base + "/api/onboard/applyment/draft", {
658
+ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
659
+ });
660
+ assert.strictEqual(r.status, 400);
661
+ } finally { srv.close(); }
662
+ });
663
+
664
+ test("草稿页面逻辑: 自动保存/恢复优先级/FINISHED 清除/提交保留 元素齐", async () => {
665
+ const { srv, base } = await startSeller();
666
+ try {
667
+ const html = await (await fetch(base + "/onboard/")).text();
668
+ assert.ok(html.includes("/api/onboard/applyment/draft"), "草稿端点调用");
669
+ assert.ok(html.includes("saveApplyDraft") && html.includes("800"), "防抖 800ms 自动保存");
670
+ assert.ok(html.includes("restoreApplyDraft"), "草稿恢复函数");
671
+ assert.ok(html.includes("clearApplyDraft"), "FINISHED 清除草稿");
672
+ assert.ok(html.includes("已恢复上次填写"), "恢复提示文案");
673
+ assert.ok(html.includes("修改后可直接重提"), "提交后保留草稿提示");
674
+ // 恢复优先级注释: 草稿 > form_snapshot > 空表单
675
+ assert.ok(html.indexOf("applyment/draft") < html.indexOf("form_snapshot") || html.includes("恢复优先级"), "草稿优先于快照");
676
+ } finally { srv.close(); }
677
+ });