trustbase-seller-backend 0.2.9 → 0.3.1

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.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -60,6 +60,9 @@ h1{font-size:22px;margin-bottom:4px}
60
60
  #apply-form .afield input,#apply-form .afield select{width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff;transition:border-color .15s,box-shadow .15s}
61
61
  #apply-form .afield input::placeholder{color:#BDB5A6}
62
62
  #apply-form .afield input.amanual{margin-top:6px;border-style:dashed}
63
+ /* 结算规则只读展示框 (按主体推导, 商户不用选; settlement_id=结算规则 ID 716/719/727,
64
+ qualification_type=行业类目 MCC — 两次微信实测错误交叉锁定后的正确口径) */
65
+ #apply-form .arule{padding:10px 12px;border:1px dashed #D4CDBF;border-radius:8px;background:#F7F3EB;color:#6B655B;font-size:14px}
63
66
  #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)}
64
67
  /* 上传卡片 (结构由 mountApplyUpload 生成: 标题label + label.sbtn>input + span#st_*) */
65
68
  #apply-form .auploads{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
@@ -196,13 +199,15 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
196
199
  <div class="afield"><label for="a_merchant_name">营业执照名称</label><input id="a_merchant_name" placeholder="全称, 与执照一致"></div>
197
200
  <div class="afield"><label for="a_license_number">统一社会信用代码</label><input id="a_license_number" placeholder="18 位, 9 开头"></div>
198
201
  <div class="afield"><label for="a_service_phone">客服电话</label><input id="a_service_phone" placeholder="交易记录向买家展示"></div>
199
- <div class="afield"><label for="a_settlement_id">经营类目</label>
202
+ <div class="afield"><label>结算规则 (按主体自动确定, 不用选)</label>
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">
200
206
  <select id="a_settlement_id">
201
- <option value="719">719 · 个体户通用(餐饮/零售/生活服务,0.6% T+1)</option>
202
- <option value="716">716 · 企业通用(餐饮/零售/网上商城/生活服务,0.6% T+1)</option>
203
- <option value="727">727 · 其他组织通用(咨询/社区服务/娱乐票务,0.6% T+1)</option>
204
- </select></div>
205
- <div class="afield afield-full"><label for="a_qualification_type">类目资质描述 <span class="aopt">通用类目留空</span></label><input id="a_qualification_type" placeholder="通用零售/餐饮留空即可;特殊行业(医疗/教育/食品)需对应资质"></div>
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>
206
211
  </div>
207
212
  </div>
208
213
 
