trustbase-seller-backend 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,8 @@ const CUSTOM_TYPE_URLS = [
47
47
  "/trustchain.economy.v1.MsgGrantCredits",
48
48
  // N13 (2026-09-23): trustcoin 账户自助注册 (上架/下单前置; signer=creator, 商户本人签)
49
49
  "/trustchain.trustcoin.v1.MsgRegisterAccount",
50
+ // N17 (2026-10): 引导金池自动回补 (oracle c5-glue stepPoolRefill, authority 签)
51
+ "/trustchain.reward.v1.MsgRewardPoolTransfer",
50
52
  ];
51
53
 
52
54
  let _root = null;
@@ -68,6 +70,8 @@ function protoRoot() {
68
70
  "trustchain/economy/v1/tx.proto",
69
71
  // N13 (2026-09-23): trustcoin 账户注册
70
72
  "trustchain/trustcoin/v1/tx.proto",
73
+ // N17 (2026-10): reward 池划转 (MsgRewardPoolTransfer)
74
+ "trustchain/reward/v1/tx.proto",
71
75
  ], { keepCase: true });
72
76
  _root = root;
73
77
  return root;
@@ -192,7 +196,12 @@ async function pollFinalTx(lcdUrl, txhash) {
192
196
  return null;
193
197
  }
194
198
 
195
- class SequenceMismatchErrorJs extends Error {}
199
+ /** P4: sequence mismatch 统一抛 routes/chain-writes 的 SequenceMismatchError —
200
+ * c5-glue/sp-notify/chain-writes 三处 "重查序号重试一次" 的 instanceof 判断对 JS 通道同样生效。
201
+ * 延迟 require 规避循环依赖 (chain-writes.js 顶部即 require 本模块, 加载期取不到其 class)。 */
202
+ function newSequenceMismatchError(msg) {
203
+ return new (require("../routes/chain-writes").SequenceMismatchError)(msg);
204
+ }
196
205
 
197
206
  /**
198
207
  * JS 直签 + 广播完整流程。入参/返回与 chain-writes.js 的 CLI 路径对齐:
@@ -201,7 +210,8 @@ class SequenceMismatchErrorJs extends Error {}
201
210
  * account: {accountNumber, sequence} (字符串)
202
211
  * 返回 {txhash, code, raw_log} (code!=0 也返回, 由调用方判 502)
203
212
  * memo: 可选; 下单 (MsgCreateOrder) 走 "tb-auth:<sha256>" 意愿摘要, 其余写路径留空
204
- * 抛 SequenceMismatchErrorJs (message 含 "account sequence mismatch")
213
+ * 抛 chain-writes.SequenceMismatchError (message 含 "account sequence mismatch") — P4 统一后
214
+ * 调用方 instanceof chainWrites.SequenceMismatchError 的重试逻辑对 JS 通道同样生效
205
215
  * 抛 Error("tx_delivery_timeout: ...") 当 15s 未入块
206
216
  */
207
217
  async function jsSignAndBroadcast(c, messages, account, baseDir, memo) {
@@ -228,7 +238,7 @@ async function jsSignAndBroadcast(c, messages, account, baseDir, memo) {
228
238
  const txBytes = TxRaw.encode(txRaw).finish();
229
239
  const checkTx = await broadcastTxSync(c.rpcUrl, txBytes);
230
240
  if (/account sequence mismatch/i.test(checkTx.raw_log)) {
231
- throw new SequenceMismatchErrorJs(`account sequence mismatch: ${checkTx.raw_log}`);
241
+ throw newSequenceMismatchError(`account sequence mismatch: ${checkTx.raw_log}`);
232
242
  }
233
243
  if (checkTx.code !== 0) {
234
244
  // CheckTx 拒绝 (码/ccodespace/log 原样回传, 语义同 CLI broadcast 的 code!=0)
@@ -237,7 +247,7 @@ async function jsSignAndBroadcast(c, messages, account, baseDir, memo) {
237
247
  const final = await pollFinalTx(c.lcdUrl, checkTx.txhash);
238
248
  if (final) {
239
249
  if (/account sequence mismatch/i.test(final.raw_log)) {
240
- throw new SequenceMismatchErrorJs(`account sequence mismatch: ${final.raw_log}`);
250
+ throw newSequenceMismatchError(`account sequence mismatch: ${final.raw_log}`);
241
251
  }
242
252
  return final;
243
253
  }
@@ -254,5 +264,5 @@ module.exports = {
254
264
  jsSignerAddress,
255
265
  jsSignAndBroadcast,
256
266
  toEncodeObjects,
257
- SequenceMismatchErrorJs,
267
+ newSequenceMismatchError,
258
268
  };
package/oracle/c5-glue.js CHANGED
@@ -80,6 +80,9 @@ const SIGNER_FORBIDDEN_RE = /intended signer does not match|signature verificati
80
80
  // N14: settle 幂等 (已结算/状态已变/订单不存在 → 按完成处理, 不再重试)
81
81
  const SETTLE_DONE_RE = /already settled|not in confirmed status|order not found/i;
82
82
 
83
+ // E6 P2: 链上 reason_kind+ref_id 幂等命中 (上轮 tx 实际已入块但本地未记 done, 如交付超时后重发被链拒)
84
+ const GRANT_DUP_RE = /already granted|duplicate|grant already exists/i;
85
+
83
86
  function sha256Hex(s) {
84
87
  return crypto.createHash("sha256").update(String(s)).digest("hex");
85
88
  }
@@ -566,8 +569,21 @@ function createOracleGlue(deps) {
566
569
  amount: String(g.amount),
567
570
  reason_kind: reasonKind,
568
571
  ref_id: g.refId,
569
- }]);
572
+ }]).catch((e) => {
573
+ // sendTx 异常 (链抖动/超时): 留 pending 等下轮 retryPendingGrants 补发,
574
+ // 不上抛 — 避免中断本批后续 grant (此前异常会丢失同批剩余 grant 的台账行)
575
+ warn(`[oracle-glue] E6 发放异常 ${reasonKind}/${g.refId}: ${e.message} (留 pending 下轮重试)`);
576
+ return null;
577
+ });
578
+ if (!r) continue;
570
579
  if (r.code !== 0) {
580
+ if (GRANT_DUP_RE.test(r.raw_log)) {
581
+ // 链上幂等命中: 该笔实际已发放 (本地没落 done), 按完成处理
582
+ db.prepare("UPDATE oracle_credit_grants SET status='done', note=?, updated_at=? WHERE reason_kind=? AND ref_id=?")
583
+ .run(`链上幂等命中: ${String(r.raw_log).slice(0, 160)}`, nowSec(), reasonKind, g.refId);
584
+ log(`[oracle-glue] E6 ${reasonKind}/${g.refId} 链上已发放 (幂等命中), 按完成处理`);
585
+ continue;
586
+ }
571
587
  const status = MODULE_NOT_LIVE_RE.test(r.raw_log) ? "pending" : "failed";
572
588
  db.prepare("UPDATE oracle_credit_grants SET status=?, note=?, updated_at=? WHERE reason_kind=? AND ref_id=?")
573
589
  .run(status, `code=${r.code}: ${String(r.raw_log).slice(0, 200)}`, nowSec(), reasonKind, g.refId);
@@ -580,8 +596,49 @@ function createOracleGlue(deps) {
580
596
  }
581
597
  }
