trustbase-seller-backend 0.1.11 → 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.11",
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 {}
@@ -89,10 +89,12 @@ const EXEC_TIMEOUT_MS = 30_000;
89
89
  class HttpError extends Error {
90
90
  status;
91
91
  code;
92
- constructor(status, code, message) {
92
+ field;
93
+ constructor(status, code, message, field) {
93
94
  super(message);
94
95
  this.status = status;
95
96
  this.code = code;
97
+ this.field = field; // 可选: 出错字段名 (前端定位高亮用, 2026-10-03 进件表单错误提示)
96
98
  }
97
99
  }
98
100
  exports.HttpError = HttpError;
@@ -650,7 +652,7 @@ function createChainWritesRouter(deps) {
650
652
  }
651
653
  function handleError(res, e, tag) {
652
654
  if (e instanceof HttpError) {
653
- return res.status(e.status).json({ error: e.code, message: e.message });
655
+ return res.status(e.status).json({ error: e.code, message: e.message, ...(e.field ? { field: e.field } : {}) });
654
656
  }
655
657
  console.error(`[chain-writes] ${tag} 失败:`, e?.message ?? e);
656
658
  return res.status(502).json({ error: 'chain_write_error', message: e?.message || String(e) });
@@ -85,9 +85,23 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
85
85
  #apply-status[data-state="pending"]{background:#FFF7ED;border-color:#FED7AA}
86
86
  #apply-status a{display:inline-block;background:#C4633C;color:#fff !important;border-radius:8px;padding:10px 18px;font-size:15px;font-weight:600;text-decoration:none;margin-top:8px}
87
87
  #apply-status .aqr{display:flex;align-items:center;justify-content:center;width:120px;height:120px;border:2px dashed #93C5FD;border-radius:10px;background:#fff;color:#8A857B;font-size:12px;text-align:center;margin:10px 0 2px;line-height:1.6}
88
+ /* 经营场景选择卡 (2026-10-03 三场景) */
89
+ #apply-form .ascene-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
90
+ #apply-form .ascene{display:flex;flex-direction:column;align-items:center;gap:2px;border:2px solid #EAE3D6;border-radius:12px;background:#FFFDF9;padding:14px 8px;cursor:pointer;text-align:center;transition:border-color .15s,background .15s}
91
+ #apply-form .ascene input{display:none}
92
+ #apply-form .ascene .ascene-ico{font-size:24px;line-height:1}
93
+ #apply-form .ascene .ascene-name{font-size:14px;font-weight:600;color:#33302B}
94
+ #apply-form .ascene .ascene-sub{font-size:11px;color:#8A857B;line-height:1.5}
95
+ #apply-form .ascene.selected{border-color:#C4633C;background:#FBF1EA;box-shadow:0 0 0 3px rgba(196,99,60,.12)}
96
+ #apply-form .ahint{font-size:12px;color:#B45309;background:#FFFBEB;border:1px solid #FDE68A;border-radius:8px;padding:8px 10px;margin-top:10px}
97
+ /* 错误提示红卡 + 字段高亮 (2026-10-03 xiaomu 实测 "没反应" fix) */
98
+ #apply-err{background:#FEF2F2;border:1px solid #FCA5A5;border-left:4px solid #DC2626;border-radius:10px;padding:12px 14px;font-size:14px;color:#991B1B;margin:4px 0 12px;line-height:1.8;word-break:break-all}
99
+ #apply-form .aerr-field,#apply-form input.aerr-field,#apply-form select.aerr-field{border-color:#DC2626 !important;box-shadow:0 0 0 3px rgba(220,38,38,.18) !important}
100
+ #apply-form div[id^="a_up_"].aerr-field label.sbtn{border-color:#DC2626;background:#FEF2F2}
88
101
  @media (max-width:640px){
89
102
  #apply-form .agrid{grid-template-columns:1fr}
90
103
  #apply-form .auploads{grid-template-columns:1fr}
104
+ #apply-form .ascene-row{grid-template-columns:1fr}
91
105
  }
92
106
  @media print{
93
107
  body *{visibility:hidden}
@@ -128,6 +142,18 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
128
142
  <div id="apply-form" style="display:none;margin-top:12px">
129
143
  <div class="desc" style="margin-bottom:12px">以下资料由你的电脑<b>本地加密</b>后交给服务商,向微信申请特约商户收款账户。身份证号/银行卡号在你电脑上就已加密,任何人 (含服务商) 都看不到明文。</div>
130
144
 
145
+ <div class="acard">
146
+ <div class="acard-h">经营场景 <span class="aopt">三选一, 决定第 4/5 张卡片要什么资料</span></div>
147
+ <div class="ascene-row">
148
+ <label class="ascene selected"><input type="radio" name="sales_scene" value="SALES_SCENES_STORE" checked onchange="setScene(this.value)">
149
+ <span class="ascene-ico">🏬</span><span class="ascene-name">线下门店</span><span class="ascene-sub">门店地址 + 门头/店内照片</span></label>
150
+ <label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_WEB" onchange="setScene(this.value)">
151
+ <span class="ascene-ico">🌐</span><span class="ascene-name">互联网网站</span><span class="ascene-sub">网址 + 截图, 需 ICP 备案</span></label>
152
+ <label class="ascene"><input type="radio" name="sales_scene" value="SALES_SCENES_MP" onchange="setScene(this.value)">
153
+ <span class="ascene-ico">📱</span><span class="ascene-name">公众号/小程序</span><span class="ascene-sub">AppID + 页面截图</span></label>
154
+ </div>
155
+ </div>
156
+
131
157
  <div class="acard">
132
158
  <div class="acard-h"><span class="anum">1</span>主体资料</div>
133
159
  <div class="agrid">
@@ -152,6 +178,10 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
152
178
  <div class="agrid">
153
179
  <div class="afield"><label for="a_legal_name">法人/经营者姓名</label><input id="a_legal_name" placeholder="与身份证一致"></div>
154
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>
155
185
  <div class="afield"><label for="a_period_begin">身份证有效期开始</label><input id="a_period_begin" placeholder="YYYY-MM-DD"></div>
156
186
  <div class="afield"><label for="a_period_end">有效期结束</label><input id="a_period_end" placeholder="YYYY-MM-DD 或 长期"></div>
157
187
  <div class="afield"><label for="a_contact_name">超级管理员姓名</label><input id="a_contact_name" placeholder="与微信实名一致"></div>
@@ -190,8 +220,8 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
190
220
  </div>
191
221
 
192
222
  <div class="acard">
193
- <div class="acard-h"><span class="anum">4</span>门店信息</div>
194
- <div class="agrid">
223
+ <div class="acard-h"><span class="anum">4</span><span id="acard4-title">门店信息</span></div>
224
+ <div class="agrid" id="scene_fields_store">
195
225
  <div class="afield"><label for="a_store_name">门店名称</label><input id="a_store_name" placeholder="线下场所名称"></div>
196
226
  <div class="afield"><label for="a_store_address_code">门店省市编码</label>
197
227
  <select id="a_store_address_code" data-citycodes>
@@ -201,6 +231,20 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
201
231
  <input id="a_storecode_manual" class="amanual" placeholder="6 位国标区划码, 如: 320200" style="display:none"></div>
202
232
  <div class="afield afield-full"><label for="a_store_address">门店详细地址</label><input id="a_store_address" placeholder="xx 路 xx 号"></div>
203
233
  </div>
234
+ <div id="scene_fields_web" style="display:none">
235
+ <div class="agrid">
236
+ <div class="afield afield-full"><label for="a_website_url">网站网址</label><input id="a_website_url" placeholder="https:// 开头, 需已上线可访问"></div>
237
+ </div>
238
+ <div class="ahint">⚠️ 网站需已上线且有 ICP 备案, 微信审核会实际访问验证</div>
239
+ </div>
240
+ <div id="scene_fields_mp" style="display:none">
241
+ <div class="agrid">
242
+ <div class="afield"><label for="a_appid">公众号/小程序 AppID</label><input id="a_appid" placeholder="wx 开头"></div>
243
+ <div class="afield"><label for="a_appid_type">类型</label>
244
+ <select id="a_appid_type"><option value="mp">公众号</option><option value="mini">小程序</option></select></div>
245
+ </div>
246
+ <div class="ahint">⚠️ 需已认证的公众号或小程序, 且主体与本商户一致</div>
247
+ </div>
204
248
  </div>
205
249
 
206
250
  <div class="acard">
@@ -209,11 +253,12 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
209
253
  <div id="a_up_license"></div>
210
254
  <div id="a_up_idfront"></div>
211
255
  <div id="a_up_idback"></div>
212
- <div id="a_up_entrance"></div>
213
- <div id="a_up_indoor"></div>
256
+ <div id="a_up_s1"></div>
257
+ <div id="a_up_s2"></div>
214
258
  </div>
215
259
  </div>
216
260
 
261
+ <div id="apply-err" style="display:none"></div>
217
262
  <button class="abtn" id="btn-apply-submit" onclick="submitApplyment()">加密并提交进件</button>
218
263
  <div class="aprivacy">🔒 资料只保存在这台电脑; 敏感字段本机加密后交服务商; 服务商暂时不可达时加密暂存本机, 可重试。</div>
219
264
  </div>
@@ -408,11 +453,36 @@ function mountApplyUpload(slotId,field,label){
408
453
  mountApplyUpload('a_up_license','license_media_id','营业执照照片 (必传)');
409
454
  mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (必传)');
410
455
  mountApplyUpload('a_up_idback','legal_id_back_media_id','身份证国徽面 (必传)');
411
- mountApplyUpload('a_up_entrance','store_entrance_media_id','门店门头照片 (必传)');
412
- mountApplyUpload('a_up_indoor','store_indoor_media_id','门店内部照片 (必传)');
456
+ // 经营场景 (2026-10-03 xiaomu 拍板三选一): 场景决定第 4 卡片字段 + 第 5 卡片场景照片槽
457
+ var SCENE_UPLOADS={
458
+ SALES_SCENES_STORE:[['a_up_s1','store_entrance_media_id','门店门头照片 (必传)'],['a_up_s2','store_indoor_media_id','门店内部照片 (必传)']],
459
+ SALES_SCENES_WEB:[['a_up_s1','web_home_media_id','网站首页截图 (必传)'],['a_up_s2','web_product_media_id','商品/服务页截图 (必传)']],
460
+ SALES_SCENES_MP:[['a_up_s1','mp_media_id','公众号/小程序页面截图 (必传)']]
461
+ };
462
+ var SCENE_TITLES={SALES_SCENES_STORE:'门店信息',SALES_SCENES_WEB:'网站信息',SALES_SCENES_MP:'公众号 / 小程序'};
463
+ function currentScene(){
464
+ var r=document.querySelector('input[name="sales_scene"]:checked');
465
+ return r?r.value:'SALES_SCENES_STORE';
466
+ }
467
+ function setScene(scene){
468
+ document.getElementById('scene_fields_store').style.display=scene==='SALES_SCENES_STORE'?'':'none';
469
+ document.getElementById('scene_fields_web').style.display=scene==='SALES_SCENES_WEB'?'':'none';
470
+ document.getElementById('scene_fields_mp').style.display=scene==='SALES_SCENES_MP'?'':'none';
471
+ document.getElementById('acard4-title').textContent=SCENE_TITLES[scene]||'门店信息';
472
+ document.querySelectorAll('.ascene').forEach(function(c){
473
+ c.classList.toggle('selected', c.querySelector('input').value===scene);
474
+ });
475
+ // 场景照片槽重挂 + 清掉旧场景 media_id (避免跨场景串数据)
476
+ ['store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'].forEach(function(k){delete _aMedia[k];});
477
+ var slots=SCENE_UPLOADS[scene]||SCENE_UPLOADS.SALES_SCENES_STORE;
478
+ slots.forEach(function(s){var el=document.getElementById(s[0]);el.style.display='';mountApplyUpload(s[0],s[1],s[2]);});
479
+ if(!slots.some(function(s){return s[0]==='a_up_s2';})) document.getElementById('a_up_s2').style.display='none';
480
+ }
481
+ setScene('SALES_SCENES_STORE'); // 默认线下门店
413
482
  function aval(id){var e=document.getElementById(id);return e?e.value.trim():''}
414
483
  function applyFormBody(){
415
- return {
484
+ var scene=currentScene();
485
+ var body={
416
486
  subject_type:aval('a_subject_type'),merchant_shortname:aval('a_shortname'),merchant_name:aval('a_merchant_name'),
417
487
  license_number:aval('a_license_number'),service_phone:aval('a_service_phone'),
418
488
  settlement_id:aval('a_settlement_id'),qualification_type:aval('a_qualification_type'),
@@ -421,12 +491,49 @@ function applyFormBody(){
421
491
  legal_id_period_begin:aval('a_period_begin'),legal_id_period_end:aval('a_period_end'),
422
492
  account_type:aval('a_account_type'),account_name:aval('a_account_name'),account_number:aval('a_account_number'),
423
493
  account_bank:aval('a_account_bank'),bank_name:aval('a_bank_name'),bank_address_code:aval('a_bank_address_code'),
424
- store_name:aval('a_store_name'),store_address_code:aval('a_store_address_code'),store_address:aval('a_store_address'),
494
+ legal_id_address:aval('a_legal_id_address'), // 企业主体必填 (F4), 个体户留空
495
+ sales_scene:scene,
425
496
  license_media_id:_aMedia.license_media_id||'',legal_id_front_media_id:_aMedia.legal_id_front_media_id||'',
426
- legal_id_back_media_id:_aMedia.legal_id_back_media_id||'',store_entrance_media_id:_aMedia.store_entrance_media_id||'',
427
- store_indoor_media_id:_aMedia.store_indoor_media_id||''
497
+ legal_id_back_media_id:_aMedia.legal_id_back_media_id||''
428
498
  };
499
+ if(scene==='SALES_SCENES_STORE'){
500
+ body.store_name=aval('a_store_name');body.store_address_code=aval('a_store_address_code');body.store_address=aval('a_store_address');
501
+ body.store_entrance_media_id=_aMedia.store_entrance_media_id||'';body.store_indoor_media_id=_aMedia.store_indoor_media_id||'';
502
+ }else if(scene==='SALES_SCENES_WEB'){
503
+ body.website_url=aval('a_website_url');
504
+ body.web_home_media_id=_aMedia.web_home_media_id||'';body.web_product_media_id=_aMedia.web_product_media_id||'';
505
+ }else{
506
+ body.appid=aval('a_appid');body.appid_type=aval('a_appid_type');
507
+ body.mp_media_id=_aMedia.mp_media_id||'';
508
+ }
509
+ return body;
429
510
  }
511
+ // 错误提示 (2026-10-03 硬性要求): 红卡 + 平滑滚动 + 字段级红框定位
512
+ var FIELD_EL={ // 字段名 → 页面元素 id (媒体字段 → 上传槽; 文本字段默认 a_+字段)
513
+ license_media_id:'a_up_license',legal_id_front_media_id:'a_up_idfront',legal_id_back_media_id:'a_up_idback',
514
+ store_entrance_media_id:'a_up_s1',store_indoor_media_id:'a_up_s2',
515
+ web_home_media_id:'a_up_s1',web_product_media_id:'a_up_s2',mp_media_id:'a_up_s1',
516
+ website_url:'a_website_url',appid:'a_appid'
517
+ };
518
+ function showApplyErr(msg,field){
519
+ var card=document.getElementById('apply-err');
520
+ card.textContent='⚠️ '+msg;
521
+ card.style.display='block';
522
+ // 清旧高亮
523
+ document.querySelectorAll('#apply-form .aerr-field').forEach(function(e){e.classList.remove('aerr-field');});
524
+ var t=null;
525
+ if(field){
526
+ // 手填接管 id 的场景 (其他银行/手动编码): getElementById 拿到的就是当前持有 id 的可见控件
527
+ t=document.getElementById(FIELD_EL[field]||('a_'+field));
528
+ }
529
+ if(t){
530
+ t.classList.add('aerr-field');
531
+ t.scrollIntoView({behavior:'smooth',block:'center'}); // 滚动到字段, 提示卡跟随在提交按钮上方
532
+ }else{
533
+ card.scrollIntoView({behavior:'smooth',block:'center'});
534
+ }
535
+ }
536
+ function hideApplyErr(){document.getElementById('apply-err').style.display='none';}
430
537
  // 状态机渲染: 未提交(表单) → 已提交(business_code+轮询) → TO_BE_SIGNED(签约链接) → FINISHED(sub_mchid+引导金)
431
538
  function renderApplyment(j){
432
539
  var box=document.getElementById('apply-status');
@@ -451,20 +558,33 @@ function renderApplyment(j){
451
558
  async function submitApplyment(){
452
559
  var btn=document.getElementById('btn-apply-submit');
453
560
  var msg=document.getElementById('apply-msg');
454
- btn.disabled=true;btn.textContent='加密并提交中...';
561
+ hideApplyErr();
562
+ btn.disabled=true;btn.textContent='加密并提交中…';
563
+ // 前端 40s 兜底超时 (后端 SP 转发自身有 15s 超时; AbortController 防页面卡死)
564
+ var ctrl=new AbortController();
565
+ var timer=setTimeout(function(){ctrl.abort();},40000);
455
566
  try{
456
- var j=await readJson(await fetch('/api/onboard/applyment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(applyFormBody())}));
567
+ var j=await readJson(await fetch('/api/onboard/applyment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(applyFormBody()),signal:ctrl.signal}));
568
+ clearTimeout(timer);
457
569
  if(j.ok){
458
570
  msg.style.display='block';
459
571
  msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'');
460
572
  document.getElementById('apply-form').style.display='none';
461
573
  queryApplyment();
462
574
  } else {
463
- msg.style.display='block';
464
- msg.textContent='提交失败: '+(j.message||j.error||'未知错误');
575
+ // 后端/SP/微信错误 → 醒目红卡 + 字段定位 (message 已是中文, 含字段名)
576
+ showApplyErr(j.message||j.error||'未知错误', j.field);
465
577
  loadApplymentStatus(); // PENDING_PUSH 时状态框给出重试按钮
466
578
  }
467
- }catch(e){msg.style.display='block';msg.textContent='提交失败: 服务暂时不可用,请刷新重试 ('+e.message+')';}
579
+ }catch(e){
580
+ clearTimeout(timer);
581
+ if(e && e.name==='AbortError'){
582
+ showApplyErr('服务商响应超时,资料已加密暂存本机,可点重试');
583
+ loadApplymentStatus();
584
+ }else{
585
+ showApplyErr('提交失败: 服务暂时不可用,请刷新重试 ('+e.message+')');
586
+ }
587
+ }
468
588
  btn.disabled=false;btn.textContent='加密并提交进件';
469
589
  }
470
590
  async function retryApplyment(){
@@ -473,7 +593,53 @@ async function retryApplyment(){
473
593
  else{var msg=document.getElementById('apply-msg');msg.style.display='block';msg.textContent=j.message||'重试失败, 请稍后再试';}
474
594
  }
475
595
  async function queryApplyment(){
476
- 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?' (照片已传过, 无需重传)':'');
477
643
  }
478
644
  async function loadApplymentStatus(){queryApplyment();}
479
645
  function markStepDone(id){
@@ -544,6 +710,10 @@ async function submit(){
544
710
  if(subj&&stl){
545
711
  subj.addEventListener('change',function(){
546
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';
547
717
  });
548
718
  }
549
719
  // 省市编码选项 (国标区划码, 两处下拉共用; 微信官方对照表口径)