trustbase-seller-backend 0.1.6 → 0.1.7

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.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -114,6 +114,8 @@ async function wxApiCall(method, urlPath, bodyObj, override) {
114
114
  "Authorization": wxSignAuth(method, urlPath, bodyStr, override),
115
115
  "Content-Type": "application/json",
116
116
  "Accept": "application/json",
117
+ // 微信 APIv3 拒默认 Accept-Language: * (undici 自动带) — 2026-09-25 /v3/certificates 实测 PARAM_ERROR
118
+ "Accept-Language": "zh-CN",
117
119
  "User-Agent": "trustbase-seller-backend/1.0",
118
120
  },
119
121
  body: method === "GET" ? undefined : bodyStr,
@@ -213,10 +215,28 @@ async function refreshPlatformCerts() {
213
215
  console.log(`[agent-pay] 微信平台证书已刷新 (${Object.keys(certs).length} 张)`);
214
216
  }
215
217
 
216
- /** 验签: timestamp\nnonce\nbody 用平台证书公钥 RSA-SHA256 校验 */
218
+ /** 验签: timestamp\nnonce\nbody RSA-SHA256 校验。
219
+ * 双轨 (2026-09-25 真机): serial 以 "PUB_KEY_ID_" 开头 = 微信支付公钥模式 (新政策,
220
+ * /v3/certificates 返回空, 商户平台下载固定公钥); 否则走平台证书缓存 (老模式)。
221
+ * 公钥池 (2026-09-25): merchant + SP 多把公钥并存, 按回调 serial 精确匹配 ID —
222
+ * 直连回调用商户号公钥, SP 特约商户回调用服务商号公钥。 */
217
223
  function wxVerifyNotifySignature(serial, timestamp, nonce, rawBody, signature) {
218
- loadPlatformCertsFromDisk();
219
- const pem = wxPlatformCerts && wxPlatformCerts[serial];
224
+ let pem = null;
225
+ if (String(serial).startsWith("PUB_KEY_ID_")) {
226
+ const pool = [
227
+ [process.env.WXPAY_PUBLIC_KEY_ID, process.env.WXPAY_PUBLIC_KEY_PATH],
228
+ [process.env.WXPAY_SP_PUBLIC_KEY_ID, process.env.WXPAY_SP_PUBLIC_KEY_PATH],
229
+ ];
230
+ for (const [id, p] of pool) {
231
+ if (id && id === serial && p) {
232
+ try { pem = require("fs").readFileSync(p, "utf8"); } catch (_) { pem = null; }
233
+ break;
234
+ }
235
+ }
236
+ } else {
237
+ loadPlatformCertsFromDisk();
238
+ pem = wxPlatformCerts && wxPlatformCerts[serial];
239
+ }
220
240
  if (!pem) return false;
221
241
  const msg = [timestamp, nonce, rawBody].join("\n") + "\n";
222
242
  return crypto.createVerify("RSA-SHA256").update(msg).verify(pem, signature, "base64");
@@ -82,29 +82,43 @@ h1{font-size:22px;margin-bottom:4px}
82
82
  <div class="ops"><button class="sbtn" id="btn-verify" onclick="submitVerification()">提交实名认证</button></div>
83
83
  <div class="desc" id="verify-desc">提交后由服务商审核微信特约商户收款账户; 审核通过后自动发放 100 TCT 引导金 (无需重复提交)</div>
84
84
  <div class="hintbox" id="verify-msg"></div>
85
- <div class="ops noprint"><button class="sbtn" id="btn-materials" onclick="toggleMaterials()">填写进件资料并上传证件</button></div>
86
- <div class="hintbox" id="materials-msg"></div>
87
- <div id="materials-form" style="display:none;margin-top:10px">
88
- <div class="desc" style="margin-bottom:6px">以下资料用于服务商代你向微信申请特约商户收款账户。<b>资料只保存在你的电脑上</b>,并交给服务商用于进件。</div>
85
+ <div class="ops noprint"><button class="sbtn" id="btn-apply-toggle" onclick="toggleApplyForm()">填写进件资料并上传证件</button></div>
86
+ <div class="hintbox" id="apply-msg"></div>
87
+ <div id="apply-status" style="display:none;margin-top:10px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:12px 14px;font-size:13px;line-height:2"></div>
88
+ <div id="apply-form" style="display:none;margin-top:10px">
89
+ <div class="desc" style="margin-bottom:6px">以下资料由你的电脑<b>本地加密</b>后交给服务商,向微信申请特约商户收款账户。身份证号/银行卡号在你电脑上就已加密,任何人 (含服务商) 都看不到明文。</div>
89
90
  <label>主体类型</label>
90
- <select id="m_subject_type"><option>个体户</option><option>企业</option><option>小微</option></select>
91
- <label>商户全称 (与执照一致)</label><input id="m_merchant_name" placeholder="如: 无锡嘟嘟百货商行">
92
- <label>统一社会信用代码 / 执照编号</label><input id="m_license_no" placeholder="18 位">
93
- <label>法人/经营者姓名</label><input id="m_legal_name">
94
- <label>法人身份证号</label><input id="m_legal_id_no" placeholder="18 位">
95
- <label>超级管理员姓名</label><input id="m_admin_name">
96
- <label>超级管理员手机号</label><input id="m_admin_mobile" placeholder="需与微信实名一致">
97
- <label>门店名称 (选填)</label><input id="m_store_name">
98
- <label>门店地址 (选填)</label><input id="m_store_address">
99
- <label>结算账户类型 (选填)</label><select id="m_settle_type"><option></option><option>对公</option><option>对私</option></select>
100
- <label>开户行 (选填)</label><input id="m_bank_name">
101
- <label>结算账号 (选填)</label><input id="m_bank_account">
102
- <label>营业执照照片 (必传)</label><input type="file" id="m_file_license" accept="image/*">
103
- <label>法人身份证·正面 (必传)</label><input type="file" id="m_file_legal_id_front" accept="image/*">
104
- <label>法人身份证·反面 (必传)</label><input type="file" id="m_file_legal_id_back" accept="image/*">
105
- <label>门店照片 (选填)</label><input type="file" id="m_file_store_photo" accept="image/*">
106
- <div class="ops"><button class="sbtn" id="btn-materials-submit" onclick="submitMaterials()">提交资料</button></div>
107
- <div class="desc">单个文件不超过 4MB;资料含证件照片,请勿转发到聊天工具或网盘。</div>
91
+ <select id="a_subject_type"><option value="SUBJECT_TYPE_INDIVIDUAL">个体工商户</option><option value="SUBJECT_TYPE_ENTERPRISE">企业</option></select>
92
+ <label>商户简称 (支付完成页向买家展示)</label><input id="a_shortname" placeholder="如: 张三餐饮店">
93
+ <label>营业执照名称 (全称, 与执照一致)</label><input id="a_merchant_name" placeholder="如: 无锡嘟嘟百货商行">
94
+ <label>统一社会信用代码</label><input id="a_license_number" placeholder="18 位, 9 开头">
95
+ <label>客服电话</label><input id="a_service_phone" placeholder="交易记录向买家展示">
96
+ <label>经营类目编码</label><input id="a_settlement_id" placeholder="如: 719 (见服务商提供的类目对照表)">
97
+ <label>类目资质描述</label><input id="a_qualification_type" placeholder="如: 零售">
98
+ <label>超级管理员姓名 (与微信实名一致)</label><input id="a_contact_name">
99
+ <label>超级管理员手机号</label><input id="a_contact_mobile" placeholder="11 位, 接收微信验证码">
100
+ <label>联系邮箱</label><input id="a_contact_email" placeholder="接收微信开户邮件">
101
+ <label>法人/经营者姓名</label><input id="a_legal_name">
102
+ <label>法人身份证号</label><input id="a_legal_id_number" placeholder="17 位数字 + 数字或 X">
103
+ <label>身份证有效期</label>
104
+ <div style="display:flex;gap:8px"><input id="a_period_begin" placeholder="开始 YYYY-MM-DD"><input id="a_period_end" placeholder="结束 或 长期"></div>
105
+ <label>结算账户类型</label>
106
+ <select id="a_account_type"><option value="BANK_ACCOUNT_TYPE_PERSONAL">经营者个人银行卡 (个体户)</option><option value="BANK_ACCOUNT_TYPE_CORPORATE">对公银行账户</option></select>
107
+ <label>开户名称</label><input id="a_account_name" placeholder="个人卡=经营者姓名 / 对公=执照名称">
108
+ <label>银行账号</label><input id="a_account_number">
109
+ <label>开户银行</label><input id="a_account_bank" placeholder="如: 招商银行">
110
+ <label>开户支行 (选填)</label><input id="a_bank_name" placeholder="如: 招商银行深圳分行营业部">
111
+ <label>开户行省市编码 (选填)</label><input id="a_bank_address_code" placeholder="如: 440300">
112
+ <label>门店名称</label><input id="a_store_name">
113
+ <label>门店省市编码</label><input id="a_store_address_code" placeholder="如: 440300">
114
+ <label>门店详细地址</label><input id="a_store_address" placeholder="xx 路 xx 号">
115
+ <div id="a_up_license"></div>
116
+ <div id="a_up_idfront"></div>
117
+ <div id="a_up_idback"></div>
118
+ <div id="a_up_entrance"></div>
119
+ <div id="a_up_indoor"></div>
120
+ <div class="ops"><button class="sbtn primary" id="btn-apply-submit" onclick="submitApplyment()">加密并提交进件</button></div>
121
+ <div class="desc">照片在本机压缩后上传; 敏感字段在本机加密; 服务商暂时不可达时资料加密暂存本机, 可重试。</div>
108
122
  </div>
109
123
  </div>
110
124
  </div>
@@ -235,67 +249,130 @@ function reprintKey(){
235
249
  h.style.display=h.style.display==='block'?'none':'block';
236
250
  }
237
251
  }