@@ -642,7 +647,7 @@ function applyFormBody(){
642
647
  var body={
643
648
  subject_type:aval('a_subject_type'),merchant_shortname:aval('a_shortname'),merchant_name:aval('a_merchant_name'),
644
649
  license_number:aval('a_license_number'),service_phone:aval('a_service_phone'),
645
- settlement_id:aval('a_settlement_id'),qualification_type:aval('a_qualification_type'),
650
+ qualification_type:aval('a_settlement_id'), // 行业类目 MCC (settlement_id=结算规则由后端按主体推导, 前端不发)
646
651
  contact_name:aval('a_contact_name'),contact_mobile:aval('a_contact_mobile'),contact_email:aval('a_contact_email'),
647
652
  legal_name:aval('a_legal_name'),legal_id_number:aval('a_legal_id_number'),
648
653
  legal_id_period_begin:aval('a_period_begin'),legal_id_period_end:aval('a_period_end'),
@@ -781,8 +786,8 @@ async function queryApplyment(){
781
786
  }
782
787
  // F1 预填: 台账快照回填非敏感字段 + 照片 media_id 复用 (敏感字段是密文/脱敏, 需重填)
783
788
  var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',merchant_name:'a_merchant_name',
784
- license_number:'a_license_number',service_phone:'a_service_phone',settlement_id:'a_settlement_id',
785
- qualification_type:'a_qualification_type',contact_email:'a_contact_email',legal_name:'a_legal_name',
789
+ license_number:'a_license_number',service_phone:'a_service_phone',
790
+ contact_email:'a_contact_email',legal_name:'a_legal_name',
786
791
  legal_id_period_begin:'a_period_begin',legal_id_period_end:'a_period_end',
787
792
  account_type:'a_account_type',account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
788
793
  store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
@@ -809,6 +814,16 @@ function prefillApplyForm(snap){
809
814
  var e=document.getElementById(PREFILL_MAP[k]);
810
815
  if(e&&!e.value) e.value=v;
811
816
  }
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){
824
+ var se=document.getElementById('a_settlement_id');
825
+ if(se&&!se.value) se.value=mcc; // 不在码表 (手填值) 时 select 置空, 商户重选/手填
826
+ }
812
827
  // 照片复用: media_id 还在微信侧有效 → 槽位走"已上传"双态 (占位图+替换/删除, 无第二个空框)
813
828
  var reused=0;
814
829
  PREFILL_MEDIA.forEach(function(k){
@@ -932,12 +947,56 @@ async function restoreIdentity(){
932
947
  (证件缩略图预览已于二重框修复时并入主脚本 mountApplyUpload 槽位双态) ===== */
933
948
  (function(){
934
949
  // ⓪ 编码字段下拉 (2026-10-03 xiaomu: 手填编码易被微信秒拒 → 下拉)
935
- // 经营类目 ↔ 主体类型联动 (企业默认 716 / 个体户默认 719, 可手改)
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','其他学校和教育服务']];
936
965
  var subj=document.getElementById('a_subject_type');
937
966
  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){
971
+ var o=document.createElement('option');
972
+ o.value=c[0];o.textContent=c[0]+' · '+c[1];
973
+ stl.insertBefore(o,manualOpt);
974
+ });
975
+ stl.value='5311'; // 默认 百货商店 (综合电商最高频)
976
+ // 搜索过滤 (45 个选项手翻太长)
977
+ var filt=document.getElementById('a_settlement_filter');
978
+ if(filt) filt.addEventListener('input',function(){
979
+ var q=filt.value.trim().toLowerCase();
980
+ Array.prototype.forEach.call(stl.options,function(o){
981
+ if(o.value===''||o.value==='__MANUAL__') return;
982
+ o.hidden = !!(q && o.textContent.toLowerCase().indexOf(q)<0);
983
+ });
984
+ });
985
+ // 手动输入接管 (沿用 id 移交逻辑)
986
+ linkSelectManual('a_settlement_id','a_settlement_manual','__MANUAL__');
987
+ }
988
+ // 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
989
+ var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
990
+ SUBJECT_TYPE_ENTERPRISE:'716 · 企业通用(0.6% T+1)',SUBJECT_TYPE_OTHERS:'727 · 其他组织通用(0.6% T+1)'};
991
+ function paintSettleRule(){
992
+ var el=document.getElementById('a_settlement_rule');
993
+ if(el&&subj) el.textContent=SETTLE_RULES[subj.value]||SETTLE_RULES.SUBJECT_TYPE_INDIVIDUAL;
994
+ }
995
+ paintSettleRule();
938
996
  if(subj&&stl){
939
997
  subj.addEventListener('change',function(){
940
- stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '716' : '719';
998
+ stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '5311' : '5331';
999
+ paintSettleRule();
941
1000
  // F4: 企业主体显示身份证居住地址(必填) + UBO 说明; 个体户隐藏
942
1001
  var ent = subj.value==='SUBJECT_TYPE_ENTERPRISE';
943
1002
  var ia=document.getElementById('afield_idaddr'); if(ia) ia.style.display=ent?'':'none';
package/routes/onboard.js CHANGED
@@ -606,15 +606,17 @@ 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: "客服电话", settlement_id: "经营类目",
609
+ service_phone: "客服电话", qualification_type: "行业类目 (MCC 编码)",
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: "公众号/小程序页面截图",
613
613
  };
614
- // 通用必填 (qualification_type 不在内: 通用类目留空, 后端默认 "无" — 2026-10-03 实测秒拒 fix)
614
+ // 通用必填 — 字段语义最终修正 (两次微信实测错误交叉锁定):
615
+ // settlement_id = 结算规则 ID (716/719/727, 后端按主体推导, 不依赖前端);
616
+ // qualification_type = 行业类目编码 MCC (表单"行业类目"下拉值, 必填 — "无"不是合法行业编码)
615
617
  const APPLY_REQUIRED_COMMON = ["contact_name", "contact_mobile", "contact_email", "merchant_shortname", "merchant_name", "license_number",
616
618
  "legal_name", "legal_id_number", "legal_id_period_begin", "legal_id_period_end",
617
- "account_name", "account_number", "account_bank", "service_phone", "settlement_id"];
619
+ "account_name", "account_number", "account_bank", "service_phone", "qualification_type"];
618
620
  // 场景化必填 (2026-10-03 xiaomu 拍板三场景: 线下门店 / 互联网网站 / 公众号·小程序)
619
621
  // F2 fix: WEB 场景微信端只有 web_info.domain 无 pics — 截图降为可选 (收集进台账备用, 不进微信 body)
620
622
  const APPLY_SCENE_CFG = {
@@ -765,7 +767,9 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
765
767
  }
766
768
  payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
767
769
  // 通用类目资质留空 → 默认 "无" (微信通用场景接受, 避免留空被 SP 拒)
768
- if (!payload.qualification_type) payload.qualification_type = "无";
770
+ // settlement_id = 结算规则 ID, 按主体推导 (商户不用选; 前端不传也兜底)
771
+ payload.settlement_id = payload.subject_type === "SUBJECT_TYPE_ENTERPRISE" ? "716"
772
+ : (payload.subject_type === "SUBJECT_TYPE_OTHERS" ? "727" : "719");
769
773
  // M2 断点 c: tb_address 贯通 — SP 台账落列, FINISHED 后 oracle 引导金才知道发给谁
770
774
  const identity = await ensureSellerIdentity(basePaths.baseDir());
771
775
  payload.tb_address = identity.address;
@@ -241,9 +241,11 @@ function buildApplymentBody(form, enc, opts) {
241
241
  sales_info: salesInfo,
242
242
  },
243
243
  settlement_info: {
244
- settlement_id: reqStr(f.settlement_id, "经营类目", 16),
245
- // 通用类目 (716/719/727) 资质留空即可 — 留空默认 "无" (2026-10-03 xiaomu 实测秒拒 fix)
246
- qualification_type: f.qualification_type ? reqStr(f.qualification_type, "类目资质描述", 128) : "无",
244
+ // 字段语义最终修正 (两次微信实测错误交叉锁定):
245
+ // settlement_id = 结算规则 ID (716 企业 / 719 个体户 / 727 其他组织, 卖家侧按主体推导)
246
+ // qualification_type = 行业类目编码 MCC (5311 百货商店等; "无"不是合法行业编码)
247
+ settlement_id: reqStr(f.settlement_id, "结算规则 ID", 16),
248
+ qualification_type: reqStr(f.qualification_type, "行业类目编码(MCC)", 128),
247
249
  },
248
250
  bank_account_info: {
249
251
  bank_account_type: f.account_type === "BANK_ACCOUNT_TYPE_PERSONAL" ? "BANK_ACCOUNT_TYPE_PERSONAL" : "BANK_ACCOUNT_TYPE_CORPORATE",
@@ -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", settlement_id: "719", qualification_type: "零售",
114
+ service_phone: "0755-12345678", qualification_type: "5331", // 行业类目 MCC (settlement_id 结算规则由后端按主体推导)
115
115
  ...media,
116
116
  };
117
117
  }
@@ -285,7 +285,17 @@ test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元
285
285
  assert.ok(html.includes("_aSlots"), "槽位双态注册表");
286
286
  assert.ok(html.includes("delete _aMedia[field]"), "删除清 media_id");
287
287
  assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
288
- assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
288
+ assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
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 下拉)
296
+ assert.ok(html.includes("a_settlement_rule"), "结算规则只读展示框");
297
+ assert.ok(html.includes("结算规则") && html.includes("行业类目"), "双字段标签");
298
+ assert.ok(!html.includes("a_qualification_type"), "旧资质描述字段已移除");
289
299
  assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
290
300
  assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
291
301
  } finally { srv.close(); }