582
598
 
583
- /** E6 一轮: 拉 CONFIRMED 事实 → 逐笔结算 */
599
+ /** P2: 存量 pending 行补发 (上轮 economy 未上线/sendTx 异常遗留 — 此前永不再试)。
600
+ * 每轮结算开头先扫台账重发; 链上 reason_kind+ref_id 幂等是第二道保险:
601
+ * 重复发放被链拒 (GRANT_DUP_RE) 按完成处理。返回本轮处理的 pending 行数。 */
602
+ async function retryPendingGrants(oracleAddr) {
603
+ const rows = db.prepare("SELECT * FROM oracle_credit_grants WHERE status='pending'").all();
604
+ for (const row of rows) {
605
+ let r;
606
+ try {
607
+ r = await sendTx([{
608
+ "@type": "/trustchain.economy.v1.MsgGrantCredits",
609
+ issuer: oracleAddr,
610
+ recipient: row.recipient,
611
+ amount: String(row.amount),
612
+ reason_kind: row.reason_kind,
613
+ ref_id: row.ref_id,
614
+ }]);
615
+ } catch (e) {
616
+ warn(`[oracle-glue] E6 pending 补发 ${row.reason_kind}/${row.ref_id} 异常: ${e.message} (留 pending 下轮重试)`);
617
+ continue;
618
+ }
619
+ if (r.code !== 0) {
620
+ if (GRANT_DUP_RE.test(r.raw_log)) {
621
+ db.prepare("UPDATE oracle_credit_grants SET status='done', note=?, updated_at=? WHERE reason_kind=? AND ref_id=?")
622
+ .run(`链上幂等命中: ${String(r.raw_log).slice(0, 160)}`, nowSec(), row.reason_kind, row.ref_id);
623
+ log(`[oracle-glue] E6 pending 补发 ${row.reason_kind}/${row.ref_id} 链上已发放 (幂等命中), 按完成处理`);
624
+ } else if (!MODULE_NOT_LIVE_RE.test(r.raw_log)) {
625
+ db.prepare("UPDATE oracle_credit_grants SET status='failed', note=?, updated_at=? WHERE reason_kind=? AND ref_id=?")
626
+ .run(`补发 code=${r.code}: ${String(r.raw_log).slice(0, 200)}`, nowSec(), row.reason_kind, row.ref_id);
627
+ warn(`[oracle-glue] E6 pending 补发失败 ${row.reason_kind}/${row.ref_id} code=${r.code}: ${String(r.raw_log).slice(0, 200)}`);
628
+ }
629
+ // 模块未上线 → 留 pending 下轮再试
630
+ continue;
631
+ }
632
+ db.prepare("UPDATE oracle_credit_grants SET status='done', txhash=?, updated_at=? WHERE reason_kind=? AND ref_id=?")
633
+ .run(r.txhash, nowSec(), row.reason_kind, row.ref_id);
634
+ log(`[oracle-glue] E6 pending 已补发 ${row.reason_kind}/${row.ref_id} → ${row.recipient} +${row.amount} (tx=${r.txhash})`);
635
+ }
636
+ return rows.length;
637
+ }
638
+
639
+ /** E6 一轮: 先补发存量 pending (P2) → 拉 CONFIRMED 事实 → 逐笔结算 */
584
640
  async function settleConfirmedFacts(oracleAddr) {
641
+ await retryPendingGrants(oracleAddr); // 不依赖链上 params, 先清存量
585
642
  const params = await getEconomyParams();
586
643
  if (!params) { warn("[oracle-glue] economy params 查询失败 (模块未上线?), 本轮 E6 跳过"); return; }
587
644
  const pj = await lcdGet("/oxiaom/trustchain/serviceprovider/v1/providers");
@@ -732,7 +789,7 @@ function createOracleGlue(deps) {
732
789
  await settleExpiredOrders(oracleAddr);
733
790
  }
734
791
 
735
- return { tick, enqueueBootstrapGrant, settleConfirmedFacts, settleExpiredOrders, stepBootstrapGrant, getEconomyParams, monthlyUsed, resolveOrderParties, settleFact, grantFeegrant, grantFeegrantFor, findFeegrant, stepPoolRefill, refillEnabled, poolBalanceUtct, refillWindow };
792
+ return { tick, enqueueBootstrapGrant, settleConfirmedFacts, settleExpiredOrders, stepBootstrapGrant, getEconomyParams, monthlyUsed, resolveOrderParties, settleFact, retryPendingGrants, grantFeegrant, grantFeegrantFor, findFeegrant, stepPoolRefill, refillEnabled, poolBalanceUtct, refillWindow };
736
793
  }