238
- function toggleMaterials(){
239
- var el=document.getElementById('materials-form');
252
+ // ---- 进件 (2026-10-03 架构纠偏: 卖家本机提交 → 本机加密 → SP 网关 → 微信) ----
253
+ var _aMedia={}; // 字段名 → 微信 media_id (上传成功后才提交得动)
254
+ function toggleApplyForm(){
255
+ var el=document.getElementById('apply-form');
240
256
  el.style.display = el.style.display==='none' ? 'block' : 'none';
241
257
  }
242
- function readFileBase64(input){
243
- return new Promise(function(resolve,reject){
244
- var f=input.files && input.files[0];
245
- if(!f) return resolve(null);
246
- if(f.size > 4*1024*1024) return reject(new Error('文件超过 4MB: '+f.name));
247
- var r=new FileReader();
248
- r.onload=function(){resolve({name:f.name,data:String(r.result).replace(/^data:[^;]+;base64,/,'')})};
249
- r.onerror=function(){reject(new Error('读取失败: '+f.name))};
250
- r.readAsDataURL(f);
258
+ // 照片本机压缩 (≤1600px JPEG; 全局 JSON 上限 1MB, 超了降质再压)
259
+ function shrinkImage(file,cb){
260
+ var rd=new FileReader();
261
+ rd.onload=function(){
262
+ var img=new Image();
263
+ img.onload=function(){
264
+ var max=1600,w=img.width,h=img.height;
265
+ if(Math.max(w,h)>max){var k=max/Math.max(w,h);w=Math.round(w*k);h=Math.round(h*k);}
266
+ var cv=document.createElement('canvas');cv.width=w;cv.height=h;
267
+ cv.getContext('2d').drawImage(img,0,0,w,h);
268
+ var b64=cv.toDataURL('image/jpeg',0.85).split(',')[1];
269
+ if(b64.length*0.75>950*1024) b64=cv.toDataURL('image/jpeg',0.6).split(',')[1];
270
+ if(b64.length*0.75>950*1024) return cb(new Error('图片压缩后仍超服务器上限, 请换更小的图'));
271
+ cb(null,{filename:(file.name||'photo').replace(/\.[^.]*$/,'')+'.jpg',content_b64:b64});
272
+ };
273
+ img.onerror=function(){cb(new Error('图片读取失败'))};
274
+ img.src=rd.result;
275
+ };
276
+ rd.onerror=function(){cb(new Error('文件读取失败'))};
277
+ rd.readAsDataURL(file);
278
+ }
279
+ // 照片上传位: 选图 → 本机压缩 → 卖家后端转发 SP 网关 → media_id
280
+ function mountApplyUpload(slotId,field,label){
281
+ var slot=document.getElementById(slotId);
282
+ slot.innerHTML='<label>'+label+'</label>'
283
+ +'<label class="sbtn" style="cursor:pointer">拍照/选图<input type="file" accept="image/*" style="display:none"></label>'
284
+ +' <span id="st_'+field+'" style="font-size:12px"></span>';
285
+ var input=slot.querySelector('input[type=file]');
286
+ var stat=document.getElementById('st_'+field);
287
+ input.addEventListener('change',function(){
288
+ if(!input.files||!input.files[0]) return;
289
+ stat.style.color='';stat.textContent='压缩中...';
290
+ shrinkImage(input.files[0],function(err,payload){
291
+ if(err){stat.style.color='#DC2626';stat.textContent=err.message;return;}
292
+ stat.textContent='上传中...';
293
+ fetch('/api/onboard/applyment/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
294
+ .then(readJson).then(function(j){
295
+ if(j.ok&&j.media_id){_aMedia[field]=j.media_id;stat.style.color='#16A34A';stat.textContent='✓ 已上传';}
296
+ else{stat.style.color='#DC2626';stat.textContent='上传失败: '+(j.message||j.error||'未知错误');}
297
+ }).catch(function(e){stat.style.color='#DC2626';stat.textContent='网络错误: '+e.message;});
298
+ });
251
299
  });
252
300
  }
253
- function mval(id){var e=document.getElementById(id);return e?e.value.trim():''}
254
- async function submitMaterials(){
255
- var btn=document.getElementById('btn-materials-submit');
256
- var msg=document.getElementById('materials-msg');
257
- btn.disabled=true;btn.textContent='提交中...';
301
+ mountApplyUpload('a_up_license','license_media_id','营业执照照片 (必传)');
302
+ mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (必传)');
303
+ mountApplyUpload('a_up_idback','legal_id_back_media_id','身份证国徽面 (必传)');
304
+ mountApplyUpload('a_up_entrance','store_entrance_media_id','门店门头照片 (必传)');
305
+ mountApplyUpload('a_up_indoor','store_indoor_media_id','门店内部照片 (必传)');
306
+ function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
307
+ function applyFormBody(){
308
+ return {
309
+ subject_type:aval('a_subject_type'),merchant_shortname:aval('a_shortname'),merchant_name:aval('a_merchant_name'),
310
+ license_number:aval('a_license_number'),service_phone:aval('a_service_phone'),
311
+ settlement_id:aval('a_settlement_id'),qualification_type:aval('a_qualification_type'),
312
+ contact_name:aval('a_contact_name'),contact_mobile:aval('a_contact_mobile'),contact_email:aval('a_contact_email'),
313
+ legal_name:aval('a_legal_name'),legal_id_number:aval('a_legal_id_number'),
314
+ legal_id_period_begin:aval('a_period_begin'),legal_id_period_end:aval('a_period_end'),
315
+ account_type:aval('a_account_type'),account_name:aval('a_account_name'),account_number:aval('a_account_number'),
316
+ account_bank:aval('a_account_bank'),bank_name:aval('a_bank_name'),bank_address_code:aval('a_bank_address_code'),
317
+ store_name:aval('a_store_name'),store_address_code:aval('a_store_address_code'),store_address:aval('a_store_address'),
318
+ license_media_id:_aMedia.license_media_id||'',legal_id_front_media_id:_aMedia.legal_id_front_media_id||'',
319
+ legal_id_back_media_id:_aMedia.legal_id_back_media_id||'',store_entrance_media_id:_aMedia.store_entrance_media_id||'',
320
+ store_indoor_media_id:_aMedia.store_indoor_media_id||''
321
+ };
322
+ }
323
+ // 状态机渲染: 未提交(表单) → 已提交(business_code+轮询) → TO_BE_SIGNED(签约链接) → FINISHED(sub_mchid+引导金)
324
+ function renderApplyment(j){
325
+ var box=document.getElementById('apply-status');
326
+ if(!j||!j.status||j.status==='NOT_SUBMITTED'){box.style.display='none';return;}
327
+ box.style.display='block';
328
+ var html='';
329
+ if(j.status==='PENDING_PUSH'){
330
+ html='📦 进件资料已加密暂存本机'+(j.push_error?' (上次提交: '+j.push_error+')':'')
331
+ +' <button class="sbtn" onclick="retryApplyment()">重新提交</button>';
332
+ } else {
333
+ html='业务申请编号: <b>'+(j.business_code||'')+'</b>'
334
+ +'<br>申请状态: <b>'+j.status+'</b>'+(j.state_msg?' ('+j.state_msg+')':'')+(j.cached?' (本机缓存)':'');
335
+ if(j.reject_reason) html+='<br><span style="color:#DC2626">驳回原因: '+j.reject_reason+'</span> — 点"填写进件资料"修改后重新提交';
336
+ if(j.sub_mchid) html+='<br>特约商户号 sub_mchid: <b>'+j.sub_mchid+'</b>';
337
+ if(j.sign_url) html+='<br><a href="'+String(j.sign_url).replace(/"/g,'')+'" target="_blank" style="color:#C4633C;font-weight:600">👉 法人/超管用微信打开签约链接完成签约</a>';
338
+ if(j.status==='APPLYMENT_STATE_FINISHED') html+='<br>✅ 微信收款账户已开通, 引导金将自动发放, 可进入下一步上架商品';
339
+ html+=' <button class="sbtn" onclick="queryApplyment()">刷新状态</button>';
340
+ }
341
+ box.innerHTML=html;
342
+ if(j.status==='APPLYMENT_STATE_FINISHED') markStepDone('st-verification');
343
+ }
344
+ async function submitApplyment(){
345
+ var btn=document.getElementById('btn-apply-submit');
346
+ var msg=document.getElementById('apply-msg');
347
+ btn.disabled=true;btn.textContent='加密并提交中...';
258
348
  try{
259
- var files={};
260
- var names={license:'m_file_license',legal_id_front:'m_file_legal_id_front',legal_id_back:'m_file_legal_id_back',store_photo:'m_file_store_photo'};
261
- for(var k in names){
262
- var f=await readFileBase64(document.getElementById(names[k]));
263
- if(f) files[k]=f;
349
+ var j=await readJson(await fetch('/api/onboard/applyment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(applyFormBody())}));
350
+ if(j.ok){
351
+ msg.style.display='block';
352
+ msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'');
353
+ document.getElementById('apply-form').style.display='none';
354
+ queryApplyment();
355
+ } else {
356
+ msg.style.display='block';
357
+ msg.textContent='提交失败: '+(j.message||j.error||'未知错误');
358
+ loadApplymentStatus(); // PENDING_PUSH 时状态框给出重试按钮
264
359
  }
265
- var body={subject_type:mval('m_subject_type'),merchant_name:mval('m_merchant_name'),license_no:mval('m_license_no'),
266
- legal_name:mval('m_legal_name'),legal_id_no:mval('m_legal_id_no'),admin_name:mval('m_admin_name'),admin_mobile:mval('m_admin_mobile'),
267
- store_name:mval('m_store_name'),store_address:mval('m_store_address'),settle_type:mval('m_settle_type'),bank_name:mval('m_bank_name'),bank_account:mval('m_bank_account'),
268
- files:files};
269
- var r=await fetch('/onboard/materials',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
270
- var j=await readJson(r);
271
- if(!j.ok){msg.style.display='block';msg.textContent='提交失败: '+(j.message||j.error||'未知错误');btn.disabled=false;btn.textContent='提交资料';return}
272
- msg.style.display='block';
273
- msg.textContent='✅ '+(j.message||'资料已提交')+' — 服务商审核通过后自动发放引导金 (凭据 '+String(j.bundle_hash||'').slice(0,16)+'…)';
274
- document.getElementById('materials-form').style.display='none';
275
- markStepDone('st-verification');
276
- loadStatus(); // 资料提交后立刻拉一次链上状态
277
- btn.disabled=false;btn.textContent='提交资料';
278
- }catch(e){
279
- msg.style.display='block';msg.textContent='提交失败: '+e.message;
280
- btn.disabled=false;btn.textContent='提交资料';
281
- }
360
+ }catch(e){msg.style.display='block';msg.textContent='提交失败: 服务暂时不可用,请刷新重试 ('+e.message+')';}
361
+ btn.disabled=false;btn.textContent='加密并提交进件';
362
+ }
363
+ async function retryApplyment(){
364
+ var j=await readJson(await fetch('/api/onboard/applyment',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}));
365
+ if(j.ok){queryApplyment();}
366
+ else{var msg=document.getElementById('apply-msg');msg.style.display='block';msg.textContent=j.message||'重试失败, 请稍后再试';}
367
+ }
368
+ async function queryApplyment(){
369
+ try{renderApplyment(await readJson(await fetch('/api/onboard/applyment/status')));}catch(e){}
282
370
  }
371
+ async function loadApplymentStatus(){queryApplyment();}
283
372
  function markStepDone(id){
284
373
  var el=document.getElementById(id);
285
374
  if(el){el.className='step done';var m=el.querySelector('.mark');if(m)m.textContent='✅'}
286
375
  }
287
- async function loadMaterialsStatus(){
288
- try{
289
- var j=await readJson(await fetch('/onboard/materials'));
290
- if(!j.ok) return;
291
- if(j.submitted){
292
- markStepDone('st-verification');
293
- var msg=document.getElementById('materials-msg');
294
- msg.style.display='block';
295
- msg.textContent='资料已提交 (待审核)'+(j.pushed?' — 已送达服务商':' — '+ (j.push_error||'未能送达服务商'));
296
- }
297
- }catch(e){}
298
- }
299
376
  async function submitVerification(){
300
377
  var btn=document.getElementById('btn-verify');btn.disabled=true;btn.textContent='提交中...';
301
378
  var msg=document.getElementById('verify-msg');
@@ -324,7 +401,7 @@ async function loadStatus(){
324
401
  }
325
402
  window.addEventListener('load',loadStatus);
326
403
  setInterval(loadStatus,15000); // 链上实名审批/引导金发放会变, 页面自动刷新 (不必手点)
327
- window.addEventListener('load',loadMaterialsStatus);
404
+ window.addEventListener('load',loadApplymentStatus);
328
405
  async function submit(){
329
406
  var btn=document.getElementById('btn');btn.disabled=true;btn.textContent='生成中...';
330
407
  var d={merchant_shortname:document.getElementById('m_name').value,mobile_phone:document.getElementById('m_phone').value,category:document.getElementById('m_category').value};
package/routes/onboard.js CHANGED
@@ -13,6 +13,15 @@
13
13
  * N13 trustcoin 自助注册 (2026-09-23): submit-verification 前置 + POST /register-account
14
14
  * 独立重驱。链上 signer=creator 强制 — oracle 代签不可行, 必须商户本人签 (本机身份,
15
15
  * 同一 JS 直签通道)。oracle 侧 (c5-glue.js 步骤2.5) 见到链上账户已存在会自动继续发放。
16
+ *
17
+ * 2026-10-03 架构纠偏 (xiaomu 拍板): 微信特约商户进件主流程在卖家本机 ——
18
+ * POST /api/onboard/applyment 进件提交 (敏感字段本地 RSA-OAEP 加密后转发 SP 网关;
19
+ * 空 body = 用暂存资料重试; 已有 business_code = 幂等返回)
20
+ * POST /api/onboard/applyment/upload 证件照片 → 转发 SP 网关 media/upload → media_id
21
+ * GET /api/onboard/applyment/status 代理查 SP 网关状态 (sign_url/sub_mchid 回页面)
22
+ * SP 网关: env SP_GATEWAY_URL (默认 https://sp-notify.openclawskills.ployq.com),
23
+ * 凭证 header x-sp-admin-key = env SP_MERCHANT_TOKEN (开发期从简); SP 不可达时资料
24
+ * (密文) 暂存本地台账 onboard_applyments, 页面可重试。公钥: env WXPAY_SP_PUBLIC_KEY_PATH。
16
25
  */
17
26
  const fs = require("fs");
18
27
  const path = require("path");
@@ -429,6 +438,207 @@ function createOnboardRouter(deps) {
429
438
  }
430
439
  });
431
440
 
441
+ // ---------- 微信特约商户进件 (Stage 2, 2026-10-03 架构纠偏: 卖家本机提交 → SP 网关 → 微信) ----------
442
+ const spApplyment = require("./sp-applyment");
443
+ /** 在卖家本机就要加密的敏感字段 (与 sp-applyment buildApplymentBody 加密清单一致;
444
+ * 注意 legal_name 不在内: 营业执照 legal_person 按微信文档是明文, 加密版单独走 legal_name_cipher) */
445
+ const APPLY_SENSITIVE = ["contact_name", "contact_mobile", "contact_email", "legal_id_number", "account_name", "account_number"];
446
+ const APPLY_REQUIRED = ["contact_name", "contact_mobile", "contact_email", "merchant_shortname", "merchant_name", "license_number",
447
+ "legal_name", "legal_id_number", "legal_id_period_begin", "legal_id_period_end",
448
+ "account_name", "account_number", "account_bank", "store_name", "store_address_code", "store_address",
449
+ "service_phone", "settlement_id", "qualification_type"];
450
+ const APPLY_MEDIA = [["license_media_id", "营业执照照片"], ["legal_id_front_media_id", "身份证人像面"],
451
+ ["legal_id_back_media_id", "身份证国徽面"], ["store_entrance_media_id", "门店门头照片"], ["store_indoor_media_id", "门店内部照片"]];
452
+
453
+ /** SP 网关配置 (懒读 env, 测试可注入 deps.fetcher) */
454
+ function spGatewayUrl() {
455
+ return (process.env.SP_GATEWAY_URL || "https://sp-notify.openclawskills.ployq.com").trim().replace(/\/+$/, "");
456
+ }
457
+ async function spCall(method, urlPath, bodyObj) {
458
+ const fetcher = deps.fetcher || globalThis.fetch;
459
+ const token = (process.env.SP_MERCHANT_TOKEN || "").trim();
460
+ const r = await fetcher(spGatewayUrl() + urlPath, {
461
+ method,
462
+ headers: Object.assign({ "Content-Type": "application/json" }, token ? { "x-sp-admin-key": token } : {}),
463
+ body: bodyObj ? JSON.stringify(bodyObj) : undefined,
464
+ signal: AbortSignal.timeout(15000),
465
+ });
466
+ const j = await r.json().catch(() => ({}));
467
+ return { status: r.status, json: j };
468
+ }
469
+
470
+ /** 本地进件台账 (一台机器一个卖家, 取最新一行; business_code 未拿到前 status=PENDING_PUSH 可重试) */
471
+ db.exec(`
472
+ CREATE TABLE IF NOT EXISTS onboard_applyments (
473
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
474
+ status TEXT NOT NULL DEFAULT 'PENDING_PUSH', -- PENDING_PUSH / SUBMITTED / 微信 applyment_state
475
+ business_code TEXT NOT NULL DEFAULT '', -- SP 网关首次提交成功后分配
476
+ applyment_id TEXT NOT NULL DEFAULT '',
477
+ sign_url TEXT NOT NULL DEFAULT '',
478
+ sub_mchid TEXT NOT NULL DEFAULT '',
479
+ state_msg TEXT NOT NULL DEFAULT '',
480
+ encrypted_payload TEXT NOT NULL DEFAULT '', -- 转发 SP 的完整 JSON (敏感字段已加密, 可安全暂存重试)
481
+ form_snapshot TEXT NOT NULL DEFAULT '', -- 页面展示快照 (敏感字段脱敏)
482
+ push_error TEXT NOT NULL DEFAULT '',
483
+ created_at INTEGER NOT NULL,
484
+ updated_at INTEGER NOT NULL
485
+ );
486
+ `);
487
+ function latestApplyment() {
488
+ return db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get() || null;
489
+ }
490
+ function maskTailLocal(s) {
491
+ s = String(s || "");
492
+ return s.length <= 4 ? "***" : "***" + s.slice(-4);
493
+ }
494
+
495
+ /** POST /applyment/upload — 证件照片转发 SP 网关 media/upload (base64 JSON 同款) */
496
+ router.post("/applyment/upload", async (req, res) => {
497
+ try {
498
+ const b = req.body || {};
499
+ if (!b.filename || !b.content_b64) {
500
+ return res.status(400).json({ ok: false, error: "invalid_param", message: "filename/content_b64 必填" });
501
+ }
502
+ const r = await spCall("POST", "/api/sp/applyment/upload", { filename: String(b.filename), content_b64: String(b.content_b64) });
503
+ if (r.status !== 200 || !r.json.media_id) {
504
+ return res.status(502).json({ ok: false, error: "sp_error", sp_status: r.status, message: "服务商上传失败: " + (r.json.message || ("HTTP " + r.status)) });
505
+ }
506
+ res.json({ ok: true, media_id: r.json.media_id });
507
+ } catch (e) {
508
+ console.warn("[onboard] applyment upload SP 不可达:", e && e.message);
509
+ res.status(503).json({ ok: false, error: "sp_unavailable", message: "服务商暂时不可用,请稍后再试" });
510
+ }
511
+ });
512
+
513
+ /**
514
+ * POST /applyment — 进件提交: 敏感字段本机 RSA-OAEP 加密 (SP 公钥) → 转发 SP 网关 submit
515
+ * (already_encrypted: true 直通)。空 body = 用台账暂存的密文资料重试; 已有 business_code = 幂等返回。
516
+ */
517
+ router.post("/applyment", async (req, res) => {
518
+ try {
519
+ const b = req.body || {};
520
+ const latest = latestApplyment();
521
+ // 幂等: 已拿到 business_code → 直接返回, 不再调 SP
522
+ if (latest && latest.business_code) {
523
+ return res.json({ ok: true, idempotent: true, business_code: latest.business_code, applyment_id: latest.applyment_id, status: latest.status });
524
+ }
525
+ const hasForm = APPLY_REQUIRED.some((k) => b[k]) || Object.keys(b).some((k) => /_media_id$/.test(k));
526
+ let payload;
527
+ const now = Math.floor(Date.now() / 1000);
528
+ if (hasForm) {
529
+ // 校验必填字段 + 必备照片
530
+ for (const k of APPLY_REQUIRED) {
531
+ if (!String(b[k] || "").trim()) {
532
+ return res.status(400).json({ ok: false, error: "invalid_param", message: "缺少必填字段: " + k });
533
+ }
534
+ }
535
+ for (const [k, label] of APPLY_MEDIA) {
536
+ if (!b[k]) {
537
+ return res.status(400).json({ ok: false, error: "media_missing", message: label + " 未上传 (请先上传拿到 media_id)" });
538
+ }
539
+ }
540
+ // 本机加密 (SP 微信支付公钥; 私钥只在微信侧, 密文暂存/传输都安全)
541
+ const pubPath = (process.env.WXPAY_SP_PUBLIC_KEY_PATH || "").trim();
542
+ if (!pubPath) {
543
+ return res.status(500).json({ ok: false, error: "sp_pubkey_missing", message: "本机未配置 SP 微信支付公钥 (WXPAY_SP_PUBLIC_KEY_PATH)" });
544
+ }
545
+ let pem;
546
+ try { pem = fs.readFileSync(pubPath, "utf8"); }
547
+ catch (e) { return res.status(500).json({ ok: false, error: "sp_pubkey_missing", message: "SP 微信支付公钥读不到: " + pubPath }); }
548
+ payload = {};
549
+ for (const k of Object.keys(b)) {
550
+ if (typeof b[k] === "string" && b[k].trim()) payload[k] = b[k].trim();
551
+ }
552
+ for (const k of APPLY_SENSITIVE) payload[k] = spApplyment.encryptSensitive(payload[k], pem);
553
+ // 法人姓名双轨: legal_person 要明文 (payload.legal_name 不动),
554
+ // id_card_info.id_card_name 要密文 (单独字段, SP 侧 already_encrypted 时优先取它)
555
+ payload.legal_name_cipher = spApplyment.encryptSensitive(payload.legal_name, pem);
556
+ payload.already_encrypted = true;
557
+ // 台账暂存 (密文 payload + 脱敏快照), SP 不可达可重试
558
+ const snapshot = { ...payload };
559
+ for (const k of APPLY_SENSITIVE) snapshot[k] = maskTailLocal(b[k]); // 快照留明文后4位展示
560
+ delete snapshot.already_encrypted;
561
+ delete snapshot.legal_name_cipher; // 密文不进展示快照
562
+ const payloadStr = JSON.stringify(payload);
563
+ if (latest) {
564
+ db.prepare("UPDATE onboard_applyments SET status='PENDING_PUSH', encrypted_payload=?, form_snapshot=?, push_error='', updated_at=? WHERE id=?")
565
+ .run(payloadStr, JSON.stringify(snapshot), now, latest.id);
566
+ } else {
567
+ db.prepare("INSERT INTO onboard_applyments (status, encrypted_payload, form_snapshot, created_at, updated_at) VALUES ('PENDING_PUSH',?,?,?,?)")
568
+ .run(payloadStr, JSON.stringify(snapshot), now, now);
569
+ }
570
+ } else if (latest && latest.encrypted_payload) {
571
+ payload = JSON.parse(latest.encrypted_payload); // 重试: 用暂存密文
572
+ } else {
573
+ return res.status(400).json({ ok: false, error: "no_form", message: "请先填写进件资料" });
574
+ }
575
+ // 转发 SP 网关
576
+ let r;
577
+ try {
578
+ r = await spCall("POST", "/api/sp/applyment/submit", payload);
579
+ } catch (e) {
580
+ const msg = "服务商暂时不可用,请稍后再试 (资料已加密保存在本机, 可点重试)";
581
+ db.prepare("UPDATE onboard_applyments SET push_error=?, updated_at=? WHERE id=(SELECT id FROM onboard_applyments ORDER BY id DESC LIMIT 1)")
582
+ .run(msg, now);
583
+ console.warn("[onboard] applyment submit SP 不可达:", e && e.message);
584
+ return res.status(503).json({ ok: false, error: "sp_unavailable", message: msg, pending: true });
585
+ }
586
+ if (r.status !== 200) {
587
+ const msg = r.json.message || ("服务商返回 HTTP " + r.status);
588
+ db.prepare("UPDATE onboard_applyments SET push_error=?, updated_at=? WHERE id=(SELECT id FROM onboard_applyments ORDER BY id DESC LIMIT 1)")
589
+ .run(String(msg).slice(0, 300), now);
590
+ // SP/微信业务错误 (如资料问题) 原样透传给卖家页面
591
+ return res.status(502).json({ ok: false, error: "sp_error", sp_status: r.status, wx_code: r.json.wx_code || "", message: msg });
592
+ }
593
+ db.prepare(`UPDATE onboard_applyments SET status='SUBMITTED', business_code=?, applyment_id=?, push_error='', updated_at=?
594
+ WHERE id=(SELECT id FROM onboard_applyments ORDER BY id DESC LIMIT 1)`)
595
+ .run(String(r.json.business_code || ""), String(r.json.applyment_id || ""), now);
596
+ console.log("[onboard] applyment 已提交 SP bc:", r.json.business_code);
597
+ res.json({ ok: true, business_code: r.json.business_code, applyment_id: r.json.applyment_id, status: "SUBMITTED" });
598
+ } catch (e) {
599
+ console.error("[onboard] applyment error:", e && e.message);
600
+ res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
601
+ }
602
+ });
603
+
604
+ /** GET /applyment/status — 代理查 SP 网关状态 → 更新本地台账 → 回页面 (SP 不可达回台账缓存) */
605
+ router.get("/applyment/status", async (req, res) => {
606
+ try {
607
+ const latest = latestApplyment();
608
+ if (!latest) return res.json({ ok: true, status: "NOT_SUBMITTED" });
609
+ if (!latest.business_code) {
610
+ return res.json({ ok: true, status: latest.status, push_error: latest.push_error || undefined });
611
+ }
612
+ const now = Math.floor(Date.now() / 1000);
613
+ try {
614
+ const r = await spCall("GET", "/api/sp/applyment/status/" + encodeURIComponent(latest.business_code));
615
+ if (r.status === 200 && r.json) {
616
+ const j = r.json;
617
+ db.prepare(`UPDATE onboard_applyments SET status=?, sign_url=?, sub_mchid=?, state_msg=?, updated_at=?
618
+ WHERE id=(SELECT id FROM onboard_applyments ORDER BY id DESC LIMIT 1)`)
619
+ .run(String(j.status || latest.status), String(j.sign_url || ""), String(j.sub_mchid || ""), String(j.state_msg || ""), now);
620
+ return res.json({ ok: true, ...j });
621
+ }
622
+ // SP 返回业务错误: 透传 + 台账缓存兜底
623
+ return res.json({
624
+ ok: true, status: latest.status, business_code: latest.business_code, cached: true,
625
+ sign_url: latest.sign_url || undefined, sub_mchid: latest.sub_mchid || undefined,
626
+ message: j_safeMsg(r),
627
+ });
628
+ } catch (e) {
629
+ return res.json({
630
+ ok: true, status: latest.status, business_code: latest.business_code, cached: true,
631
+ sign_url: latest.sign_url || undefined, sub_mchid: latest.sub_mchid || undefined,
632
+ message: "服务商暂时不可用,请稍后再试 (展示本机缓存状态)",
633
+ });
634
+ }
635
+ } catch (e) {
636
+ console.error("[onboard] applyment status error:", e && e.message);
637
+ res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
638
+ }
639
+ });
640
+ function j_safeMsg(r) { return "服务商返回 HTTP " + r.status + (r.json && r.json.message ? ": " + r.json.message : ""); }
641
+
432
642
  return router;
433
643
  }
434
644