@@ -335,18 +345,52 @@ test("错误提示: 缺 store_address → 中文 message + field 定位 (2026-10
335
345
  } finally { srv.close(); }
336
346
  });
337
347
 
338
- test("qualification_type 留空可提交 (后端默认 '无', 2026-10-03 实测秒拒 fix)", async () => {
348
+ test("字段语义最终修正: settlement_id=结算规则按主体推导 + qualification_type=MCC 必填", async () => {
339
349
  const { srv, base } = await startSeller();
340
350
  try {
351
+ // 行业类目缺失 → 400 (两次微信实测: "无"不是合法行业编码)
341
352
  const form = await fillForm(base);
342
353
  delete form.qualification_type;
354
+ let r = await fetch(base + "/api/onboard/applyment", {
355
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
356
+ });
357
+ let j = await r.json();
358
+ assert.strictEqual(r.status, 400);
359
+ assert.strictEqual(j.field, "qualification_type");
360
+ assert.ok(j.message.includes("行业类目"), j.message);
361
+ // 个体户 → settlement_id=719 (前端没传, 后端推导) + qualification_type=5331 透传
362
+ const before = spCapture.submits.length;
363
+ r = await fetch(base + "/api/onboard/applyment", {
364
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form2ok(form) ),
365
+ });
366
+ function form2ok(f){ const c={...f, qualification_type:"5331"}; return c; }
367
+ j = await r.json();
368
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
369
+ assert.strictEqual(spCapture.submits[before].settlement_id, "719", "个体户 → 结算规则 719 (后端推导)");
370
+ assert.strictEqual(spCapture.submits[before].qualification_type, "5331", "行业类目 MCC 透传");
371
+ } finally { srv.close(); }
372
+ });
373
+
374
+ test("字段语义: 企业主体 → settlement_id=716 + qualification_type=MCC (submit 组装断言)", async () => {
375
+ const { srv, base } = await startSeller();
376
+ try {
377
+ const form = await fillForm(base);
378
+ form.subject_type = "SUBJECT_TYPE_ENTERPRISE";
379
+ form.qualification_type = "5311";
380
+ form.legal_id_address = "广东省深圳市南山区xx路1号101室"; // 企业必填 (F4)
343
381
  const before = spCapture.submits.length;
344
382
  const r = await fetch(base + "/api/onboard/applyment", {
345
383
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
346
384
  });
347
385
  const j = await r.json();
348
386
  assert.strictEqual(r.status, 200, JSON.stringify(j));
349
- assert.strictEqual(spCapture.submits[before].qualification_type, "无", "留空默认 '无'");
387
+ const fwd = spCapture.submits[before];
388
+ assert.strictEqual(fwd.settlement_id, "716", "企业 → 结算规则 716");
389
+ assert.strictEqual(fwd.qualification_type, "5311", "行业类目 MCC");
390
+ // SP 侧组装: settlement_info 两字段正确分工
391
+ const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
392
+ assert.strictEqual(wxBody.settlement_info.settlement_id, "716");
393
+ assert.strictEqual(wxBody.settlement_info.qualification_type, "5311");
350
394
  } finally { srv.close(); }
351
395
  });
352
396