737
794
 
738
795
  module.exports = { createOracleGlue, oracleCfg, sha256Hex, MODULE_NOT_LIVE_RE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -49,6 +49,8 @@ async function wxCall(method, urlPath, bodyObj) {
49
49
  "Authorization": agentPay.wxSignAuth(method, urlPath, bodyStr, spOverride(c)),
50
50
  "Content-Type": "application/json",
51
51
  "Accept": "application/json",
52
+ // 微信 APIv3 拒 undici 默认 Accept-Language: * (2026-09-25 实测 PARAM_ERROR) — 与 agent-pay wxApiCall 对齐
53
+ "Accept-Language": "zh-CN",
52
54
  "User-Agent": "trustbase-seller-backend/1.0",
53
55
  },
54
56
  body: method === "GET" ? undefined : bodyStr,
@@ -0,0 +1,44 @@
1
+ syntax = "proto3";
2
+ package trustchain.reward.v1;
3
+
4
+ import "amino/amino.proto";
5
+ import "gogoproto/gogo.proto";
6
+
7
+ option go_package = "github.com/oxiaom/trustchain/x/reward/types";
8
+
9
+ message Params {
10
+ option (amino.name) = "trustchain/x/reward/Params";
11
+ option (gogoproto.equal) = true;
12
+
13
+ // reward per purchase (microTCT)
14
+ string purchase_reward = 1;
15
+
16
+ // reward per positive review (microTCT)
17
+ string review_reward = 2;
18
+
19
+ // reward per invitation (microTCT)
20
+ string invite_reward = 3;
21
+
22
+ // daily reward cap per user (microTCT)
23
+ string daily_cap = 4;
24
+
25
+ // registration reward, locked until verified (microTCT) — v0.20.0
26
+ string registration_reward = 5;
27
+
28
+ // invite reward when invitee role=buyer/both activates (microTCT) — v0.20.0 (PRD §4.9 default 5 TCT)
29
+ string invite_buyer_reward = 6;
30
+
31
+ // invite reward when invitee role=seller activates (microTCT) — v0.20.0 (PRD §4.9 default 10 TCT)
32
+ string invite_seller_reward = 7;
33
+ }
34
+
35
+ // RewardEvent tracks a single reward distribution.
36
+ message RewardEvent {
37
+ option (amino.name) = "trustchain/x/reward/RewardEvent";
38
+
39
+ string recipient = 1;
40
+ string amount = 2; // microTCT
41
+ string reward_type = 3; // "purchase", "review", "invite", "listing"
42
+ uint64 reference_id = 4; // order_id or invite_id
43
+ uint64 created_at = 5;
44
+ }
@@ -0,0 +1,62 @@
1
+ syntax = "proto3";
2
+ package trustchain.reward.v1;
3
+
4
+ import "amino/amino.proto";
5
+ import "cosmos/msg/v1/msg.proto";
6
+ import "gogoproto/gogo.proto";
7
+ import "trustchain/reward/v1/params.proto";
8
+
9
+ option go_package = "github.com/oxiaom/trustchain/x/reward/types";
10
+
11
+ // Msg defines the Msg service.
12
+ service Msg {
13
+ option (cosmos.msg.v1.service) = true;
14
+
15
+ // UpdateParams defines a (governance) operation for updating the module
16
+ // parameters. The authority defaults to the x/gov module account.
17
+ rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse);
18
+
19
+ // RewardPoolTransfer — 奖励池定向划转 (authority/emergency 双轨, 池间调拨用)
20
+ rpc RewardPoolTransfer(MsgRewardPoolTransfer) returns (MsgRewardPoolTransferResponse);
21
+ }
22
+
23
+ // MsgRewardPoolTransfer 从 x/reward 奖励池向目标地址划转 TCT (池间调拨, 如: 奖励池 → 引导金池)
24
+ message MsgRewardPoolTransfer {
25
+ option (cosmos.msg.v1.signer) = "authority";
26
+ option (amino.name) = "trustchain/x/reward/MsgRewardPoolTransfer";
27
+
28
+ // authority 是划转签名人 (gov 模块地址或应急密钥, 双轨)
29
+ string authority = 1;
30
+
31
+ // recipient 是收款地址 (普通账户或目标模块账户, 如 subchain 引导金池)
32
+ string recipient = 2;
33
+
34
+ // amount 是划转金额 (utct)
35
+ string amount = 3;
36
+ }
37
+
38
+ message MsgRewardPoolTransferResponse {
39
+ // amount 回显实际划转金额 (utct)
40
+ string amount = 1;
41
+ }
42
+
43
+ // MsgUpdateParams is the Msg/UpdateParams request type.
44
+ message MsgUpdateParams {
45
+ option (cosmos.msg.v1.signer) = "authority";
46
+ option (amino.name) = "trustchain/x/reward/MsgUpdateParams";
47
+
48
+ // authority is the address that controls the module (defaults to x/gov unless overwritten).
49
+ string authority = 1;
50
+
51
+ // params defines the module parameters to update.
52
+ //
53
+ // NOTE: All parameters must be supplied.
54
+ Params params = 2 [
55
+ (gogoproto.nullable) = false,
56
+ (amino.dont_omitempty) = true
57
+ ];
58
+ }
59
+
60
+ // MsgUpdateParamsResponse defines the response structure for executing a
61
+ // MsgUpdateParams message.
62
+ message MsgUpdateParamsResponse {}
@@ -178,6 +178,10 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
178
178
  <div class="agrid">
