trustbase-seller-backend 0.5.0 → 0.5.6
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/README.md +1 -1
- package/docs/DEPLOYMENT.md +42 -0
- package/package.json +1 -1
- package/routes/agent-pay.js +4 -1
- package/routes/agent-serviceprovider.js +1 -1
- package/routes/buyer-shop.js +380 -0
- package/routes/onboard.html +1 -1
- package/routes/sp-applyment.js +28 -1
- package/routes/sp-notify.js +62 -2
- package/routes/sp-tunnel.js +354 -0
- package/run-unified.js +25 -1
- package/test-buyer-shop-ps.js +235 -0
- package/test-buyer-shop.js +333 -0
- package/test-sp-applyment.js +14 -21
- package/test-sp-tunnel.js +311 -0
- package/tools/mock-tunnel-merchant.js +25 -0
- package/tools/tunnel-e2e-check.sh +20 -0
package/README.md
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SP 网关 / seller-backend 部署必读
|
|
2
|
+
|
|
3
|
+
## ⚠️ 必开配置 (缺一不可)
|
|
4
|
+
|
|
5
|
+
### 1. 链节点 tx_index (必开!)
|
|
6
|
+
|
|
7
|
+
```toml
|
|
8
|
+
# config.toml
|
|
9
|
+
[tx_index]
|
|
10
|
+
indexer = "kv" # 默认 null = 不索引交易
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**为什么**: 网关"等入块"逻辑需要通过 tx hash 查交易确认。如果 indexer=null:
|
|
14
|
+
- 交易明明入块了, 但查询返回"未找到"
|
|
15
|
+
- 网关误报"未在 15s 内入块" → chain_txhash 留空 → 触发 retryPending 重复补链
|
|
16
|
+
- 虽然链上硬约束 (code 4 duplicate) 防止重复事实, 但会浪费 gas + 日志噪音
|
|
17
|
+
|
|
18
|
+
**实测**: 2026-10-02 node-c 开 indexer=kv 后 code 0 正常确认。
|
|
19
|
+
|
|
20
|
+
### 2. SP 签名 key 环境变量 (必填!)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# .env
|
|
24
|
+
CHAIN_HOME=/opt/trustbase-sp/chain-keys # provider keyring 目录
|
|
25
|
+
SP_CHAIN_KEY_NAME=tb-sp-provider # provider 专用签名 key 名
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**为什么**: 默认值 `emergency-key` + `/root/.trustchain` 在 SP 网关机器上不存在且非已注册 provider。
|
|
29
|
+
第一笔真实支付 100% 写不上链 (signer 非 ACTIVE provider 被链上拒)。
|
|
30
|
+
|
|
31
|
+
**自检**: 启动时自动检查 key 存在性 + provider ACTIVE 状态, 输出 `[STARTUP-CHECK]` 日志。
|
|
32
|
+
|
|
33
|
+
## 部署检查清单
|
|
34
|
+
|
|
35
|
+
- [ ] config.toml [tx_index] indexer = "kv"
|
|
36
|
+
- [ ] .env CHAIN_HOME = <provider keyring 目录>
|
|
37
|
+
- [ ] .env SP_CHAIN_KEY_NAME = <provider key 名>
|
|
38
|
+
- [ ] 启动后检查 [STARTUP-CHECK] 输出全 ✓
|
|
39
|
+
- [ ] SP_CHAIN_DISABLED 未设置或 =0
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
*pi 2026-10-02 · per kimi 18462 派单*
|
package/package.json
CHANGED
package/routes/agent-pay.js
CHANGED
|
@@ -131,7 +131,9 @@ async function wxApiCall(method, urlPath, bodyObj, override) {
|
|
|
131
131
|
return { status: r.status, json: j };
|
|
132
132
|
}
|
|
133
133
|
// Native 下单: 返回 code_url (SP 模式走 /v3/pay/partner/* endpoint — 2026-09-25 修)
|
|
134
|
-
|
|
134
|
+
// opts.settleInfo (2026-10-06 U7): 可选, 如 { profit_sharing: true } — 微信规则: 订单下单时
|
|
135
|
+
// 不带 settle_info.profit_sharing 的分账请求会被拒; 默认不带 (向后兼容, 直连模式不用传)
|
|
136
|
+
async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opts) {
|
|
135
137
|
// SP 模式: 用服务商身份签名 + 请求体带 sp_mchid + sub_mchid
|
|
136
138
|
const isSp = !!(WX.spMchid && process.env.WXPAY_SUB_MCHID);
|
|
137
139
|
const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
|
|
@@ -143,6 +145,7 @@ async function wxNativeOrder(outTradeNo, description, totalCents, expireSec) {
|
|
|
143
145
|
notify_url: WX.notifyUrl,
|
|
144
146
|
amount: { total: totalCents, currency: "CNY" },
|
|
145
147
|
};
|
|
148
|
+
if (opts && opts.settleInfo) body.settle_info = opts.settleInfo; // U7: 分账标记 (仅 rate>0 且 SP 模式时调用方传入)
|
|
146
149
|
const endpoint = isSp ? "/v3/pay/partner/transactions/native" : "/v3/pay/transactions/native";
|
|
147
150
|
const r = await wxApiCall("POST", endpoint, body, override);
|
|
148
151
|
if (r.status !== 200 || !r.json.code_url) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* agent-serviceprovider.js — AI Agent 服务商路由 (C5)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 契约方(历史): ShopXO trustbasechain 插件 ServiceProviderService.php (pi 2026-09-20; ShopXO 已于 2026-09-22 下架, 该插件契约已废弃, 本路由保留给通用服务商对接),
|
|
6
6
|
* 字段以插件源码为准, 不可改:
|
|
7
7
|
* POST /api/agent/serviceprovider/register {provider, wechat_mchid, stake, name}
|
|
8
8
|
* POST /api/agent/serviceprovider/submit-fact {provider, order_ref, psp_txn_id, amount, evidence_hash}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* buyer-shop.js — 买家公开店铺面 API (2026-10-06, v0.5.2; U7 分账标记 v0.5.6)
|
|
4
|
+
*
|
|
5
|
+
* 背景: Pi 交付示例店铺前端 @trustbaseai/shop-demo (pwa-shop-demo, 纯静态 PWA),
|
|
6
|
+
* 它假设了一套买家面 API 契约, 本模块是卖家后端的适配层。契约字段名与 Pi 前端
|
|
7
|
+
* 逐字对齐 (packages/pwa-shop-demo/public/app.js):
|
|
8
|
+
*
|
|
9
|
+
* GET /api/products → [{id, name, description, price, image_url, stock}] (price 单位: 元, 数字)
|
|
10
|
+
* GET /api/products/:id → 单个同上 (404 {error, message})
|
|
11
|
+
* POST /api/orders body {product_id, quantity, buyer_card, client_order_no?}
|
|
12
|
+
* → {id, amount, code_url} (amount 单位: 元)
|
|
13
|
+
* GET /api/orders/:id → {id, status, amount, product_name, quantity, tx_hash, code_url}
|
|
14
|
+
* status ∈ pending/paid/completed/failed
|
|
15
|
+
*
|
|
16
|
+
* 数据源:
|
|
17
|
+
* - 商品: store_products 表 (索引器 DB; 列以 PRAGMA 实读为准)。在架 = status='active'。
|
|
18
|
+
* 价格列现状只有 price_utct (TCT 微单位) — 折元走链上官方汇率 economy_params.utct_per_cny
|
|
19
|
+
* (缺表/缺行/为 0 → env BUYER_SHOP_UTCT_PER_CNY → 默认 1_000_000 即 1 TCT = 1 元)。
|
|
20
|
+
* 若未来出现 price_cny/price_fen 列 (分) 则优先直读。整数分运算, 无浮点误差。
|
|
21
|
+
* - 订单: 复用 agent_pay_sessions 表 (不新增表) — 既有微信回调 /api/agent/pay/notify/wechat
|
|
22
|
+
* 和 E3 对账轮询按 order_ref 自动翻转状态, 本模块零回调代码。
|
|
23
|
+
*
|
|
24
|
+
* U7 (2026-10-06, xiaomu 9/29 拍板官方 1% 抽佣): rate>0 且 SP 模式 → 下单时微信 body 带
|
|
25
|
+
* settle_info: { profit_sharing: true } (微信规则: 不带标记的订单分账请求被拒)。
|
|
26
|
+
* 费率优先级: env SP_COMMISSION_BP (显式覆盖) → 网关公开接口
|
|
27
|
+
* GET {SP_GATEWAY_URL}/api/sp/commission-rate/{sub_mchid} (60s 内存缓存, 失败缓存 0 防抖动)
|
|
28
|
+
* → 本地 db sp_commission_rates (同库时直接命中)。sub_mchid = env WXPAY_SUB_MCHID
|
|
29
|
+
* (无值 = 直连模式, 永不标记)。台账 ps_flag 列记录该单是否带分账标记 (运营台区分用)。
|
|
30
|
+
* 回调侧不动: sp-notify 的分账触发已按 rate>0 判断。
|
|
31
|
+
*
|
|
32
|
+
* 下单要点:
|
|
33
|
+
* - 价格权威 = 本地 DB (单价 × qty 算总分), 客户端传的任何金额字段一律忽略
|
|
34
|
+
* - qty 整数 >=1 且不超库存; 单价折算后 <= 0 分 → 409 (微信不支持 0 元单)
|
|
35
|
+
* - 幂等两级: ① body.client_order_no → client_session_id='buyer:{card}:{no}' 同号返原单
|
|
36
|
+
* ② 无 client_order_no → 同 buyer_card+product_id 5 分钟窗口内未支付未过期单直接复用
|
|
37
|
+
* - client_session_id = 'buyer:'+buyer_card+':'+订单号 (订单号即对外 id = order_ref)
|
|
38
|
+
* - 微信 Native 下单复用 agent-pay 的 wxNativeOrder (SP 模式由 env WXPAY_SUB_MCHID 自动切换);
|
|
39
|
+
* AGENT_PAY_MODE=mock 时生成伪 code_url (测试/沙箱), 不碰真微信
|
|
40
|
+
*
|
|
41
|
+
* 公开面安全: 无鉴权 (买家无账号), 但 POST /api/orders IP 限流 10 单/分钟 (内存桶,
|
|
42
|
+
* 照 agent-pay rateLimit 模式); 全部 SQL 参数绑定; 错误一律 {error, message}。
|
|
43
|
+
* 术语红线: 买家可见 message 零链上词汇 (链上/助记词/gas/钱包/区块/TrustChain 不许出现);
|
|
44
|
+
* tx_hash 字段名保留 (契约要求), 值可为空字符串。
|
|
45
|
+
*
|
|
46
|
+
* 共存 (run-unified.js): POST /api/orders 与 chain-writes 的 WooCommerce 钩子同路径同方法 —
|
|
47
|
+
* 本路由按 body 形状接管 (有 buyer_card+product_id 才是买家单), 否则 next() 落回
|
|
48
|
+
* sensitiveOpGuard + chain-writes 老路径, 互不干扰。
|
|
49
|
+
*/
|
|
50
|
+
const express = require("express");
|
|
51
|
+
const crypto = require("crypto");
|
|
52
|
+
const agentPay = require("./agent-pay");
|
|
53
|
+
|
|
54
|
+
function nowSec() { return Math.floor(Date.now() / 1000); }
|
|
55
|
+
|
|
56
|
+
/** 限速: 每IP每分钟 N 次 (内存桶, 照 agent-pay.js rateLimit 模式; 桶随路由实例 —
|
|
57
|
+
* 单进程单实例语义不变, 测试多实例互不污染) */
|
|
58
|
+
function makeRateLimiter() {
|
|
59
|
+
const bucket = new Map();
|
|
60
|
+
return function rateLimit(key, limit, windowMs) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const b = bucket.get(key) || { n: 0, reset: now + windowMs };
|
|
63
|
+
if (now > b.reset) { b.n = 0; b.reset = now + windowMs; }
|
|
64
|
+
b.n++;
|
|
65
|
+
bucket.set(key, b);
|
|
66
|
+
return b.n <= limit;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** agent_pay_sessions 建表 (DDL 与 agent-pay.js ensureTables 一致; IF NOT EXISTS 幂等,
|
|
71
|
+
* 路由工厂直接建 — 0.4.3 教训: 写路径不依赖别的模块先建表) */
|
|
72
|
+
function ensureBuyerTables(db) {
|
|
73
|
+
db.exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS agent_pay_sessions (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
client_session_id TEXT UNIQUE NOT NULL,
|
|
77
|
+
sku_id TEXT NOT NULL,
|
|
78
|
+
qty INTEGER NOT NULL,
|
|
79
|
+
amount_total INTEGER NOT NULL, -- 分 (服务端权威价×qty)
|
|
80
|
+
currency TEXT NOT NULL DEFAULT 'CNY',
|
|
81
|
+
agent_addr TEXT DEFAULT '',
|
|
82
|
+
order_ref TEXT UNIQUE NOT NULL, -- tb-{nonce} = 买家面订单 id
|
|
83
|
+
pay_token_hash TEXT NOT NULL,
|
|
84
|
+
status_token_hash TEXT NOT NULL,
|
|
85
|
+
state TEXT NOT NULL DEFAULT 'CREATED',
|
|
86
|
+
psp_code_url TEXT DEFAULT '',
|
|
87
|
+
psp_txn_id TEXT DEFAULT '',
|
|
88
|
+
shopxo_order_no TEXT DEFAULT '',
|
|
89
|
+
chain_order_id TEXT DEFAULT '',
|
|
90
|
+
callback_url TEXT DEFAULT '',
|
|
91
|
+
expires_at INTEGER NOT NULL,
|
|
92
|
+
created_at INTEGER NOT NULL,
|
|
93
|
+
updated_at INTEGER NOT NULL
|
|
94
|
+
);
|
|
95
|
+
`);
|
|
96
|
+
// 老库兼容: agent-pay 的物流列 + U7 分账标记列 (已存在则跳过)
|
|
97
|
+
for (const col of ["carrier TEXT DEFAULT ''", "tracking_no TEXT DEFAULT ''", "logistics_state TEXT DEFAULT ''", "timeline TEXT DEFAULT '[]'", "shopxo_order_no_ref TEXT DEFAULT ''",
|
|
98
|
+
"ps_flag INTEGER NOT NULL DEFAULT 0"]) { // U7: 1=下单带 settle_info.profit_sharing (rate>0 且 SP 模式)
|
|
99
|
+
try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasTable(db, name) {
|
|
104
|
+
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** utct → 元 汇率 (1 元 = rate utct): economy_params 表 → env → 默认 1 TCT = 1 元 */
|
|
108
|
+
function readUtctPerCny(db) {
|
|
109
|
+
try {
|
|
110
|
+
if (hasTable(db, "economy_params")) {
|
|
111
|
+
const row = db.prepare("SELECT utct_per_cny FROM economy_params WHERE id = 1").get();
|
|
112
|
+
if (row && Number(row.utct_per_cny) > 0) return Number(row.utct_per_cny);
|
|
113
|
+
}
|
|
114
|
+
} catch (_) { /* 表结构异常走默认 */ }
|
|
115
|
+
const env = Number(process.env.BUYER_SHOP_UTCT_PER_CNY || 0);
|
|
116
|
+
if (Number.isInteger(env) && env > 0) return env;
|
|
117
|
+
return 1000000;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** session.state → 买家面 status (契约枚举 pending/paid/completed/failed) */
|
|
121
|
+
function mapStatus(state) {
|
|
122
|
+
if (state === "PAID" || state === "ON_CHAIN" || state === "SHIPPED") return "paid";
|
|
123
|
+
if (state === "DELIVERED" || state === "CONFIRMED") return "completed";
|
|
124
|
+
if (state === "EXPIRED" || state === "CLOSED" || state === "REFUNDED" || state === "DISPUTED") return "failed";
|
|
125
|
+
return "pending"; // CREATED 等
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 分 → 元 (数字, 两位小数; 整数运算无浮点误差) */
|
|
129
|
+
function fenToYuan(fen) { return Math.floor(fen) / 100; }
|
|
130
|
+
|
|
131
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
132
|
+
const SESSION_TTL_SEC = 15 * 60; // 支付窗口 15min (与微信 time_expire 对齐, 同 agent-pay)
|
|
133
|
+
const IDEMPOTENCY_WINDOW_SEC = 5 * 60; // 无 client_order_no 时的复用窗口
|
|
134
|
+
const RATE_CACHE_MS = 60 * 1000; // U7: 费率网关查询 60s 内存缓存
|
|
135
|
+
|
|
136
|
+
/** U7 抽佣费率解析器 (每路由实例一个, 缓存随实例): 返回 rate_bp (整数 >=0)
|
|
137
|
+
* 优先级: env SP_COMMISSION_BP 显式覆盖 → 网关公开接口 (缓存 60s, 失败/异常缓存 0 防抖动)
|
|
138
|
+
* → 本地 sp_commission_rates 表 (与网关同库时直接命中)。直连模式 (无 WXPAY_SUB_MCHID) 恒 0。 */
|
|
139
|
+
function makeCommissionRateResolver(db, fetcher) {
|
|
140
|
+
const cache = new Map(); // sub_mchid → {bp, expiresAt}
|
|
141
|
+
const fetchFn = fetcher || globalThis.fetch;
|
|
142
|
+
return async function resolveRateBp() {
|
|
143
|
+
// ① env 显式覆盖 (最高优先, 不调网关不读表)
|
|
144
|
+
if (process.env.SP_COMMISSION_BP !== undefined && process.env.SP_COMMISSION_BP !== "") {
|
|
145
|
+
const v = Number(process.env.SP_COMMISSION_BP);
|
|
146
|
+
return Number.isInteger(v) && v >= 0 ? v : 0;
|
|
147
|
+
}
|
|
148
|
+
const subMchid = String(process.env.WXPAY_SUB_MCHID || "");
|
|
149
|
+
if (!subMchid) return 0; // 直连模式不标记
|
|
150
|
+
const hit = cache.get(subMchid);
|
|
151
|
+
if (hit && Date.now() < hit.expiresAt) return hit.bp;
|
|
152
|
+
const remember = (bp) => { cache.set(subMchid, { bp, expiresAt: Date.now() + RATE_CACHE_MS }); return bp; };
|
|
153
|
+
// ② 网关公开接口 (SP 网关是费率权威; 公开只读端点无需 key)
|
|
154
|
+
const gw = String(process.env.SP_GATEWAY_URL || "").replace(/\/+$/, "");
|
|
155
|
+
if (gw && fetchFn) {
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetchFn(gw + "/api/sp/commission-rate/" + encodeURIComponent(subMchid), { signal: AbortSignal.timeout(5000) });
|
|
158
|
+
if (r && r.ok) {
|
|
159
|
+
const j = await r.json();
|
|
160
|
+
const bp = Number(j && j.rate_bp);
|
|
161
|
+
return remember(Number.isInteger(bp) && bp >= 0 ? bp : 0);
|
|
162
|
+
}
|
|
163
|
+
if (r && r.status === 404) return remember(0); // 未登记 = 零抽成 (官方默认)
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.warn("[buyer-shop] 费率网关查询失败 (回落本地表):", e && e.message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ③ 本地表兜底 (卖家后端与 SP 网关同库部署时直接命中)
|
|
169
|
+
try {
|
|
170
|
+
if (hasTable(db, "sp_commission_rates")) {
|
|
171
|
+
const row = db.prepare("SELECT rate_bp FROM sp_commission_rates WHERE sub_mchid = ?").get(subMchid);
|
|
172
|
+
if (row && Number.isInteger(row.rate_bp) && row.rate_bp >= 0) return remember(row.rate_bp);
|
|
173
|
+
}
|
|
174
|
+
} catch (_) { /* 表异常按 0 */ }
|
|
175
|
+
return remember(0); // 全源失败/未登记 → 缓存 0 防抖动, 不阻塞下单
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function createBuyerShopRouter(deps) {
|
|
180
|
+
const router = express.Router();
|
|
181
|
+
const { db } = deps;
|
|
182
|
+
ensureBuyerTables(db);
|
|
183
|
+
const rateLimit = makeRateLimiter();
|
|
184
|
+
const resolveRateBp = makeCommissionRateResolver(db, deps.fetcher);
|
|
185
|
+
|
|
186
|
+
const MODE = process.env.AGENT_PAY_MODE || "mock"; // mock | wechat (同 agent-pay 口径)
|
|
187
|
+
// 测试可注入假 wxNativeOrder; 生产默认 agent-pay 真实现 (SP 模式其内部自动切换)
|
|
188
|
+
const wxNativeOrder = deps.wxNativeOrder || agentPay.wxNativeOrder;
|
|
189
|
+
|
|
190
|
+
// store_products 列实读 (以实际 schema 为准做字段映射; 表不存在 → 商品面全空)
|
|
191
|
+
const productCols = hasTable(db, "store_products")
|
|
192
|
+
? db.prepare("PRAGMA table_info(store_products)").all().map((c) => c.name)
|
|
193
|
+
: [];
|
|
194
|
+
|
|
195
|
+
/** 单价 (整数分, 权威口径): price_cny/price_fen 列 (分) 直读优先;
|
|
196
|
+
* 现状只有 price_utct (TCT 微单位) → 官方汇率折分 (整数运算, 不过浮点) */
|
|
197
|
+
function unitFenOf(row) {
|
|
198
|
+
if (productCols.includes("price_cny")) return Number(row.price_cny) || 0;
|
|
199
|
+
if (productCols.includes("price_fen")) return Number(row.price_fen) || 0;
|
|
200
|
+
const utct = Number(row.price_utct) || 0;
|
|
201
|
+
return Math.round((utct * 100) / readUtctPerCny(db));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 单行 → 契约结构 {id, name, description, price(元), image_url, stock} */
|
|
205
|
+
function productToJson(row) {
|
|
206
|
+
return {
|
|
207
|
+
id: String(row.id),
|
|
208
|
+
name: String(row.title || row.id),
|
|
209
|
+
description: String(row.description || ""),
|
|
210
|
+
price: fenToYuan(unitFenOf(row)),
|
|
211
|
+
image_url: productCols.includes("image_url") ? String(row.image_url || "") : "",
|
|
212
|
+
stock: Number(row.stock) || 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getActiveProduct(id) {
|
|
217
|
+
if (!productCols.length) return null;
|
|
218
|
+
const row = db.prepare("SELECT * FROM store_products WHERE id = ?").get(String(id));
|
|
219
|
+
if (!row || row.status !== "active") return null; // 只在架商品可见 (不泄露下架品存在性)
|
|
220
|
+
return row;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** session 行 → 契约下单/查单响应 */
|
|
224
|
+
function orderToJson(row) {
|
|
225
|
+
let productName = row.sku_id;
|
|
226
|
+
if (productCols.length) {
|
|
227
|
+
const p = db.prepare("SELECT title FROM store_products WHERE id = ?").get(row.sku_id);
|
|
228
|
+
if (p && p.title) productName = p.title;
|
|
229
|
+
}
|
|
230
|
+
let txHash = "";
|
|
231
|
+
if (row.chain_order_id && hasTable(db, "chain_write_log")) {
|
|
232
|
+
const c = db.prepare("SELECT txhash FROM chain_write_log WHERE order_id = ?").get(row.chain_order_id);
|
|
233
|
+
if (c && c.txhash) txHash = String(c.txhash);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
id: row.order_ref,
|
|
237
|
+
product_id: row.sku_id, // Pi 复核 P2: 前端详情页跳转要 product_id (契约补齐)
|
|
238
|
+
status: mapStatus(row.state),
|
|
239
|
+
amount: fenToYuan(row.amount_total),
|
|
240
|
+
product_name: productName,
|
|
241
|
+
quantity: row.qty,
|
|
242
|
+
tx_hash: txHash,
|
|
243
|
+
code_url: row.psp_code_url || "",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** GET /products — 商品列表 (公开只读, 只在架) */
|
|
248
|
+
router.get("/products", (req, res) => {
|
|
249
|
+
try {
|
|
250
|
+
if (!productCols.length) return res.json([]);
|
|
251
|
+
const rows = db.prepare("SELECT * FROM store_products WHERE status = 'active' ORDER BY created_at DESC LIMIT 200").all();
|
|
252
|
+
res.json(rows.map(productToJson));
|
|
253
|
+
} catch (e) {
|
|
254
|
+
console.error("[buyer-shop] GET /products error:", e && e.message);
|
|
255
|
+
res.status(500).json({ error: "INTERNAL", message: "服务暂时不可用,请稍后再试" });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/** GET /products/:id — 商品详情 (公开只读, 下架/不存在一律 404) */
|
|
260
|
+
router.get("/products/:id", (req, res) => {
|
|
261
|
+
try {
|
|
262
|
+
const row = getActiveProduct(req.params.id);
|
|
263
|
+
if (!row) return res.status(404).json({ error: "NOT_FOUND", message: "商品不存在或已下架" });
|
|
264
|
+
res.json(productToJson(row));
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error("[buyer-shop] GET /products/:id error:", e && e.message);
|
|
267
|
+
res.status(500).json({ error: "INTERNAL", message: "服务暂时不可用,请稍后再试" });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** POST /orders — 买家下单 (公开; IP 限流 10 单/分钟; 价格以 DB 为权威)
|
|
272
|
+
* 共存: body 不是买家单形状 (无 buyer_card+product_id) → next() 落回 chain-writes 老路径 */
|
|
273
|
+
router.post("/orders", async (req, res, next) => {
|
|
274
|
+
try {
|
|
275
|
+
const b = req.body || {};
|
|
276
|
+
const isBuyerOrder = typeof b.buyer_card === "string" && b.buyer_card && b.product_id !== undefined;
|
|
277
|
+
if (!isBuyerOrder) return next(); // WooCommerce 钩子等其他调用方, 不归本模块
|
|
278
|
+
const err = (code, httpCode, message) => res.status(httpCode).json({ error: code, message });
|
|
279
|
+
|
|
280
|
+
const ip = req.ip || (req.connection && req.connection.remoteAddress) || "unknown";
|
|
281
|
+
if (!rateLimit("buyer-order:" + ip, 10, 60000)) {
|
|
282
|
+
return err("RATE_LIMITED", 429, "下单太频繁,请稍后再试");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 输入校验
|
|
286
|
+
const productId = String(b.product_id || "");
|
|
287
|
+
const buyerCard = String(b.buyer_card || "");
|
|
288
|
+
if (!ID_RE.test(productId)) return err("INVALID_PARAM", 400, "product_id 无效");
|
|
289
|
+
if (!ID_RE.test(buyerCard)) return err("INVALID_PARAM", 400, "buyer_card 无效");
|
|
290
|
+
const qty = Number(b.quantity);
|
|
291
|
+
if (!Number.isInteger(qty) || qty < 1) return err("INVALID_PARAM", 400, "quantity 必须是 >=1 的整数");
|
|
292
|
+
if (qty > 99) return err("INVALID_PARAM", 400, "单笔最多 99 件");
|
|
293
|
+
const clientOrderNo = b.client_order_no !== undefined ? String(b.client_order_no) : "";
|
|
294
|
+
if (clientOrderNo && !ID_RE.test(clientOrderNo)) return err("INVALID_PARAM", 400, "client_order_no 无效");
|
|
295
|
+
|
|
296
|
+
// 幂等①: client_order_no 同号返原单
|
|
297
|
+
if (clientOrderNo) {
|
|
298
|
+
const exist = db.prepare("SELECT * FROM agent_pay_sessions WHERE client_session_id = ?")
|
|
299
|
+
.get(`buyer:${buyerCard}:${clientOrderNo}`);
|
|
300
|
+
if (exist) return res.json(orderToJson(exist));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 商品与价格 (权威 = 本地 DB; 客户端任何金额字段一律忽略)
|
|
304
|
+
const product = getActiveProduct(productId);
|
|
305
|
+
if (!product) return err("NOT_FOUND", 404, "商品不存在或已下架");
|
|
306
|
+
const stock = Number(product.stock) || 0;
|
|
307
|
+
if (stock < qty) return err("OUT_OF_STOCK", 409, "库存不足");
|
|
308
|
+
const unitFen = unitFenOf(product); // 整数分, 不过浮点
|
|
309
|
+
if (!(unitFen >= 1)) return err("PRICE_UNAVAILABLE", 409, "该商品暂未定价,暂不可购买");
|
|
310
|
+
const totalFen = unitFen * qty;
|
|
311
|
+
|
|
312
|
+
// 幂等②: 无 client_order_no 时, 同买家同商品 5 分钟内未支付未过期单直接复用
|
|
313
|
+
const now = nowSec();
|
|
314
|
+
if (!clientOrderNo) {
|
|
315
|
+
const exist = db.prepare(`SELECT * FROM agent_pay_sessions
|
|
316
|
+
WHERE client_session_id LIKE ? AND sku_id = ? AND state = 'CREATED' AND expires_at > ? AND created_at >= ?
|
|
317
|
+
ORDER BY created_at DESC LIMIT 1`)
|
|
318
|
+
.get(`buyer:${buyerCard}:%`, productId, now, now - IDEMPOTENCY_WINDOW_SEC);
|
|
319
|
+
if (exist) return res.json(orderToJson(exist));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// U7: rate>0 且 SP 模式 → 微信下单带 settle_info.profit_sharing (不带标记的订单分账被拒)
|
|
323
|
+
const rateBp = await resolveRateBp();
|
|
324
|
+
const psFlag = process.env.WXPAY_SUB_MCHID && rateBp > 0 ? 1 : 0;
|
|
325
|
+
|
|
326
|
+
// 创建订单 (订单号 = order_ref, 对外 id)
|
|
327
|
+
const orderRef = "tb-" + crypto.randomBytes(8).toString("hex");
|
|
328
|
+
const clientSessionId = `buyer:${buyerCard}:${clientOrderNo || orderRef}`;
|
|
329
|
+
const exp = now + SESSION_TTL_SEC;
|
|
330
|
+
|
|
331
|
+
// 微信 Native 下单拿 code_url (mock 模式生成伪码, 不碰真微信)
|
|
332
|
+
let codeUrl = "";
|
|
333
|
+
if (MODE === "mock") {
|
|
334
|
+
codeUrl = `weixin://mock-pay/${orderRef}`;
|
|
335
|
+
} else {
|
|
336
|
+
try {
|
|
337
|
+
codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp,
|
|
338
|
+
psFlag ? { settleInfo: { profit_sharing: true } } : undefined);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.error("[buyer-shop] wx order fail:", e && e.message);
|
|
341
|
+
return err("PAYMENT_UPSTREAM", 502, "支付通道暂时不可用,请稍后再试");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 落 agent_pay_sessions (回调/对账按 order_ref 自动翻转状态; token 不发给买家,
|
|
346
|
+
// 买家面无账号体系, 订单 id 即凭据 — 与 pwa-shop-demo 轮询模型一致)
|
|
347
|
+
// U7: ps_flag 台账标注该单是否带分账标记 (运营台区分用)
|
|
348
|
+
db.prepare(`INSERT INTO agent_pay_sessions
|
|
349
|
+
(client_session_id, sku_id, qty, amount_total, currency, agent_addr, order_ref, pay_token_hash, status_token_hash, state, psp_code_url, callback_url, expires_at, created_at, updated_at, ps_flag)
|
|
350
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
351
|
+
.run(clientSessionId, productId, qty, totalFen, "CNY", "buyer:" + buyerCard, orderRef,
|
|
352
|
+
crypto.randomBytes(32).toString("hex"), crypto.randomBytes(32).toString("hex"),
|
|
353
|
+
"CREATED", codeUrl, "", exp, now, now, psFlag);
|
|
354
|
+
console.log(`[buyer-shop] 下单 ${orderRef} product=${productId} qty=${qty} total=${totalFen}分 ps_flag=${psFlag}`);
|
|
355
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
|
|
356
|
+
res.json(orderToJson(row));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error("[buyer-shop] POST /orders error:", e && e.message);
|
|
359
|
+
res.status(500).json({ error: "INTERNAL", message: "服务暂时不可用,请稍后再试" });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/** GET /orders/:id — 订单查询 (公开, 订单 id 即凭据; 状态映射 session.state → 契约枚举) */
|
|
364
|
+
router.get("/orders/:id", (req, res) => {
|
|
365
|
+
try {
|
|
366
|
+
const id = String(req.params.id || "");
|
|
367
|
+
if (!/^tb-[0-9a-f]{16}$/.test(id)) return res.status(404).json({ error: "NOT_FOUND", message: "订单不存在" });
|
|
368
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(id);
|
|
369
|
+
if (!row) return res.status(404).json({ error: "NOT_FOUND", message: "订单不存在" });
|
|
370
|
+
res.json(orderToJson(row));
|
|
371
|
+
} catch (e) {
|
|
372
|
+
console.error("[buyer-shop] GET /orders/:id error:", e && e.message);
|
|
373
|
+
res.status(500).json({ error: "INTERNAL", message: "服务暂时不可用,请稍后再试" });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return router;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { createBuyerShopRouter, ensureBuyerTables, mapStatus, makeCommissionRateResolver };
|
package/routes/onboard.html
CHANGED
|
@@ -178,7 +178,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
178
178
|
<div class="body">② 备份店铺安全卡
|
|
179
179
|
<div class="desc">安全卡 = 店铺的全部资产钥匙。丢了它且电脑坏了,谁也找不回 (服务商也不行)。请抄写或打印收好。</div>
|
|
180
180
|
<div class="ops"><button class="sbtn" onclick="reprintKey()">重新打印安全卡</button><button class="sbtn" onclick="var h=document.getElementById('hint-recover');h.style.display=h.style.display==='block'?'none':'block'">如何恢复店铺</button><button class="sbtn primary" onclick="startBackupVerify()">我已抄好, 验证备份</button></div>
|
|
181
|
-
<div class="hintbox" id="hint-reprint">出于安全,24 个安全词只在生成时在本页展示一次。<br>重新打印请在你的电脑上运行: <code>npx
|
|
181
|
+
<div class="hintbox" id="hint-reprint">出于安全,24 个安全词只在生成时在本页展示一次。<br>重新打印请在你的电脑上运行: <code>npx trustbase-shop-init print-key</code></div>
|
|
182
182
|
<div class="hintbox" id="hint-recover">丢了/换电脑,在新机器的入驻页恢复店铺,两种方式:<br>① 扫码: 用手机相机扫安全卡上的二维码,屏幕上会显示 24 个安全词;<br>② 手填: 把安全卡上的 24 个安全词按顺序填到下面。<br>
|
|
183
183
|
<textarea id="restore-words" rows="3" placeholder="24 个安全词, 按顺序, 空格分隔" style="width:100%;margin-top:8px;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:14px"></textarea>
|
|
184
184
|
<button class="sbtn primary" style="margin-top:6px" onclick="restoreIdentity()">恢复店铺</button>
|
package/routes/sp-applyment.js
CHANGED
|
@@ -437,6 +437,7 @@ function currentConfig(db) {
|
|
|
437
437
|
sp_desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
|
|
438
438
|
sp_official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
|
|
439
439
|
sp_contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
|
|
440
|
+
sp_services: spProfileGet(db, "sp_services") || process.env.SP_SERVICES || "",
|
|
440
441
|
commission_bp: bp,
|
|
441
442
|
rate_pct: (bp / 100).toFixed(2) + "%",
|
|
442
443
|
assets: readAppAssets(db),
|
|
@@ -524,6 +525,7 @@ input:focus,select:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px
|
|
|
524
525
|
<label>简介</label><input id="p_desc" placeholder="一句话介绍">
|
|
525
526
|
<label>官网</label><input id="p_url" placeholder="https://">
|
|
526
527
|
<label>客服联系方式</label><input id="p_contact" placeholder="微信/电话/邮箱">
|
|
528
|
+
<label>服务内容 (每行一条, 商户入驻页展示)</label><textarea id="p_services" rows="4" style="width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:14px;background:#fff" placeholder="如: 店铺软件托管与运维 微信支付进件代办 交易回调与对账"></textarea>
|
|
527
529
|
<label>默认抽佣费率 (bp, 100 = 1.00%)</label><input id="p_bp" type="number" min="0" max="3000">
|
|
528
530
|
<div class="cur" id="bp-cur"></div>
|
|
529
531
|
<button class="btn" onclick="saveProfile()">保存名片</button>
|
|
@@ -594,6 +596,7 @@ function render(){
|
|
|
594
596
|
}).join(''):'<div class="cur">还没有应用, 在下方新增第一个</div>';
|
|
595
597
|
renderSlots();
|
|
596
598
|
document.getElementById('p_name').value=c.sp_name||'';
|
|
599
|
+
document.getElementById('p_services').value=c.sp_services||'';
|
|
597
600
|
document.getElementById('p_desc').value=c.sp_desc||'';
|
|
598
601
|
document.getElementById('p_url').value=c.sp_official_url||'';
|
|
599
602
|
document.getElementById('p_contact').value=c.sp_contact||'';
|
|
@@ -706,6 +709,7 @@ async function saveProfile(){
|
|
|
706
709
|
sp_desc:document.getElementById('p_desc').value.trim(),
|
|
707
710
|
sp_official_url:document.getElementById('p_url').value.trim(),
|
|
708
711
|
sp_contact:document.getElementById('p_contact').value.trim(),
|
|
712
|
+
sp_services:document.getElementById('p_services').value,
|
|
709
713
|
commission_bp:bp})});
|
|
710
714
|
busy(btn,false);
|
|
711
715
|
var m=document.getElementById('p-msg');
|
|
@@ -854,6 +858,11 @@ summary{font-size:13px;color:#8A857B;cursor:pointer}
|
|
|
854
858
|
<div class="wrap">
|
|
855
859
|
<h1>特约商户进件</h1>
|
|
856
860
|
<div class="sub">TrustBase × 微信支付服务商 · 拍照上传证件, 提交后等待微信审核</div>
|
|
861
|
+
|
|
862
|
+
<div class="box" id="spcard" style="display:none">
|
|
863
|
+
<h3>服务商信息(为你提供服务的一方)</h3>
|
|
864
|
+
<div id="spcard-body" style="font-size:13px"></div>
|
|
865
|
+
</div>
|
|
857
866
|
|
|
858
867
|
<div class="box">
|
|
859
868
|
<h3>① 联系方式 (超级管理员)</h3>
|
|
@@ -929,11 +938,28 @@ summary{font-size:13px;color:#8A857B;cursor:pointer}
|
|
|
929
938
|
</div>
|
|
930
939
|
|
|
931
940
|
<script>
|
|
941
|
+
function escText(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
942
|
+
fetch('info').then(function(r){return r.json()}).then(function(sp){
|
|
943
|
+
if(!sp||!sp.name)return;
|
|
944
|
+
var h='<div style="font-size:15px;font-weight:600">'+escText(sp.name)+'</div>';
|
|
945
|
+
if(sp.desc)h+='<div style="color:#6B655B;margin-top:4px">'+escText(sp.desc)+'</div>';
|
|
946
|
+
if(Array.isArray(sp.services)&&sp.services.length){
|
|
947
|
+
h+='<div style="margin-top:8px;color:#6B655B">服务内容:</div><ul style="margin:4px 0 0;padding-left:18px">'+sp.services.map(function(x){return '<li>'+escText(x)+'</li>'}).join('')+'</ul>';
|
|
948
|
+
}
|
|
949
|
+
h+='<div style="margin-top:10px;font-size:12px;color:#8A857B">公示分账比例:'+escText(sp.rate_pct||'0.00%')+'(分账 = 微信支付按约定比例把交易款结算给服务商的机制;比例上限由你在授权时设置,实际抽取以交易为准)</div>';
|
|
950
|
+
h+='<div style="margin-top:10px;padding:8px 10px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;font-size:12px;color:#166534">🔒 数据归你所有:店铺数据保存在你自己的电脑上,身份与信誉由你的安全卡持有;你可以随时更换服务商——店铺地址和客户不丢失。</div>';
|
|
951
|
+
if(sp.official_url)h+='<div style="margin-top:6px;font-size:12px">官网:<a href="'+escText(sp.official_url)+'" target="_blank" rel="noopener" style="color:#C4633C">'+escText(sp.official_url)+'</a></div>';
|
|
952
|
+
if(sp.contact)h+='<div style="font-size:12px;color:#8A857B">联系:'+escText(sp.contact)+'</div>';
|
|
953
|
+
document.getElementById('spcard-body').innerHTML=h;
|
|
954
|
+
document.getElementById('spcard').style.display='';
|
|
955
|
+
}).catch(function(){});
|
|
956
|
+
|
|
932
957
|
var API = '/api/sp/applyment';
|
|
933
958
|
var mediaIds = {}; // 字段名 → 微信 media_id
|
|
934
959
|
|
|
935
960
|
// admin key: 首次打开输入, 存 localStorage; 401 时清除重来
|
|
936
961
|
function adminKey(){
|
|
962
|
+
|
|
937
963
|
var k = localStorage.getItem('spAdminKey') || '';
|
|
938
964
|
if (!k) { k = prompt('请输入管理密钥 (x-sp-admin-key)') || ''; if (k) localStorage.setItem('spAdminKey', k); }
|
|
939
965
|
return k;
|
|
@@ -1102,6 +1128,7 @@ function createSpApplymentRouter(deps) {
|
|
|
1102
1128
|
res.json({
|
|
1103
1129
|
name: spProfileGet(db, "sp_name") || process.env.SP_NAME || "TrustBase 官方服务商",
|
|
1104
1130
|
desc: spProfileGet(db, "sp_desc") || process.env.SP_DESC || "",
|
|
1131
|
+
services: (spProfileGet(db, "sp_services") || process.env.SP_SERVICES || "").split("\n").map(function(x){return x.trim()}).filter(Boolean), // 2026-09-29 xiaomu: 服务内容清单(商户要知道为什么有分账比例)
|
|
1105
1132
|
official_url: spProfileGet(db, "sp_official_url") || process.env.SP_OFFICIAL_URL || "",
|
|
1106
1133
|
contact: spProfileGet(db, "sp_contact") || process.env.SP_CONTACT || "",
|
|
1107
1134
|
commission_rate_bp: bp,
|
|
@@ -1202,7 +1229,7 @@ function createSpApplymentRouter(deps) {
|
|
|
1202
1229
|
if (!checkAdmin(req, res)) return;
|
|
1203
1230
|
try {
|
|
1204
1231
|
const b = req.body || {};
|
|
1205
|
-
const KEYMAP = { app_appid: "app_appid", sp_name: "sp_name", sp_desc: "sp_desc", sp_official_url: "sp_official_url", sp_contact: "sp_contact" };
|
|
1232
|
+
const KEYMAP = { app_appid: "app_appid", sp_name: "sp_name", sp_desc: "sp_desc", sp_official_url: "sp_official_url", sp_contact: "sp_contact", sp_services: "sp_services" };
|
|
1206
1233
|
const saved = {};
|
|
1207
1234
|
for (const k of Object.keys(KEYMAP)) {
|
|
1208
1235
|
if (b[k] !== undefined) {
|
package/routes/sp-notify.js
CHANGED
|
@@ -70,7 +70,14 @@ CREATE TABLE IF NOT EXISTS sp_sub_merchants (
|
|
|
70
70
|
* evidence_hash 口径与 ShopXO 插件/agent-serviceprovider 一致: sha256(order_ref + psp_txn_id + amount) */
|
|
71
71
|
async function submitPaymentFactToChain(fact) {
|
|
72
72
|
const c = chainWrites.cfg();
|
|
73
|
-
|
|
73
|
+
// [P1 修复 kimi 18462] 默认值不再写死 emergency-key (SP 网关机器上该 key 不存在且非 provider)
|
|
74
|
+
// 必填: SP_CHAIN_KEY_NAME + SP_CHAIN_HOME (或 CHAIN_HOME)
|
|
75
|
+
c.keyName = process.env.SP_CHAIN_KEY_NAME || "";
|
|
76
|
+
c.keyringHome = process.env.SP_CHAIN_HOME || process.env.CHAIN_HOME || c.keyringHome;
|
|
77
|
+
if (!c.keyName) {
|
|
78
|
+
console.warn("[sp-notify] SP_CHAIN_KEY_NAME 未设置 — 上链签名将失败。");
|
|
79
|
+
console.warn("[sp-notify] 请在 .env 配置: SP_CHAIN_KEY_NAME=<provider专用key名> + CHAIN_HOME=<keyring目录>");
|
|
80
|
+
}
|
|
74
81
|
const addr = await chainWrites.keyAddress(c);
|
|
75
82
|
const messages = [{
|
|
76
83
|
"@type": "/trustchain.serviceprovider.v1.MsgSubmitPaymentFact",
|
|
@@ -331,4 +338,57 @@ function createSpNotifyRouter(deps) {
|
|
|
331
338
|
return router;
|
|
332
339
|
}
|
|
333
340
|
|
|
334
|
-
|
|
341
|
+
/** [P1 修复] 启动自检: 签名 key 存在性 + provider ACTIVE 状态 (不阻塞启动, 只 WARN/ERROR) */
|
|
342
|
+
async function verifyChainSigning(deps) {
|
|
343
|
+
try {
|
|
344
|
+
const c = chainWrites.cfg();
|
|
345
|
+
const keyName = process.env.SP_CHAIN_KEY_NAME || "";
|
|
346
|
+
const chainHome = process.env.SP_CHAIN_HOME || process.env.CHAIN_HOME || "";
|
|
347
|
+
|
|
348
|
+
if (!keyName) {
|
|
349
|
+
console.error("[sp-notify][STARTUP-CHECK] ❌ SP_CHAIN_KEY_NAME 未设置 — 支付事实无法上链");
|
|
350
|
+
console.error("[sp-notify][STARTUP-CHECK] 修法: .env 加 SP_CHAIN_KEY_NAME=<provider key 名>");
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (!chainHome) {
|
|
354
|
+
console.error("[sp-notify][STARTUP-CHECK] ⚠️ CHAIN_HOME/SP_CHAIN_HOME 未设置 — 使用默认 /root/.trustchain (可能没有 provider key)");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 检查 key 是否能解析出地址
|
|
358
|
+
try {
|
|
359
|
+
const addr = await chainWrites.keyAddress(c);
|
|
360
|
+
console.log("[sp-notify][STARTUP-CHECK] ✓ 签名 key 可用, provider 地址:", addr);
|
|
361
|
+
|
|
362
|
+
// 检查该地址是否是链上 ACTIVE provider (查询, 失败只 WARN 不阻塞)
|
|
363
|
+
try {
|
|
364
|
+
const res = await fetch(`${process.env.CHAIN_RPC || "http://127.0.0.1:26657"}/abci_query?path="/trustchain.serviceprovider.v1.Query/ServiceProvider&data=${Buffer.from(JSON.stringify({provider: addr})).toString("hex").toUpperCase()}`);
|
|
365
|
+
if (res.ok) {
|
|
366
|
+
const json = await res.json();
|
|
367
|
+
const value = json?.result?.response?.value;
|
|
368
|
+
if (value) {
|
|
369
|
+
const decoded = JSON.parse(Buffer.from(value, "base64").toString());
|
|
370
|
+
if (decoded?.status === "ACTIVE") {
|
|
371
|
+
console.log("[sp-notify][STARTUP-CHECK] ✓ provider 状态 ACTIVE");
|
|
372
|
+
} else {
|
|
373
|
+
console.error(`[sp-notify][STARTUP-CHECK] ❌ provider 状态 ${decoded?.status || "unknown"} (需 ACTIVE) — 支付事实将被链上拒绝`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
console.warn("[sp-notify][STARTUP-CHECK] ⚠️ provider 查询无结果 — 该地址可能未注册为 provider");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (queryErr) {
|
|
380
|
+
console.warn("[sp-notify][STARTUP-CHECK] ⚠️ provider 状态查询失败 (链节点可能未启动):", queryErr.message);
|
|
381
|
+
}
|
|
382
|
+
return true;
|
|
383
|
+
} catch (keyErr) {
|
|
384
|
+
console.error("[sp-notify][STARTUP-CHECK] ❌ 签名 key 不可用:", keyErr.message);
|
|
385
|
+
console.error("[sp-notify][STARTUP-CHECK] 检查 CHAIN_HOME 目录 + keyring 里是否有 key:", keyName);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
console.warn("[sp-notify][STARTUP-CHECK] 自检异常 (不阻塞):", e.message);
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = { createSpNotifyRouter, ensureTables, retryPending, retryProfitsharingSweep, submitPaymentFactToChain, verifyChainSigning };
|