179
179
  <div class="afield"><label for="a_legal_name">法人/经营者姓名</label><input id="a_legal_name" placeholder="与身份证一致"></div>
180
180
  <div class="afield"><label for="a_legal_id_number">法人身份证号</label><input id="a_legal_id_number" placeholder="17 位数字 + 数字或 X"></div>
181
+ <div class="afield afield-full" id="afield_idaddr" style="display:none"><label for="a_legal_id_address">身份证居住地址 <span class="aopt">企业主体必填, 本机加密</span></label><input id="a_legal_id_address" placeholder="与身份证住址一致, 如: 广东省深圳市南山区xx路xx号"></div>
182
+ <div class="afield afield-full" id="afield_ubo_note" style="display:none">
183
+ <div class="ahint" style="margin-top:0">企业主体:若最终受益人只有法定代表人本人,无需填写受益人信息 (微信自动回填);否则暂不支持,请联系服务商协助进件</div>
184
+ </div>
181
185
  <div class="afield"><label for="a_period_begin">身份证有效期开始</label><input id="a_period_begin" placeholder="YYYY-MM-DD"></div>
182
186
  <div class="afield"><label for="a_period_end">有效期结束</label><input id="a_period_end" placeholder="YYYY-MM-DD 或 长期"></div>
183
187
  <div class="afield"><label for="a_contact_name">超级管理员姓名</label><input id="a_contact_name" placeholder="与微信实名一致"></div>
@@ -487,6 +491,7 @@ function applyFormBody(){
487
491
  legal_id_period_begin:aval('a_period_begin'),legal_id_period_end:aval('a_period_end'),
488
492
  account_type:aval('a_account_type'),account_name:aval('a_account_name'),account_number:aval('a_account_number'),
489
493
  account_bank:aval('a_account_bank'),bank_name:aval('a_bank_name'),bank_address_code:aval('a_bank_address_code'),
494
+ legal_id_address:aval('a_legal_id_address'), // 企业主体必填 (F4), 个体户留空
490
495
  sales_scene:scene,
491
496
  license_media_id:_aMedia.license_media_id||'',legal_id_front_media_id:_aMedia.legal_id_front_media_id||'',
492
497
  legal_id_back_media_id:_aMedia.legal_id_back_media_id||''
@@ -588,7 +593,53 @@ async function retryApplyment(){
588
593
  else{var msg=document.getElementById('apply-msg');msg.style.display='block';msg.textContent=j.message||'重试失败, 请稍后再试';}
589
594
  }
590
595
  async function queryApplyment(){
591
- try{renderApplyment(await readJson(await fetch('/api/onboard/applyment/status')));}catch(e){}
596
+ try{
597
+ var j=await readJson(await fetch('/api/onboard/applyment/status'));
598
+ renderApplyment(j);
599
+ if(j && j.form_snapshot) prefillApplyForm(j.form_snapshot); // F1: 驳回/暂存后预填
600
+ }catch(e){}
601
+ }
602
+ // F1 预填: 台账快照回填非敏感字段 + 照片 media_id 复用 (敏感字段是密文/脱敏, 需重填)
603
+ var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',merchant_name:'a_merchant_name',
604
+ license_number:'a_license_number',service_phone:'a_service_phone',settlement_id:'a_settlement_id',
605
+ qualification_type:'a_qualification_type',contact_email:'a_contact_email',legal_name:'a_legal_name',
606
+ legal_id_period_begin:'a_period_begin',legal_id_period_end:'a_period_end',
607
+ account_type:'a_account_type',account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
608
+ store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
609
+ website_url:'a_website_url',appid:'a_appid',appid_type:'a_appid_type'};
610
+ var PREFILL_MEDIA=['license_media_id','legal_id_front_media_id','legal_id_back_media_id',
611
+ 'store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'];
612
+ var _prefilled=false;
613
+ function prefillApplyForm(snap){
614
+ if(_prefilled||!snap||typeof snap!=='object') return;
615
+ _prefilled=true;
616
+ // 场景先切 (setScene 会重挂上传槽, 必须在写媒体状态前)
617
+ if(snap.sales_scene){
618
+ var r=document.querySelector('input[name="sales_scene"][value="'+snap.sales_scene+'"]');
619
+ if(r){r.checked=true;setScene(snap.sales_scene);}
620
+ }
621
+ // 主体类型联动先触发 (居住地址/UBO 说明显示 + 类目默认), 再写快照值 (避免类目被联动覆盖)
622
+ var subj=document.getElementById('a_subject_type');
623
+ if(subj&&snap.subject_type){subj.value=snap.subject_type;subj.dispatchEvent(new Event('change'));}
624
+ for(var k in PREFILL_MAP){
625
+ if(k==='subject_type') continue; // 上面已处理
626
+ var v=snap[k];
627
+ if(!v||v==='无') continue; // 敏感字段快照是脱敏的 ***后4位, 不会命中这里 (值带 *** 前缀也跳过)
628
+ if(String(v).indexOf('***')===0) continue;
629
+ var e=document.getElementById(PREFILL_MAP[k]);
630
+ if(e&&!e.value) e.value=v;
631
+ }
632
+ // 照片复用: media_id 还在微信侧有效, 标已传
633
+ var reused=0;
634
+ PREFILL_MEDIA.forEach(function(k){
635
+ if(!snap[k]) return;
636
+ _aMedia[k]=snap[k]; reused++;
637
+ var st=document.getElementById('st_'+k);
638
+ if(st){st.style.color='#16A34A';st.textContent='✓ 已上传过, 无需重传';}
639
+ });
640
+ var msg=document.getElementById('apply-msg');
641
+ msg.style.display='block';
642
+ msg.textContent='ℹ️ 已预填上次提交的资料; 身份证号/手机号/卡号等敏感字段需重新填写'+(reused?' (照片已传过, 无需重传)':'');
592
643
  }
593
644
  async function loadApplymentStatus(){queryApplyment();}
594
645
  function markStepDone(id){
@@ -659,6 +710,10 @@ async function submit(){
659
710
  if(subj&&stl){
660
711
  subj.addEventListener('change',function(){
661
712
  stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '716' : '719';
713
+ // F4: 企业主体显示身份证居住地址(必填) + UBO 说明; 个体户隐藏
714
+ var ent = subj.value==='SUBJECT_TYPE_ENTERPRISE';
715
+ var ia=document.getElementById('afield_idaddr'); if(ia) ia.style.display=ent?'':'none';
716
+ var un=document.getElementById('afield_ubo_note'); if(un) un.style.display=ent?'':'none';
662
717
  });
663
718
  }
664
719
  // 省市编码选项 (国标区划码, 两处下拉共用; 微信官方对照表口径)
package/routes/onboard.js CHANGED
@@ -459,12 +459,12 @@ function createOnboardRouter(deps) {
459
459
  const spApplyment = require("./sp-applyment");
460
460
  /** 在卖家本机就要加密的敏感字段 (与 sp-applyment buildApplymentBody 加密清单一致;
461
461
  * 注意 legal_name 不在内: 营业执照 legal_person 按微信文档是明文, 加密版单独走 legal_name_cipher) */
462
- const APPLY_SENSITIVE = ["contact_name", "contact_mobile", "contact_email", "legal_id_number", "account_name", "account_number"];
462
+ const APPLY_SENSITIVE = ["contact_name", "contact_mobile", "contact_email", "legal_id_number", "account_name", "account_number", "legal_id_address"];
463
463
  // 字段名 → 中文 (错误提示直接给中文, 前端无需翻译表 — 2026-10-03 xiaomu 实测 "没反应" fix)
464
464
  const APPLY_LABELS = {
465
465
  contact_name: "超管姓名", contact_mobile: "超管手机号", contact_email: "联系邮箱",
466
466
  merchant_shortname: "商户简称", merchant_name: "营业执照名称", license_number: "统一社会信用代码",
467
- legal_name: "法人/经营者姓名", legal_id_number: "法人身份证号",
467
+ legal_name: "法人/经营者姓名", legal_id_number: "法人身份证号", legal_id_address: "身份证居住地址",
468
468
  legal_id_period_begin: "身份证有效期开始", legal_id_period_end: "身份证有效期结束",
469
469
  account_name: "开户名称", account_number: "银行账号", account_bank: "开户银行",
470
470
  store_name: "门店名称", store_address_code: "门店省市编码", store_address: "门店详细地址",
@@ -479,9 +479,10 @@ function createOnboardRouter(deps) {
479
479
  "legal_name", "legal_id_number", "legal_id_period_begin", "legal_id_period_end",
480
480
  "account_name", "account_number", "account_bank", "service_phone", "settlement_id"];
481
481
  // 场景化必填 (2026-10-03 xiaomu 拍板三场景: 线下门店 / 互联网网站 / 公众号·小程序)
482
+ // F2 fix: WEB 场景微信端只有 web_info.domain 无 pics — 截图降为可选 (收集进台账备用, 不进微信 body)
482
483
  const APPLY_SCENE_CFG = {
483
484
  SALES_SCENES_STORE: { fields: ["store_name", "store_address_code", "store_address"], media: ["store_entrance_media_id", "store_indoor_media_id"] },
484
- SALES_SCENES_WEB: { fields: ["website_url"], media: ["web_home_media_id", "web_product_media_id"] },
485
+ SALES_SCENES_WEB: { fields: ["website_url"], media: [] },
485
486
  SALES_SCENES_MP: { fields: ["appid"], media: ["mp_media_id"] },
486
487
  };
487
488
  const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
@@ -497,12 +498,20 @@ function createOnboardRouter(deps) {
497
498
  method,
498
499
  headers: Object.assign({ "Content-Type": "application/json" }, token ? { "x-sp-admin-key": token } : {}),
499
500
  body: bodyObj ? JSON.stringify(bodyObj) : undefined,
500
- signal: AbortSignal.timeout(15000),
501
+ signal: AbortSignal.timeout(30000), // F3: 必须大于 SP 侧 wxRequest 的 20s 超时
501
502
  });
502
503
  const j = await r.json().catch(() => ({}));
503
504
  return { status: r.status, json: j };
504
505
  }
505
506
 
507
+ /** 敏感字段校验 helper: 加密清单里的字段若表单给了值, 必须在 payload 里有对应明文可被加密;
508
+ * legal_id_address 只在企业主体必填 (F4), 个体户选填 */
509
+ function applyRequiredExtra(b, scene) {
510
+ const reqs = APPLY_REQUIRED_COMMON.concat(APPLY_SCENE_CFG[scene].fields);
511
+ if (b.subject_type === "SUBJECT_TYPE_ENTERPRISE") reqs.push("legal_id_address");
512
+ return reqs;
513
+ }
514
+
506
515
  /** 本地进件台账 (一台机器一个卖家, 取最新一行; business_code 未拿到前 status=PENDING_PUSH 可重试) */
507
516
  db.exec(`
508
517
  CREATE TABLE IF NOT EXISTS onboard_applyments (
@@ -554,8 +563,12 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
554
563
  try {
555
564
  const b = req.body || {};
556
565
  const latest = latestApplyment();
557
- // 幂等: 已拿到 business_code → 直接返回, 不再调 SP
558
- if (latest && latest.business_code) {
566
+ // 幂等 vs 驳回重提 (2026-10-03 F1): 已拿到 business_code 且状态非驳回类 → 幂等返回;
567
+ // REJECTED/EDITTING/CANCELED → 微信支持同 business_code 覆盖修改重提, 放行走正常提交流程
568
+ // F3 注意: business_code 现在 PENDING_PUSH 阶段就生成了, 幂等必须排除 PENDING_PUSH
569
+ // (SP 还没收到的暂存单绝不当已提交, 否则重试通道被堵死)
570
+ const RETRYABLE = ["APPLYMENT_STATE_REJECTED", "APPLYMENT_STATE_EDITTING", "APPLYMENT_STATE_CANCELED", "PENDING_PUSH"];
571
+ if (latest && latest.business_code && !RETRYABLE.includes(latest.status)) {
559
572
  return res.json({ ok: true, idempotent: true, business_code: latest.business_code, applyment_id: latest.applyment_id, status: latest.status });
560
573
  }
561
574
  const hasForm = APPLY_REQUIRED_COMMON.some((k) => b[k]) || Object.keys(b).some((k) => /_media_id$/.test(k));
@@ -565,8 +578,8 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
565
578
  // 经营场景 (非法值回落线下门店)
566
579
  const scene = APPLY_SCENE_CFG[b.sales_scene] ? b.sales_scene : "SALES_SCENES_STORE";
567
580
  const sceneCfg = APPLY_SCENE_CFG[scene];
568
- // 校验必填字段 (中文提示 + field 名回传, 前端红卡 + 字段定位)
569
- for (const k of APPLY_REQUIRED_COMMON.concat(sceneCfg.fields)) {
581
+ // 校验必填字段 (中文提示 + field 名回传, 前端红卡 + 字段定位; 企业主体加验身份证居住地址 F4)
582
+ for (const k of applyRequiredExtra(b, scene)) {
570
583
  if (!String(b[k] || "").trim()) {
571
584
  const label = APPLY_LABELS[k] || k;
572
585
  return res.status(400).json({ ok: false, error: "invalid_param", field: k, message: `缺少必填字段: ${label} (${k})` });
@@ -593,7 +606,11 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
593
606
  payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
594
607
  // 通用类目资质留空 → 默认 "无" (微信通用场景接受, 避免留空被 SP 拒)
595
608
  if (!payload.qualification_type) payload.qualification_type = "无";
596
- for (const k of APPLY_SENSITIVE) payload[k] = spApplyment.encryptSensitive(payload[k], pem);
609
+ // F3: business_code 卖家侧首次提交时本地生成 (TB{毫秒}{rand6}), 台账暂存/转发/重试都带同号;
610
+ // SP 侧收到带号的提交不再新生成 (驳回重提也复用原号 — 微信同号覆盖)
611
+ const businessCode = (latest && latest.business_code) || spApplyment.genBusinessCode();
612
+ payload.business_code = businessCode;
613
+ for (const k of APPLY_SENSITIVE) if (payload[k]) payload[k] = spApplyment.encryptSensitive(payload[k], pem); // 选填敏感字段 (legal_id_address 个体户可空) 跳过空值
597
614
  // 法人姓名双轨: legal_person 要明文 (payload.legal_name 不动),
598
615
  // id_card_info.id_card_name 要密文 (单独字段, SP 侧 already_encrypted 时优先取它)
599
616
  payload.legal_name_cipher = spApplyment.encryptSensitive(payload.legal_name, pem);
@@ -605,11 +622,11 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
605
622
  delete snapshot.legal_name_cipher; // 密文不进展示快照
606
623
  const payloadStr = JSON.stringify(payload);
607
624
  if (latest) {
608
- db.prepare("UPDATE onboard_applyments SET status='PENDING_PUSH', encrypted_payload=?, form_snapshot=?, push_error='', updated_at=? WHERE id=?")
609
- .run(payloadStr, JSON.stringify(snapshot), now, latest.id);
625
+ db.prepare("UPDATE onboard_applyments SET status='PENDING_PUSH', business_code=?, encrypted_payload=?, form_snapshot=?, push_error='', updated_at=? WHERE id=?")
626
+ .run(businessCode, payloadStr, JSON.stringify(snapshot), now, latest.id);
610
627
  } else {
611
- db.prepare("INSERT INTO onboard_applyments (status, encrypted_payload, form_snapshot, created_at, updated_at) VALUES ('PENDING_PUSH',?,?,?,?)")
612
- .run(payloadStr, JSON.stringify(snapshot), now, now);
628
+ db.prepare("INSERT INTO onboard_applyments (status, business_code, encrypted_payload, form_snapshot, created_at, updated_at) VALUES ('PENDING_PUSH',?,?,?,?,?)")
629
+ .run(businessCode, payloadStr, JSON.stringify(snapshot), now, now);
613
630
  }
614
631
  } else if (latest && latest.encrypted_payload) {
615
632
  payload = JSON.parse(latest.encrypted_payload); // 重试: 用暂存密文
@@ -645,13 +662,20 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
645
662
  }
646
663
  });
647
664
 
648
- /** GET /applyment/status — 代理查 SP 网关状态 → 更新本地台账 → 回页面 (SP 不可达回台账缓存) */
665
+ /** GET /applyment/status — 代理查 SP 网关状态 → 更新本地台账 → 回页面 (SP 不可达回台账缓存)
666
+ * 附带 form_snapshot (脱敏表单快照), 前端用于驳回/暂存后的表单预填 (F1) */
649
667
  router.get("/applyment/status", async (req, res) => {
650
668
  try {
651
669
  const latest = latestApplyment();
652
670
  if (!latest) return res.json({ ok: true, status: "NOT_SUBMITTED" });
671
+ let snapshot;
672
+ try { snapshot = latest.form_snapshot ? JSON.parse(latest.form_snapshot) : undefined; } catch (_) { snapshot = undefined; }
673
+ if (latest.status === "PENDING_PUSH") {
674
+ // 暂存单可能已有 business_code (F3 本地生成) 但 SP 还没收到 — 如实回 PENDING_PUSH
675
+ return res.json({ ok: true, status: "PENDING_PUSH", business_code: latest.business_code || undefined, push_error: latest.push_error || undefined, form_snapshot: snapshot });
676
+ }
653
677
  if (!latest.business_code) {
654
- return res.json({ ok: true, status: latest.status, push_error: latest.push_error || undefined });
678
+ return res.json({ ok: true, status: latest.status, push_error: latest.push_error || undefined, form_snapshot: snapshot });
655
679
  }
656
680
  const now = Math.floor(Date.now() / 1000);
657
681
  try {
@@ -661,18 +685,20 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
661
685
  db.prepare(`UPDATE onboard_applyments SET status=?, sign_url=?, sub_mchid=?, state_msg=?, updated_at=?
662
686
  WHERE id=(SELECT id FROM onboard_applyments ORDER BY id DESC LIMIT 1)`)
663
687
  .run(String(j.status || latest.status), String(j.sign_url || ""), String(j.sub_mchid || ""), String(j.state_msg || ""), now);
664
- return res.json({ ok: true, ...j });
688
+ return res.json({ ok: true, ...j, form_snapshot: snapshot });
665
689
  }
666
690
  // SP 返回业务错误: 透传 + 台账缓存兜底
667
691
  return res.json({
668
692
  ok: true, status: latest.status, business_code: latest.business_code, cached: true,
669
693
  sign_url: latest.sign_url || undefined, sub_mchid: latest.sub_mchid || undefined,
694
+ form_snapshot: snapshot,
670
695
  message: j_safeMsg(r),
671
696
  });
672
697
  } catch (e) {
673
698
  return res.json({
674
699
  ok: true, status: latest.status, business_code: latest.business_code, cached: true,
675
700
  sign_url: latest.sign_url || undefined, sub_mchid: latest.sub_mchid || undefined,
701
+ form_snapshot: snapshot,
676
702
  message: "服务商暂时不可用,请稍后再试 (展示本机缓存状态)",
677
703
  });
678
704
  }
@@ -130,15 +130,13 @@ function buildApplymentBody(form, enc, opts) {
130
130
  ];
131
131
  if (scene === "SALES_SCENES_STORE") {
132
132
  mediaNeed.push(["store_entrance_media_id", "门店门头照片"], ["store_indoor_media_id", "门店内部照片"]);
133
- } else if (scene === "SALES_SCENES_WEB") {
134
- mediaNeed.push(["web_home_media_id", "网站首页截图"], ["web_product_media_id", "商品/服务页截图"]);
135
- } else {
136
- mediaNeed.push(["mp_media_id", "公众号/小程序页面截图"]);
137
133
  }
134
+ // WEB/MP 场景的截图不进微信 body (微信 web_info 只有 domain, 无 pics 字段 — 2026-10-03 F2 fix),
135
+ // 截图由卖家侧收集进台账备用 (服务商协助审核参考), 故此处不校验
138
136
  for (const [k, label] of mediaNeed) {
139
137
  if (!f[k]) throw new HttpError(400, "media_missing", label + " 未上传 (请先拍照上传拿到 media_id)", k);
140
138
  }
141
- // 场景化经营场景结构 (微信官方: WEB→web_info / MP→mp_info(公众号) 或 mini_program_info(小程序))
139
+ // 场景化经营场景结构 (微信官方: WEB→web_info{domain} / MP→mp_info(公众号) 或 mini_program_info(小程序))
142
140
  const salesInfo = { sales_scenes_type: [scene] };
143
141
  if (scene === "SALES_SCENES_STORE") {
144
142
  salesInfo.biz_store_info = {
@@ -151,10 +149,7 @@ function buildApplymentBody(form, enc, opts) {
151
149
  } else if (scene === "SALES_SCENES_WEB") {
152
150
  const url = reqStr(f.website_url, "网站网址", 256);
153
151
  if (!/^https?:\/\//i.test(url)) throw new HttpError(400, "invalid_param", "网站网址须以 http(s):// 开头 (需已上线且有 ICP 备案)", "website_url");
154
- salesInfo.web_info = {
155
- website_url: url,
156
- web_pics: [String(f.web_home_media_id), String(f.web_product_media_id)],
157
- };
152
+ salesInfo.web_info = { domain: url }; // 微信官方字段是 domain; web_authorisation/web_appid 选填先不传
158
153
  } else {
159
154
  // 公众号/小程序二选一: 用户只有小程序 appid 时用 mini_program_info
160
155
  if (f.appid_type === "mini") {
@@ -198,6 +193,10 @@ function buildApplymentBody(form, enc, opts) {
198
193
  id_card_number: enc(reqStr(f.legal_id_number, "身份证号", sensLen || 32)),
199
194
  card_period_begin: reqStr(f.legal_id_period_begin, "身份证有效期开始", 16),
200
195
  card_period_end: reqStr(f.legal_id_period_end, "身份证有效期结束", 16), // 长期填 "长期"
196
+ // 企业主体必填 (微信文档: 主体为企业时需填, 且需加密 — 2026-10-03 F4 fix)
197
+ ...(subjectType === "SUBJECT_TYPE_ENTERPRISE"
198
+ ? { id_card_address: enc(reqStr(f.legal_id_address, "身份证居住地址", sensLen || 512)) }
199
+ : {}),
201
200
  },
202
201
  },
203
202
  },
@@ -617,21 +616,28 @@ function createSpApplymentRouter(deps) {
617
616
  });
618
617
 
619
618
  /** POST /applyment/submit — 组装进件 JSON (敏感字段 RSA-OAEP 加密) → 微信提交 → 台账落行
620
- * 幂等: body.business_code 已存在 → 直接返回已有 applyment_id */
619
+ * 幂等/重提 (2026-10-03 F1/F3 fix):
620
+ * - business_code 由调用方 (卖家本机) 首次提交时生成并随单带上; 未带则 SP 兜底生成
621
+ * - 台账已有该号且状态 SUBMITTED/AUDITING/FINISHED 等 → 幂等返回, 不重复调微信
622
+ * - 状态 REJECTED/EDITTING/CANCELED → 微信官方支持同 business_code 覆盖修改重提, 重新组装调微信
623
+ * - 台账无该号 → 新申请单, 直接用调用方给的 business_code */
621
624
  router.post("/applyment/submit", async (req, res) => {
622
625
  if (!checkAdmin(req, res)) return;
623
626
  try {
624
627
  const f = req.body || {};
625
- // 幂等: 同 business_code 重复提交 → 返回已有记录, 不重复调微信
628
+ const RETRYABLE = ["APPLYMENT_STATE_REJECTED", "APPLYMENT_STATE_EDITTING", "APPLYMENT_STATE_CANCELED"];
629
+ let businessCode;
626
630
  if (f.business_code) {
627
631
  const bc = chainWrites.assertSafe(f.business_code, "business_code");
628
632
  const row = db.prepare("SELECT business_code, applyment_id, status FROM sp_applyments WHERE business_code = ?").get(bc);
629
- if (row) {
633
+ if (row && !RETRYABLE.includes(row.status)) {
630
634
  return res.json({ applyment_id: row.applyment_id, business_code: row.business_code, status: row.status, idempotent: true });
631
635
  }
632
- throw new HttpError(400, "unknown_business_code", "business_code 不存在 (首次提交请不要传 business_code, 由后端生成)");
636
+ businessCode = bc; // 驳回覆盖重提 (同号) 或 卖家侧生成的新号
637
+ if (row) console.log(`[sp-applyment] 驳回重提 bc=${bc} 旧状态=${row.status} → 同号覆盖提交微信`);
638
+ } else {
639
+ businessCode = genBusinessCode(); // 兜底 (SP 直录页场景)
633
640
  }
634
- const businessCode = genBusinessCode();
635
641
  // 2026-10-03 架构纠偏: 卖家本机已用 SP 公钥加密的资料 (already_encrypted: true)
636
642
  // 直通不再二次加密; SP 公钥只在收明文表单时才需要
637
643
  const alreadyEnc = !!f.already_encrypted;
@@ -656,9 +662,13 @@ function createSpApplymentRouter(deps) {
656
662
  already_encrypted: undefined,
657
663
  };
658
664
  const now = nowSec();
665
+ // upsert: 驳回重提时更新已有行 (同 business_code 覆盖), 否则插新行
659
666
  db.prepare(`INSERT INTO sp_applyments
660
667
  (business_code, applyment_id, status, subject_json, created_at, updated_at)
661
- VALUES (?,?,?,?,?,?)`)
668
+ VALUES (?,?,?,?,?,?)
669
+ ON CONFLICT(business_code) DO UPDATE SET
670
+ applyment_id=excluded.applyment_id, status=excluded.status,
671
+ subject_json=excluded.subject_json, updated_at=excluded.updated_at`)
662
672
  .run(businessCode, applymentId, "SUBMITTED", JSON.stringify(snapshot).slice(0, 8000), now, now);
663
673
  console.log(`[sp-applyment] 进件已提交 bc=${businessCode} applyment_id=${applymentId}`);
664
674
  res.json({ applyment_id: applymentId, business_code: businessCode });
package/run-unified.js CHANGED
@@ -614,6 +614,17 @@ async function startUnifiedServer() {
614
614
  // 2026-09-26: 微信支付服务商 (SP) 模式三件套 — 特约商户回调 (/api/sp/notify/wechat) + 抽佣登记 (/api/sp/commission-rate*)
615
615
  // 分账骨架在 payments/wechat/profitsharing.js (纯函数, 由 sp-notify 按抽佣比例触发, 默认 mock)
616
616
  app.use('/api/sp', (0, sp_notify_1.createSpNotifyRouter)({ db: sqliteIndexer.getRawDb() }));
617
+ // P1 fix: retryPending 此前只导出无调度方 — 支付事实上链失败的补链清扫, 每 5min 一轮
618
+ // (unref 不拖住进程退出; 连续 3 轮有失败额外告警, 单轮失败明细由 retryPending 内部 warn)
619
+ let spRetryFailStreak = 0;
620
+ const spRetryTimer = setInterval(() => {
621
+ sp_notify_1.retryPending(sqliteIndexer.getRawDb()).then((r) => {
622
+ spRetryFailStreak = r.failed > 0 ? spRetryFailStreak + 1 : 0;
623
+ if (spRetryFailStreak >= 3)
624
+ console.warn(`[sp-notify] retryPending 连续 ${spRetryFailStreak} 轮补链失败, 请检查链通道`);
625
+ }).catch((e) => console.warn('[sp-notify] retryPending 异常:', e && e.message));
626
+ }, 5 * 60 * 1000);
627
+ spRetryTimer.unref();
617
628
  app.use('/api/sp', (0, sp_commission_1.createSpCommissionRouter)({ db: sqliteIndexer.getRawDb() }));
618
629
  // 2026-10-03: SP 特约商户进件 — 手机表单页 GET /sp/apply + 进件 API /api/sp/applyment/{upload,submit,status}
619
630
  // 同一 router 挂两处: /sp 下只暴露页面 (/sp/apply), /api/sp 下是 API; /sp/applyment/* 多一份无害 (也要 admin key)