trustbase-seller-backend 0.6.0 → 0.7.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.
- package/db/store-migrate.js +2 -0
- package/middleware/api-key-auth.js +4 -1
- package/package.json +1 -1
- package/routes/agent-pay.js +31 -11
- package/routes/buyer-shop.js +83 -11
- package/test-buyer-shop-ps.js +13 -10
- package/test-buyer-shop.js +14 -13
- package/test-laneC.js +85 -0
- package/test-product-type.js +173 -0
package/db/store-migrate.js
CHANGED
|
@@ -88,6 +88,8 @@ function runStoreMigrations(db) {
|
|
|
88
88
|
`ALTER TABLE store_products ADD COLUMN chain_listed_at INTEGER`,
|
|
89
89
|
`ALTER TABLE store_products ADD COLUMN chain_tx_hash TEXT`,
|
|
90
90
|
`ALTER TABLE store_products ADD COLUMN chain_stake_per_unit INTEGER`,
|
|
91
|
+
// A1 (2026-10-05): 商品类型驱动收货地址 — physical(默认) 实物 / virtual 虚拟
|
|
92
|
+
`ALTER TABLE store_products ADD COLUMN product_type TEXT NOT NULL DEFAULT 'physical'`,
|
|
91
93
|
];
|
|
92
94
|
for (const stmt of alterStatements) {
|
|
93
95
|
try {
|
|
@@ -165,7 +165,10 @@ function isTunnelBuyerRequest(req, clientIP) {
|
|
|
165
165
|
const fromLoopback = clientIP === '127.0.0.1' || clientIP === '::1' || /^127\./.test(clientIP);
|
|
166
166
|
if (!fromLoopback) return false;
|
|
167
167
|
if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
|
|
168
|
-
|
|
168
|
+
// 注意: 本中间件挂在 '/api' 上, req.path 是挂载后路径 ('/products'), req.originalUrl 才带 '/api'
|
|
169
|
+
// → 两种前缀都要判, 否则放行条件恒为假 (2026-10-03 .211 实测 401 才发现)
|
|
170
|
+
const paths = [String(req.path || ''), String(req.originalUrl || '').split('?')[0]];
|
|
171
|
+
return TUNNEL_BUYER_PATHS.some(p => paths.some(x => x === p || x.startsWith(p + '/') || x.startsWith(p)));
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
function requireAPIKey(skipLAN = true, exemptPaths = []) { return (req, res, next) => {
|
package/package.json
CHANGED
package/routes/agent-pay.js
CHANGED
|
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS agent_pay_sessions (
|
|
|
44
44
|
pay_token_hash TEXT NOT NULL,
|
|
45
45
|
status_token_hash TEXT NOT NULL,
|
|
46
46
|
state TEXT NOT NULL DEFAULT 'CREATED', -- CREATED/PAID/ON_CHAIN/SHIPPED/DELIVERED/CONFIRMED/CLOSED/EXPIRED/REFUNDED/DISPUTED
|
|
47
|
+
sub_mchid TEXT DEFAULT '', -- Lane C: 本单所属特约商户 (多商户; 查单/对账按它选 partner 端点)
|
|
47
48
|
psp_code_url TEXT DEFAULT '',
|
|
48
49
|
psp_txn_id TEXT DEFAULT '',
|
|
49
50
|
shopxo_order_no TEXT DEFAULT '',
|
|
@@ -62,9 +63,16 @@ CREATE TABLE IF NOT EXISTS psp_txns (
|
|
|
62
63
|
created_at INTEGER NOT NULL
|
|
63
64
|
);
|
|
64
65
|
CREATE INDEX IF NOT EXISTS idx_aps_state ON agent_pay_sessions(state);
|
|
65
|
-
`);
|
|
66
|
-
// 2026-
|
|
67
|
-
|
|
66
|
+
`);
|
|
67
|
+
// Lane C (2026-10-03): 旧库补列 — CREATE TABLE IF NOT EXISTS 不会给已有表加列
|
|
68
|
+
try {
|
|
69
|
+
const _c = db.prepare("PRAGMA table_info(agent_pay_sessions)").all().map((x) => x.name);
|
|
70
|
+
if (!_c.includes("sub_mchid")) db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN sub_mchid TEXT DEFAULT ''");
|
|
71
|
+
} catch (_) {}
|
|
72
|
+
|
|
73
|
+
// 2026-09-20 E4': 物流列 (老库兼容) + A1 收货地址/交付凭据列
|
|
74
|
+
for (const col of ["carrier TEXT DEFAULT ''", "tracking_no TEXT DEFAULT ''", "logistics_state TEXT DEFAULT ''", "timeline TEXT DEFAULT '[]'", "shopxo_order_no_ref TEXT DEFAULT ''",
|
|
75
|
+
"shipping_address TEXT DEFAULT ''", "delivery_credential TEXT DEFAULT ''"]) {
|
|
68
76
|
try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
|
|
69
77
|
}
|
|
70
78
|
}
|
|
@@ -138,13 +146,21 @@ async function wxApiCall(method, urlPath, bodyObj, override) {
|
|
|
138
146
|
// opts.settleInfo (2026-10-06 U7): 可选, 如 { profit_sharing: true } — 微信规则: 订单下单时
|
|
139
147
|
// 不带 settle_info.profit_sharing 的分账请求会被拒; 默认不带 (向后兼容, 直连模式不用传)
|
|
140
148
|
async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opts) {
|
|
149
|
+
// Lane C (2026-10-03): sub_mchid 按本单商家传入 (多商户), 未传才回落全局 env (单商户旧部署)
|
|
150
|
+
const subMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
|
|
151
|
+
if (opts && opts.subMchidSource && opts.subMchidSource !== "registry") {
|
|
152
|
+
console.warn(
|
|
153
|
+
`[agent-pay] 本单 sub_mchid 来源=${opts.subMchidSource} (非登记表), 使用 ${subMchid || "直连模式"}` +
|
|
154
|
+
(subMchid ? " — 多商户场景请核对该商户是否已进件登记" : "")
|
|
155
|
+
);
|
|
156
|
+
}
|
|
141
157
|
// SP 模式: 用服务商身份签名 + 请求体带 sp_mchid + sub_mchid
|
|
142
|
-
const isSp = !!(WX.spMchid &&
|
|
158
|
+
const isSp = !!(WX.spMchid && subMchid);
|
|
143
159
|
const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
|
|
144
160
|
const body = {
|
|
145
161
|
// 直连模式: appid = 商户公众号; SP 模式: 微信不认 appid, 要 sp_appid(服务商) + sub_appid(特约商户)
|
|
146
162
|
...(isSp
|
|
147
|
-
? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid:
|
|
163
|
+
? { sp_mchid: WX.spMchid, sp_appid: WX.spAppid, sub_mchid: subMchid, sub_appid: WX.subAppid || WX.appid }
|
|
148
164
|
: { appid: WX.appid, mchid: WX.mchid }),
|
|
149
165
|
description: String(description).slice(0, 127),
|
|
150
166
|
out_trade_no: outTradeNo, time_expire: new Date(expireSec * 1000).toISOString(),
|
|
@@ -161,11 +177,13 @@ async function wxNativeOrder(outTradeNo, description, totalCents, expireSec, opt
|
|
|
161
177
|
return r.json.code_url;
|
|
162
178
|
}
|
|
163
179
|
// 查单: trade_state ∈ {SUCCESS, NOTPAY, CLOSED, ...} (SP 模式走 partner endpoint + 双 mchid 查询串)
|
|
164
|
-
async function wxQueryOrder(outTradeNo) {
|
|
165
|
-
|
|
180
|
+
async function wxQueryOrder(outTradeNo, opts) {
|
|
181
|
+
// Lane C: 本单商户号优先, 回落全局 env (多商户)
|
|
182
|
+
const qSubMchid = String((opts && opts.subMchid) || process.env.WXPAY_SUB_MCHID || "");
|
|
183
|
+
const isSp = !!(WX.spMchid && qSubMchid);
|
|
166
184
|
const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
|
|
167
185
|
const endpoint = isSp
|
|
168
|
-
? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${
|
|
186
|
+
? `/v3/pay/partner/transactions/out-trade-no/${outTradeNo}?sp_mchid=${WX.spMchid}&sub_mchid=${qSubMchid}`
|
|
169
187
|
: `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${WX.mchid}`;
|
|
170
188
|
const r = await wxApiCall("GET", endpoint, null, override);
|
|
171
189
|
if (r.status !== 200) return { state: "UNKNOWN", raw: r.json };
|
|
@@ -210,7 +228,8 @@ function loadPlatformCertsFromDisk() {
|
|
|
210
228
|
|
|
211
229
|
/** 拉取微信平台证书 (GET /v3/certificates, 密文用调用方 APIv3 key 解密) → 内存+磁盘缓存 */
|
|
212
230
|
async function refreshPlatformCerts() {
|
|
213
|
-
|
|
231
|
+
// Lane C: SP 身份只判 sp_mchid 是否配置 — 不能依赖全局 sub_mchid (多商户部署常不设默认值)
|
|
232
|
+
const isSp = !!WX.spMchid;
|
|
214
233
|
const override = isSp ? { mchid: WX.spMchid, serial: WX.spSerial, keyPath: WX.spKeyPath } : undefined;
|
|
215
234
|
const r = await wxApiCall("GET", "/v3/certificates", null, override);
|
|
216
235
|
if (r.status !== 200 || !Array.isArray(r.json && r.json.data)) {
|
|
@@ -260,7 +279,8 @@ function wxVerifyNotifySignature(serial, timestamp, nonce, rawBody, signature) {
|
|
|
260
279
|
|
|
261
280
|
/** 回调资源解密 (SP 模式优先 sp APIv3 key) */
|
|
262
281
|
function wxDecryptNotifyResource(resource) {
|
|
263
|
-
|
|
282
|
+
// Lane C: 回调是发给服务商的, 必须用服务商 apiv3 key — 不能依赖全局 sub_mchid
|
|
283
|
+
const isSp = !!WX.spMchid;
|
|
264
284
|
const apiv3 = isSp && WX.spApiv3Key ? WX.spApiv3Key : WX.apiv3Key;
|
|
265
285
|
const plain = wxAesGcmDecrypt(apiv3, resource.nonce, resource.associated_data, resource.ciphertext);
|
|
266
286
|
return JSON.parse(plain.toString("utf8"));
|
|
@@ -624,7 +644,7 @@ async function runReconcileOnce(db) {
|
|
|
624
644
|
scanned = pend.length;
|
|
625
645
|
for (const row of pend) {
|
|
626
646
|
try {
|
|
627
|
-
const q = await wxQueryOrder(row.order_ref);
|
|
647
|
+
const q = await wxQueryOrder(row.order_ref, { subMchid: row.sub_mchid });
|
|
628
648
|
if (q.state === "SUCCESS") {
|
|
629
649
|
settlePayment(row, q.txnId || ("wxq-" + row.order_ref), "wechat_native", q.amount || row.amount_total, JSON.stringify(q.raw).slice(0, 2000));
|
|
630
650
|
settled++;
|
package/routes/buyer-shop.js
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
*
|
|
32
32
|
* 下单要点:
|
|
33
33
|
* - 价格权威 = 本地 DB (单价 × qty 算总分), 客户端传的任何金额字段一律忽略
|
|
34
|
+
* - A1 (2026-10-05 设计方案): product_type 驱动收货地址 — physical 强制 shipping_address
|
|
35
|
+
* (缺失 400 SHIPPING_ADDRESS_REQUIRED), virtual 免地址并生成 delivery_credential
|
|
36
|
+
* (license 格式 tb-license-{order_ref}-{random4}); 无 product_type 列/字段默认 physical
|
|
34
37
|
* - qty 整数 >=1 且不超库存; 单价折算后 <= 0 分 → 409 (微信不支持 0 元单)
|
|
35
38
|
* - 幂等两级: ① body.client_order_no → client_session_id='buyer:{card}:{no}' 同号返原单
|
|
36
39
|
* ② 无 client_order_no → 同 buyer_card+product_id 5 分钟窗口内未支付未过期单直接复用
|
|
@@ -93,11 +96,19 @@ CREATE TABLE IF NOT EXISTS agent_pay_sessions (
|
|
|
93
96
|
updated_at INTEGER NOT NULL
|
|
94
97
|
);
|
|
95
98
|
`);
|
|
96
|
-
// 老库兼容: agent-pay 的物流列 + U7 分账标记列 (已存在则跳过)
|
|
99
|
+
// 老库兼容: agent-pay 的物流列 + Lane C 商户号列 + U7 分账标记列 + A1 收货地址/交付凭据列 (已存在则跳过)
|
|
97
100
|
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
|
-
"
|
|
101
|
+
"sub_mchid TEXT DEFAULT ''", // Lane C: 本单所属特约商户 (INSERT 引用; 0.4.3 教训: 写路径自建, 不依赖 agent-pay 先跑)
|
|
102
|
+
"ps_flag INTEGER NOT NULL DEFAULT 0", // U7: 1=下单带 settle_info.profit_sharing (rate>0 且 SP 模式)
|
|
103
|
+
"shipping_address TEXT DEFAULT ''", // A1: 实物订单收货地址 (虚拟单允许空)
|
|
104
|
+
"delivery_credential TEXT DEFAULT ''" // A1: 虚拟商品交付凭据 (license/兑换码; 实物单为空)
|
|
105
|
+
]) {
|
|
99
106
|
try { db.exec("ALTER TABLE agent_pay_sessions ADD COLUMN " + col); } catch (e) { /* 已存在 */ }
|
|
100
107
|
}
|
|
108
|
+
// A1 商品类型: store_products 补 product_type (写路径自建, 不依赖 store-migrate 先跑; 表不存在则跳过)
|
|
109
|
+
if (hasTable(db, "store_products")) {
|
|
110
|
+
try { db.exec("ALTER TABLE store_products ADD COLUMN product_type TEXT NOT NULL DEFAULT 'physical'"); } catch (e) { /* 已存在 */ }
|
|
111
|
+
}
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
function hasTable(db, name) {
|
|
@@ -139,13 +150,14 @@ const RATE_CACHE_MS = 60 * 1000; // U7: 费率网关查询 60s 内存缓
|
|
|
139
150
|
function makeCommissionRateResolver(db, fetcher) {
|
|
140
151
|
const cache = new Map(); // sub_mchid → {bp, expiresAt}
|
|
141
152
|
const fetchFn = fetcher || globalThis.fetch;
|
|
142
|
-
return async function resolveRateBp() {
|
|
153
|
+
return async function resolveRateBp(subMchidArg) {
|
|
143
154
|
// ① env 显式覆盖 (最高优先, 不调网关不读表)
|
|
144
155
|
if (process.env.SP_COMMISSION_BP !== undefined && process.env.SP_COMMISSION_BP !== "") {
|
|
145
156
|
const v = Number(process.env.SP_COMMISSION_BP);
|
|
146
157
|
return Number.isInteger(v) && v >= 0 ? v : 0;
|
|
147
158
|
}
|
|
148
|
-
|
|
159
|
+
// Lane C (2026-10-03): 按传入的商户号解析; 未传才回落全局 env (多商户)
|
|
160
|
+
const subMchid = String(subMchidArg || process.env.WXPAY_SUB_MCHID || "");
|
|
149
161
|
if (!subMchid) return 0; // 直连模式不标记
|
|
150
162
|
const hit = cache.get(subMchid);
|
|
151
163
|
if (hit && Date.now() < hit.expiresAt) return hit.bp;
|
|
@@ -176,12 +188,48 @@ function makeCommissionRateResolver(db, fetcher) {
|
|
|
176
188
|
};
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/** Lane C (2026-10-03): 按「商品卖家地址」解析特约商户号 —— 多商户的核心
|
|
192
|
+
* 数据源: sp_sub_merchants.seller_address → sub_mchid (进件时写入, 网关与商家机各一份)
|
|
193
|
+
* 兜底: 全局 env WXPAY_SUB_MCHID (单商户旧部署) —— **必须显式告警**, 不静默错分
|
|
194
|
+
* 返回 { subMchid, source: registry | env-fallback | none } */
|
|
195
|
+
function makeSubMchidResolver(db) {
|
|
196
|
+
const cache = new Map(); // seller_address → {subMchid, source}
|
|
197
|
+
return function resolveSubMchid(sellerAddress) {
|
|
198
|
+
const env = String(process.env.WXPAY_SUB_MCHID || "");
|
|
199
|
+
const key = String(sellerAddress || "");
|
|
200
|
+
if (!key) return { subMchid: env, source: env ? "env-fallback-no-seller" : "none" };
|
|
201
|
+
const hit = cache.get(key);
|
|
202
|
+
if (hit) return hit;
|
|
203
|
+
let out = { subMchid: env, source: env ? "env-fallback" : "none" };
|
|
204
|
+
try {
|
|
205
|
+
if (hasTable(db, "sp_sub_merchants")) {
|
|
206
|
+
const row = db.prepare(
|
|
207
|
+
"SELECT sub_mchid FROM sp_sub_merchants WHERE seller_address = ? AND status = 'active'"
|
|
208
|
+
).get(key);
|
|
209
|
+
if (row && row.sub_mchid) out = { subMchid: String(row.sub_mchid), source: "registry" };
|
|
210
|
+
}
|
|
211
|
+
} catch (_) { /* 表缺失/异常 → 走兜底 */ }
|
|
212
|
+
if (out.source !== "registry") {
|
|
213
|
+
console.warn(
|
|
214
|
+
`[buyer-shop] 卖家 ${key.slice(0, 14)}… 未在 sp_sub_merchants 登记 → ` +
|
|
215
|
+
(out.subMchid
|
|
216
|
+
? `回退全局 WXPAY_SUB_MCHID=${out.subMchid}(多商户场景可能分错商户!)`
|
|
217
|
+
: "无 sub_mchid(按直连模式,本单不分账)")
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
cache.set(key, out);
|
|
221
|
+
return out;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
179
225
|
function createBuyerShopRouter(deps) {
|
|
180
226
|
const router = express.Router();
|
|
181
227
|
const { db } = deps;
|
|
182
228
|
ensureBuyerTables(db);
|
|
183
229
|
const rateLimit = makeRateLimiter();
|
|
184
230
|
const resolveRateBp = makeCommissionRateResolver(db, deps.fetcher);
|
|
231
|
+
// Lane C: 按商品卖家解析 sub_mchid (多商户); 表缺失时回落 env 并告警
|
|
232
|
+
const resolveSubMchid = makeSubMchidResolver(db);
|
|
185
233
|
|
|
186
234
|
const MODE = process.env.AGENT_PAY_MODE || "mock"; // mock | wechat (同 agent-pay 口径)
|
|
187
235
|
// 测试可注入假 wxNativeOrder; 生产默认 agent-pay 真实现 (SP 模式其内部自动切换)
|
|
@@ -210,6 +258,8 @@ function createBuyerShopRouter(deps) {
|
|
|
210
258
|
price: fenToYuan(unitFenOf(row)),
|
|
211
259
|
image_url: productCols.includes("image_url") ? String(row.image_url || "") : "",
|
|
212
260
|
stock: Number(row.stock) || 0,
|
|
261
|
+
// A1: 商品类型 (physical=实物要收货地址 / virtual=虚拟免地址发凭据); 无该列的老库默认 physical
|
|
262
|
+
product_type: productCols.includes("product_type") ? String(row.product_type || "physical") : "physical",
|
|
213
263
|
};
|
|
214
264
|
}
|
|
215
265
|
|
|
@@ -241,6 +291,9 @@ function createBuyerShopRouter(deps) {
|
|
|
241
291
|
quantity: row.qty,
|
|
242
292
|
tx_hash: txHash,
|
|
243
293
|
code_url: row.psp_code_url || "",
|
|
294
|
+
// A1: 收货地址 / 交付凭据 (老订单两列为空 → 空串)
|
|
295
|
+
shipping_address: String(row.shipping_address || ""),
|
|
296
|
+
delivery_credential: String(row.delivery_credential || ""),
|
|
244
297
|
};
|
|
245
298
|
}
|
|
246
299
|
|
|
@@ -328,6 +381,13 @@ function createBuyerShopRouter(deps) {
|
|
|
328
381
|
if (!(unitFen >= 1)) return err("PRICE_UNAVAILABLE", 409, "该商品暂未定价,暂不可购买");
|
|
329
382
|
const totalFen = unitFen * qty;
|
|
330
383
|
|
|
384
|
+
// A1 商品类型驱动收货地址 (2026-10-05 设计方案): physical 强制收货地址, virtual 免地址生成交付凭据
|
|
385
|
+
const productType = String(product.product_type || "physical").toLowerCase() === "virtual" ? "virtual" : "physical";
|
|
386
|
+
const shippingAddress = String(b.shipping_address || "").trim().slice(0, 500);
|
|
387
|
+
if (productType === "physical" && !shippingAddress) {
|
|
388
|
+
return err("SHIPPING_ADDRESS_REQUIRED", 400, "该商品需要填写收货地址");
|
|
389
|
+
}
|
|
390
|
+
|
|
331
391
|
// 幂等②: 无 client_order_no 时, 同买家同商品 5 分钟内未支付未过期单直接复用
|
|
332
392
|
const now = nowSec();
|
|
333
393
|
if (!clientOrderNo) {
|
|
@@ -338,23 +398,35 @@ function createBuyerShopRouter(deps) {
|
|
|
338
398
|
if (exist) return res.json(orderToJson(exist));
|
|
339
399
|
}
|
|
340
400
|
|
|
401
|
+
// Lane C: 本单属于哪个特约商户 = 商品卖家的登记商户号 (不再是全局 env)
|
|
402
|
+
const sellerResolved = resolveSubMchid(product.seller_address);
|
|
403
|
+
const subMchid = sellerResolved.subMchid;
|
|
404
|
+
|
|
341
405
|
// U7: rate>0 且 SP 模式 → 微信下单带 settle_info.profit_sharing (不带标记的订单分账被拒)
|
|
342
|
-
const rateBp = await resolveRateBp();
|
|
343
|
-
const psFlag =
|
|
406
|
+
const rateBp = await resolveRateBp(subMchid);
|
|
407
|
+
const psFlag = subMchid && rateBp > 0 ? 1 : 0;
|
|
344
408
|
|
|
345
409
|
// 创建订单 (订单号 = order_ref, 对外 id)
|
|
346
410
|
const orderRef = "tb-" + crypto.randomBytes(8).toString("hex");
|
|
347
411
|
const clientSessionId = `buyer:${buyerCard}:${clientOrderNo || orderRef}`;
|
|
348
412
|
const exp = now + SESSION_TTL_SEC;
|
|
349
413
|
|
|
414
|
+
// A1: 虚拟商品交付凭据 (license/兑换码, 与订单绑定, 格式 tb-license-{order_ref}-{random4}; 实物单为空)
|
|
415
|
+
const deliveryCredential = productType === "virtual"
|
|
416
|
+
? `tb-license-${orderRef}-${crypto.randomBytes(2).toString("hex")}`
|
|
417
|
+
: "";
|
|
418
|
+
|
|
350
419
|
// 微信 Native 下单拿 code_url (mock 模式生成伪码, 不碰真微信)
|
|
351
420
|
let codeUrl = "";
|
|
352
421
|
if (MODE === "mock") {
|
|
353
422
|
codeUrl = `weixin://mock-pay/${orderRef}`;
|
|
354
423
|
} else {
|
|
355
424
|
try {
|
|
356
|
-
codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp,
|
|
357
|
-
|
|
425
|
+
codeUrl = await wxNativeOrder(orderRef, String(product.title || productId), totalFen, exp, {
|
|
426
|
+
subMchid,
|
|
427
|
+
subMchidSource: sellerResolved.source,
|
|
428
|
+
...(psFlag ? { settleInfo: { profit_sharing: true } } : {}),
|
|
429
|
+
});
|
|
358
430
|
} catch (e) {
|
|
359
431
|
console.error("[buyer-shop] wx order fail:", e && e.message);
|
|
360
432
|
return err("PAYMENT_UPSTREAM", 502, "支付通道暂时不可用,请稍后再试");
|
|
@@ -365,11 +437,11 @@ function createBuyerShopRouter(deps) {
|
|
|
365
437
|
// 买家面无账号体系, 订单 id 即凭据 — 与 pwa-shop-demo 轮询模型一致)
|
|
366
438
|
// U7: ps_flag 台账标注该单是否带分账标记 (运营台区分用)
|
|
367
439
|
db.prepare(`INSERT INTO agent_pay_sessions
|
|
368
|
-
(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)
|
|
369
|
-
VALUES (
|
|
440
|
+
(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, sub_mchid, shipping_address, delivery_credential)
|
|
441
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
370
442
|
.run(clientSessionId, productId, qty, totalFen, "CNY", "buyer:" + buyerCard, orderRef,
|
|
371
443
|
crypto.randomBytes(32).toString("hex"), crypto.randomBytes(32).toString("hex"),
|
|
372
|
-
"CREATED", codeUrl, "", exp, now, now, psFlag);
|
|
444
|
+
"CREATED", codeUrl, "", exp, now, now, psFlag, subMchid, shippingAddress, deliveryCredential);
|
|
373
445
|
console.log(`[buyer-shop] 下单 ${orderRef} product=${productId} qty=${qty} total=${totalFen}分 ps_flag=${psFlag}`);
|
|
374
446
|
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(orderRef);
|
|
375
447
|
res.json(orderToJson(row));
|
package/test-buyer-shop-ps.js
CHANGED
|
@@ -71,10 +71,10 @@ function withWechatMode(fn) {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function placeOrder(base, card) {
|
|
74
|
+
async function placeOrder(base, card, productId = "p1") {
|
|
75
75
|
const r = await fetch(base + "/api/orders", {
|
|
76
76
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
77
|
-
body: JSON.stringify({ product_id:
|
|
77
|
+
body: JSON.stringify({ product_id: productId, quantity: 1, buyer_card: card, shipping_address: "addr-ps" }),
|
|
78
78
|
});
|
|
79
79
|
return { status: r.status, json: await r.json() };
|
|
80
80
|
}
|
|
@@ -90,7 +90,7 @@ test("rate=0 (网关 404 未登记) + SP 模式 → 不带 settle_info, ps_flag=
|
|
|
90
90
|
const { status, json } = await placeOrder(base, "card_ps1");
|
|
91
91
|
assert.strictEqual(status, 200, JSON.stringify(json));
|
|
92
92
|
assert.strictEqual(wxCalls.length, 1);
|
|
93
|
-
assert.
|
|
93
|
+
assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback" }, "rate=0 不传 settleInfo");
|
|
94
94
|
const row = db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(json.id);
|
|
95
95
|
assert.strictEqual(row.ps_flag, 0, "台账 ps_flag=0");
|
|
96
96
|
} finally { srv.close(); restoreEnv(env); }
|
|
@@ -110,7 +110,7 @@ test("rate=100 (env SP_COMMISSION_BP 覆盖) + SP 模式 → settle_info.profit_
|
|
|
110
110
|
assert.strictEqual(status, 200, JSON.stringify(json));
|
|
111
111
|
assert.strictEqual(gwCalls, 0, "env 覆盖时不调网关");
|
|
112
112
|
assert.strictEqual(wxCalls.length, 1);
|
|
113
|
-
assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } }, "带分账标记");
|
|
113
|
+
assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } }, "带分账标记");
|
|
114
114
|
assert.strictEqual(db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(json.id).ps_flag, 1);
|
|
115
115
|
} finally { srv.close(); restoreEnv(env); }
|
|
116
116
|
}));
|
|
@@ -126,13 +126,13 @@ test("网关接口返回 rate_bp=100 → 带 settle_info; 60s 缓存 (第二次
|
|
|
126
126
|
process.env.SP_GATEWAY_URL = "https://gw.example.com";
|
|
127
127
|
const a = await placeOrder(base, "card_ps3a");
|
|
128
128
|
assert.strictEqual(a.status, 200);
|
|
129
|
-
assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } });
|
|
129
|
+
assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } });
|
|
130
130
|
assert.strictEqual(gwCalls, 1);
|
|
131
131
|
// 第二次下单 (不同买家卡避免幂等复用) → 命中缓存, 不再调网关
|
|
132
132
|
const b = await placeOrder(base, "card_ps3b");
|
|
133
133
|
assert.strictEqual(b.status, 200);
|
|
134
134
|
assert.strictEqual(gwCalls, 1, "60s 缓存生效");
|
|
135
|
-
assert.deepStrictEqual(wxCalls[1].opts, { settleInfo: { profit_sharing: true } });
|
|
135
|
+
assert.deepStrictEqual(wxCalls[1].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } });
|
|
136
136
|
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions WHERE ps_flag = 1").get().n, 2);
|
|
137
137
|
} finally { srv.close(); restoreEnv(env); }
|
|
138
138
|
}));
|
|
@@ -148,7 +148,7 @@ test("费率网关失败 → 缓存 0 防抖动, 不阻塞下单, 不带 settle_
|
|
|
148
148
|
process.env.SP_GATEWAY_URL = "https://unreachable.example.com";
|
|
149
149
|
const a = await placeOrder(base, "card_ps4a");
|
|
150
150
|
assert.strictEqual(a.status, 200, "网关失败不阻塞下单");
|
|
151
|
-
assert.
|
|
151
|
+
assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback" }, "失败按 rate=0 不带标记");
|
|
152
152
|
const b = await placeOrder(base, "card_ps4b");
|
|
153
153
|
assert.strictEqual(b.status, 200);
|
|
154
154
|
assert.strictEqual(gwCalls, 1, "失败也缓存 60s (防抖动)");
|
|
@@ -167,12 +167,15 @@ test("本地 sp_commission_rates 兜底 (网关未配置, 同库直读) + 直连
|
|
|
167
167
|
process.env.WXPAY_SUB_MCHID = "1118152327";
|
|
168
168
|
const a = await placeOrder(base, "card_ps5a");
|
|
169
169
|
assert.strictEqual(a.status, 200);
|
|
170
|
-
assert.deepStrictEqual(wxCalls[0].opts, { settleInfo: { profit_sharing: true } }, "本地表 rate=150 → 带标记");
|
|
170
|
+
assert.deepStrictEqual(wxCalls[0].opts, { subMchid: "1118152327", subMchidSource: "env-fallback", settleInfo: { profit_sharing: true } }, "本地表 rate=150 → 带标记");
|
|
171
171
|
// 直连模式: 即便有费率也不标记 (settle_info 是 SP 分账语义)
|
|
172
|
+
// Lane C: sub_mchid 解析按卖家地址缓存 — 换另一个卖家的商品, 避免命中上一单的 env-fallback 缓存
|
|
172
173
|
delete process.env.WXPAY_SUB_MCHID;
|
|
173
|
-
|
|
174
|
+
db.prepare(`INSERT INTO store_products (id, seller_address, title, description, price_utct, stock, status, image_url, created_at, updated_at)
|
|
175
|
+
VALUES ('p2','tct1sellerDirect','U7直连商品','',2500000,10,'active',NULL,?,?)`).run(NOW_MS, NOW_MS);
|
|
176
|
+
const b = await placeOrder(base, "card_ps5b", "p2");
|
|
174
177
|
assert.strictEqual(b.status, 200);
|
|
175
|
-
assert.
|
|
178
|
+
assert.deepStrictEqual(wxCalls[1].opts, { subMchid: "", subMchidSource: "none" }, "直连模式不带 settle_info");
|
|
176
179
|
assert.strictEqual(db.prepare("SELECT ps_flag FROM agent_pay_sessions WHERE order_ref = ?").get(b.json.id).ps_flag, 0);
|
|
177
180
|
} finally { srv.close(); restoreEnv(env); }
|
|
178
181
|
}));
|
package/test-buyer-shop.js
CHANGED
|
@@ -87,12 +87,13 @@ test("GET /api/products: 只在架商品 + 契约字段 + utct 折元 (整数分
|
|
|
87
87
|
assert.ok(Array.isArray(list), "契约: 裸数组");
|
|
88
88
|
assert.strictEqual(list.length, 3, "draft 被过滤 (active 3 个)");
|
|
89
89
|
const p = list.find((x) => x.id === "p-active-1");
|
|
90
|
-
assert.deepStrictEqual(Object.keys(p).sort(), ["description", "id", "image_url", "name", "price", "stock"], "契约字段一字不差");
|
|
90
|
+
assert.deepStrictEqual(Object.keys(p).sort(), ["description", "id", "image_url", "name", "price", "product_type", "stock"], "契约字段一字不差");
|
|
91
91
|
assert.strictEqual(p.name, "测试商品A");
|
|
92
92
|
assert.strictEqual(p.description, "好用");
|
|
93
93
|
assert.strictEqual(p.price, 2.5, "2_500_000 utct / 1e6 = 2.5 元");
|
|
94
94
|
assert.strictEqual(p.image_url, "https://img.example.com/a.jpg");
|
|
95
95
|
assert.strictEqual(p.stock, 10);
|
|
96
|
+
assert.strictEqual(p.product_type, "physical", "A1: 无 product_type 列的老库默认 physical");
|
|
96
97
|
const noImg = list.find((x) => x.id === "p-nostock");
|
|
97
98
|
assert.strictEqual(noImg.image_url, "", "无图给空字符串");
|
|
98
99
|
assert.strictEqual(noImg.price, 1);
|
|
@@ -125,7 +126,7 @@ test("POST /api/orders: 正常下单 (mock code_url) + 价格以 DB 为权威 (
|
|
|
125
126
|
try {
|
|
126
127
|
const r = await fetch(base + "/api/orders", {
|
|
127
128
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
128
|
-
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_test001", amount: 0.01, price: 0 }), // 乱传金额无效
|
|
129
|
+
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_test001", shipping_address: "上海市浦东新区测试路1号", amount: 0.01, price: 0 }), // 乱传金额无效
|
|
129
130
|
});
|
|
130
131
|
const j = await r.json();
|
|
131
132
|
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
@@ -182,20 +183,20 @@ test("POST /api/orders 幂等: client_order_no 同号返原单; 无号 5 分钟
|
|
|
182
183
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
183
184
|
}).then((r) => r.json());
|
|
184
185
|
// ① client_order_no 幂等
|
|
185
|
-
const a = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1" });
|
|
186
|
-
const b = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1" });
|
|
186
|
+
const a = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1", shipping_address: "addr-1" });
|
|
187
|
+
const b = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-1", shipping_address: "addr-1" });
|
|
187
188
|
assert.strictEqual(b.id, a.id, "同 client_order_no 返原单");
|
|
188
189
|
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions").get().n, 1);
|
|
189
190
|
// 不同 client_order_no → 新单
|
|
190
|
-
const c = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-2" });
|
|
191
|
+
const c = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem1", client_order_no: "ORDER-X-2", shipping_address: "addr-1" });
|
|
191
192
|
assert.notStrictEqual(c.id, a.id);
|
|
192
193
|
// ② 无号窗口复用 (同买家同商品)
|
|
193
|
-
const d = await post({ product_id: "p-active-1", quantity: 3, buyer_card: "card_idem2" });
|
|
194
|
-
const e = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem2" });
|
|
194
|
+
const d = await post({ product_id: "p-active-1", quantity: 3, buyer_card: "card_idem2", shipping_address: "addr-2" });
|
|
195
|
+
const e = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem2", shipping_address: "addr-2" });
|
|
195
196
|
assert.strictEqual(e.id, d.id, "5 分钟窗口内复用未支付单");
|
|
196
197
|
assert.strictEqual(e.amount, 7.5, "复用的是原单金额 (qty=3)");
|
|
197
198
|
// 不同买家不复用
|
|
198
|
-
const f = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem3" });
|
|
199
|
+
const f = await post({ product_id: "p-active-1", quantity: 1, buyer_card: "card_idem3", shipping_address: "addr-3" });
|
|
199
200
|
assert.notStrictEqual(f.id, d.id);
|
|
200
201
|
} finally { srv.close(); }
|
|
201
202
|
});
|
|
@@ -205,7 +206,7 @@ test("POST /api/orders 限流: IP 维度 10 单/分钟, 第 11 单 429", async (
|
|
|
205
206
|
try {
|
|
206
207
|
const post = (card) => fetch(base + "/api/orders", {
|
|
207
208
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
208
|
-
body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: card }),
|
|
209
|
+
body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: card, shipping_address: "addr-rl" }),
|
|
209
210
|
});
|
|
210
211
|
for (let i = 0; i < 10; i++) {
|
|
211
212
|
const r = await post("card_rl_" + i); // 不同买家卡 → 每单独立 (不触发幂等)
|
|
@@ -226,12 +227,12 @@ test("GET /api/orders/:id: 状态映射 pending/paid/completed/failed + 404", as
|
|
|
226
227
|
try {
|
|
227
228
|
const cr = await fetch(base + "/api/orders", {
|
|
228
229
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
229
|
-
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_st1" }),
|
|
230
|
+
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_st1", shipping_address: "addr-st1" }),
|
|
230
231
|
});
|
|
231
232
|
const { id } = await cr.json();
|
|
232
233
|
const get = () => fetch(base + "/api/orders/" + id).then((r) => r.json());
|
|
233
234
|
let o = await get();
|
|
234
|
-
assert.deepStrictEqual(Object.keys(o).sort(), ["amount", "code_url", "id", "product_id", "product_name", "quantity", "status", "tx_hash"] /* 0.5.2 补 product_id (Pi 复核 P2) */, "契约字段一字不差");
|
|
235
|
+
assert.deepStrictEqual(Object.keys(o).sort(), ["amount", "code_url", "delivery_credential", "id", "product_id", "product_name", "quantity", "shipping_address", "status", "tx_hash"] /* 0.5.2 补 product_id (Pi 复核 P2); A1 补 shipping_address/delivery_credential */, "契约字段一字不差");
|
|
235
236
|
assert.strictEqual(o.status, "pending", "CREATED → pending");
|
|
236
237
|
assert.strictEqual(o.amount, 5);
|
|
237
238
|
assert.strictEqual(o.product_name, "测试商品A", "product_name 联 store_products.title");
|
|
@@ -295,7 +296,7 @@ test("wechat 模式: wxNativeOrder 注入捕获参数 (out_trade_no/金额分/
|
|
|
295
296
|
try {
|
|
296
297
|
const r = await fetch(base + "/api/orders", {
|
|
297
298
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
298
|
-
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_wx1" }),
|
|
299
|
+
body: JSON.stringify({ product_id: "p-active-1", quantity: 2, buyer_card: "card_wx1", shipping_address: "addr-wx1" }),
|
|
299
300
|
});
|
|
300
301
|
const j = await r.json();
|
|
301
302
|
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
@@ -319,7 +320,7 @@ test("wechat 模式: 微信下单失败 → 502, 不落单", async () => {
|
|
|
319
320
|
try {
|
|
320
321
|
const r = await fetch(base + "/api/orders", {
|
|
321
322
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
322
|
-
body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: "card_wx2" }),
|
|
323
|
+
body: JSON.stringify({ product_id: "p-active-1", quantity: 1, buyer_card: "card_wx2", shipping_address: "addr-wx2" }),
|
|
323
324
|
});
|
|
324
325
|
assert.strictEqual(r.status, 502);
|
|
325
326
|
const j = await r.json();
|
package/test-laneC.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane C 多商户路由 单测 (2026-10-03 kimi)
|
|
3
|
+
* 验证: ① 按商品卖家解析 sub_mchid ② 兜底回退 env 且有告警语义 ③ 费率按商户号解析
|
|
4
|
+
* 跑法: node test-laneC.js
|
|
5
|
+
*/
|
|
6
|
+
const sqlite3 = require("better-sqlite3");
|
|
7
|
+
const assert = require("assert");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const tmp = path.join(os.tmpdir(), "lanec-test-" + Date.now() + ".db");
|
|
13
|
+
const db = new sqlite3(tmp);
|
|
14
|
+
|
|
15
|
+
// 造最小 schema (与线上同结构)
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS sp_sub_merchants (sub_mchid TEXT, seller_address TEXT, shop_name TEXT, webhook_url TEXT, status TEXT, created_at INTEGER);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS sp_commission_rates (sub_mchid TEXT, rate_bp INTEGER);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS store_products (id TEXT, seller_address TEXT, title TEXT, price_utct INTEGER, stock INTEGER, status TEXT);
|
|
20
|
+
`);
|
|
21
|
+
db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1118152327", "tct1v75dSellerA", "港思恒", "", "active", 1);
|
|
22
|
+
db.prepare("INSERT INTO sp_sub_merchants VALUES (?,?,?,?,?,?)").run("1900000109", "tct1v75dSellerB", "第二商户", "", "active", 1);
|
|
23
|
+
db.prepare("INSERT INTO sp_commission_rates VALUES (?,?)").run("1900000109", 100); // 第二商户 1%
|
|
24
|
+
|
|
25
|
+
process.env.WXPAY_SUB_MCHID = "1118152327"; // 全局默认(单商户旧部署)
|
|
26
|
+
delete process.env.SP_COMMISSION_BP; // 走解析路径
|
|
27
|
+
|
|
28
|
+
// 载入被测函数 (从 buyer-shop.js 里抠出来, 避免 require 整个应用)
|
|
29
|
+
const src = fs.readFileSync(path.join(__dirname, "routes", "buyer-shop.js"), "utf8");
|
|
30
|
+
function extract(name) {
|
|
31
|
+
const i = src.indexOf("function " + name);
|
|
32
|
+
if (i < 0) throw new Error("找不到函数 " + name);
|
|
33
|
+
// 找到函数体结束
|
|
34
|
+
let depth = 0, started = false, j = i;
|
|
35
|
+
for (; j < src.length; j++) {
|
|
36
|
+
if (src[j] === "{") { depth++; started = true; }
|
|
37
|
+
else if (src[j] === "}") { depth--; if (started && depth === 0) { j++; break; } }
|
|
38
|
+
}
|
|
39
|
+
return src.slice(i, j);
|
|
40
|
+
}
|
|
41
|
+
global.hasTable = eval("(" + extract("hasTable") + ")");
|
|
42
|
+
const makeSubMchidResolver = eval("(" + extract("makeSubMchidResolver") + ")");
|
|
43
|
+
const RATE_CACHE_MS = 60 * 1000; // 与 buyer-shop.js 同值
|
|
44
|
+
const makeCommissionRateResolver = eval("(" + extract("makeCommissionRateResolver") + ")");
|
|
45
|
+
|
|
46
|
+
const resolveSubMchid = makeSubMchidResolver(db);
|
|
47
|
+
const resolveRateBp = makeCommissionRateResolver(db, null);
|
|
48
|
+
|
|
49
|
+
let pass = 0, fail = 0;
|
|
50
|
+
function check(name, got, want) {
|
|
51
|
+
const okv = JSON.stringify(got) === JSON.stringify(want);
|
|
52
|
+
console.log((okv ? " ✔ " : " ✘ ") + name + (okv ? "" : ` got=${JSON.stringify(got)} want=${JSON.stringify(want)}`));
|
|
53
|
+
okv ? pass++ : fail++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log("Lane C 多商户路由单测:");
|
|
57
|
+
|
|
58
|
+
// ① 已登记商户 → 走登记表
|
|
59
|
+
check("港思恒(1118152327) 解析", resolveSubMchid("tct1v75dSellerA").subMchid, "1118152327");
|
|
60
|
+
check("第二商户(1900000109) 解析", resolveSubMchid("tct1v75dSellerB").subMchid, "1900000109");
|
|
61
|
+
check("第二商户 source=registry", resolveSubMchid("tct1v75dSellerB").source, "registry");
|
|
62
|
+
|
|
63
|
+
// ② 未登记卖家 → 回落 env + source 标记(不静默)
|
|
64
|
+
const r3 = resolveSubMchid("tct1unknown");
|
|
65
|
+
check("未登记卖家回落 env", r3.subMchid, "1118152327");
|
|
66
|
+
check("未登记卖家 source=env-fallback", r3.source, "env-fallback");
|
|
67
|
+
|
|
68
|
+
// ③ 无卖家地址 → 也不静默
|
|
69
|
+
check("无 seller 回落 env", resolveSubMchid("").source, "env-fallback-no-seller");
|
|
70
|
+
|
|
71
|
+
// ④ 费率按传入商户号解析 (第二商户 1% = 100bp; 全局默认未登记 → 0)
|
|
72
|
+
resolveRateBp.cache = null;
|
|
73
|
+
(async () => {
|
|
74
|
+
const bp2 = await resolveRateBp("1900000109");
|
|
75
|
+
check("费率按第二商户解析 (1%=100bp)", bp2, 100);
|
|
76
|
+
const bp1 = await resolveRateBp("1118152327");
|
|
77
|
+
check("费率按港思恒解析 (未登记→0)", bp1, 0);
|
|
78
|
+
// 不传参 → 回落 env 商户号
|
|
79
|
+
const bp0 = await resolveRateBp();
|
|
80
|
+
check("费率不传参回落 env", bp0, 0);
|
|
81
|
+
|
|
82
|
+
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
83
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
84
|
+
process.exit(fail ? 1 : 0);
|
|
85
|
+
})();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-product-type.js — A1 商品类型驱动收货地址 离线单测 (2026-10-05 设计方案)
|
|
4
|
+
* node --test test-product-type.js
|
|
5
|
+
*
|
|
6
|
+
* 照 test-buyer-shop.js 模式: :memory: 库起 mock server, AGENT_PAY_MODE=mock 不碰真微信/真链。
|
|
7
|
+
* 覆盖设计方案 4 条验收:
|
|
8
|
+
* 1. physical 商品下单必须提供 shipping_address, 缺失 400 (SHIPPING_ADDRESS_REQUIRED)
|
|
9
|
+
* 2. virtual 商品下单不要地址, 返回 delivery_credential (tb-license-{order_ref}-{random4})
|
|
10
|
+
* 3. 向后兼容: 无 product_type 列的商品默认 physical, 要求地址
|
|
11
|
+
* 4. 老订单 (新列为空) 查询正常, 返回空串
|
|
12
|
+
*/
|
|
13
|
+
const test = require("node:test");
|
|
14
|
+
const assert = require("node:assert");
|
|
15
|
+
const express = require("express");
|
|
16
|
+
const Database = require("better-sqlite3");
|
|
17
|
+
|
|
18
|
+
process.env.AGENT_PAY_MODE = "mock";
|
|
19
|
+
|
|
20
|
+
const buyerShop = require("./routes/buyer-shop");
|
|
21
|
+
|
|
22
|
+
const NOW_MS = Date.now();
|
|
23
|
+
const ADDR = "上海市浦东新区测试路1号 张三收 13800000000";
|
|
24
|
+
|
|
25
|
+
/** 新 schema: store_products 含 product_type 列 (A1 迁移后) */
|
|
26
|
+
function seedProductsWithType(db) {
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE store_products (
|
|
29
|
+
id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
|
|
30
|
+
description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
|
|
31
|
+
stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
|
|
32
|
+
product_type TEXT NOT NULL DEFAULT 'physical',
|
|
33
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
`);
|
|
36
|
+
const ins = db.prepare(`INSERT INTO store_products (id, seller_address, title, price_utct, stock, status, product_type, created_at, updated_at)
|
|
37
|
+
VALUES (?,?,?,?,?,?,?,?,?)`);
|
|
38
|
+
ins.run("p-phys-1", "tct1seller", "实物商品", 2500000, 10, "active", "physical", NOW_MS, NOW_MS);
|
|
39
|
+
ins.run("p-virt-1", "tct1seller", "虚拟商品", 1000000, 99, "active", "virtual", NOW_MS, NOW_MS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 老 schema: store_products 无 product_type 列 (A1 迁移前的生产库形状, 同 test-buyer-shop.js) */
|
|
43
|
+
function seedProductsLegacy(db) {
|
|
44
|
+
db.exec(`
|
|
45
|
+
CREATE TABLE store_products (
|
|
46
|
+
id TEXT PRIMARY KEY, seller_address TEXT NOT NULL, title TEXT NOT NULL,
|
|
47
|
+
description TEXT NOT NULL DEFAULT '', price_utct INTEGER NOT NULL,
|
|
48
|
+
stock INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft',
|
|
49
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
50
|
+
);
|
|
51
|
+
`);
|
|
52
|
+
db.prepare(`INSERT INTO store_products (id, seller_address, title, price_utct, stock, status, created_at, updated_at)
|
|
53
|
+
VALUES (?,?,?,?,?,?,?,?)`)
|
|
54
|
+
.run("p-legacy-1", "tct1seller", "老商品", 2500000, 10, "active", NOW_MS, NOW_MS);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function startServer(seed) {
|
|
58
|
+
const db = new Database(":memory:");
|
|
59
|
+
seed(db);
|
|
60
|
+
const app = express();
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use("/api", buyerShop.createBuyerShopRouter({ db }));
|
|
63
|
+
const srv = app.listen(0);
|
|
64
|
+
await new Promise((r) => srv.once("listening", r));
|
|
65
|
+
return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function post(base, body) {
|
|
69
|
+
return fetch(base + "/api/orders", {
|
|
70
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("① physical 商品: 缺 shipping_address → 400 SHIPPING_ADDRESS_REQUIRED; 提供后下单成功且落库/返回地址", async () => {
|
|
75
|
+
const { db, srv, base } = await startServer(seedProductsWithType);
|
|
76
|
+
try {
|
|
77
|
+
// 缺地址 → 400
|
|
78
|
+
let r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1" });
|
|
79
|
+
assert.strictEqual(r.status, 400);
|
|
80
|
+
let j = await r.json();
|
|
81
|
+
assert.strictEqual(j.error, "SHIPPING_ADDRESS_REQUIRED");
|
|
82
|
+
assert.ok(j.message, "{error, message} 结构");
|
|
83
|
+
// 空白地址也视为缺失
|
|
84
|
+
r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1", shipping_address: " " });
|
|
85
|
+
assert.strictEqual(r.status, 400);
|
|
86
|
+
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM agent_pay_sessions").get().n, 0, "校验失败不落单");
|
|
87
|
+
// 提供地址 → 200, 地址落库且随单返回
|
|
88
|
+
r = await post(base, { product_id: "p-phys-1", quantity: 1, buyer_card: "card_pt1", shipping_address: ADDR });
|
|
89
|
+
j = await r.json();
|
|
90
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
91
|
+
assert.match(j.id, /^tb-[0-9a-f]{16}$/);
|
|
92
|
+
assert.strictEqual(j.shipping_address, ADDR);
|
|
93
|
+
assert.strictEqual(j.delivery_credential, "", "实物单无交付凭据");
|
|
94
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(j.id);
|
|
95
|
+
assert.strictEqual(row.shipping_address, ADDR, "地址落 agent_pay_sessions");
|
|
96
|
+
assert.strictEqual(row.delivery_credential, "");
|
|
97
|
+
// 查单也带地址
|
|
98
|
+
const o = await (await fetch(base + "/api/orders/" + j.id)).json();
|
|
99
|
+
assert.strictEqual(o.shipping_address, ADDR);
|
|
100
|
+
} finally { srv.close(); }
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("② virtual 商品: 不要地址, 返回 delivery_credential (license 格式), 幂等重放凭据不变", async () => {
|
|
104
|
+
const { db, srv, base } = await startServer(seedProductsWithType);
|
|
105
|
+
try {
|
|
106
|
+
let r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
|
|
107
|
+
const j = await r.json();
|
|
108
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
109
|
+
assert.match(j.delivery_credential, /^tb-license-tb-[0-9a-f]{16}-[0-9a-f]{4}$/, "license 格式 tb-license-{order_ref}-{random4}");
|
|
110
|
+
assert.ok(j.delivery_credential.includes(j.id), "凭据与订单绑定");
|
|
111
|
+
assert.strictEqual(j.shipping_address, "", "虚拟单地址为空");
|
|
112
|
+
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(j.id);
|
|
113
|
+
assert.strictEqual(row.delivery_credential, j.delivery_credential, "凭据落库");
|
|
114
|
+
// 幂等重放 → 同一凭据 (不重新生成)
|
|
115
|
+
r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
|
|
116
|
+
const j2 = await r.json();
|
|
117
|
+
assert.strictEqual(j2.id, j.id);
|
|
118
|
+
assert.strictEqual(j2.delivery_credential, j.delivery_credential);
|
|
119
|
+
// 查单带凭据
|
|
120
|
+
const o = await (await fetch(base + "/api/orders/" + j.id)).json();
|
|
121
|
+
assert.strictEqual(o.delivery_credential, j.delivery_credential);
|
|
122
|
+
// 两个虚拟单凭据不同
|
|
123
|
+
const j3 = await (await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-2" })).json();
|
|
124
|
+
assert.notStrictEqual(j3.delivery_credential, j.delivery_credential);
|
|
125
|
+
} finally { srv.close(); }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("②b 商品面: GET /products 返回 product_type (virtual/physical 如实; 列表含类型)", async () => {
|
|
129
|
+
const { srv, base } = await startServer(seedProductsWithType);
|
|
130
|
+
try {
|
|
131
|
+
const list = await (await fetch(base + "/api/products")).json();
|
|
132
|
+
assert.strictEqual(list.find((x) => x.id === "p-phys-1").product_type, "physical");
|
|
133
|
+
assert.strictEqual(list.find((x) => x.id === "p-virt-1").product_type, "virtual");
|
|
134
|
+
const one = await (await fetch(base + "/api/products/p-virt-1")).json();
|
|
135
|
+
assert.strictEqual(one.product_type, "virtual");
|
|
136
|
+
} finally { srv.close(); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("③ 向后兼容: 无 product_type 列的老商品默认 physical, 下单要求地址", async () => {
|
|
140
|
+
const { srv, base } = await startServer(seedProductsLegacy);
|
|
141
|
+
try {
|
|
142
|
+
// 商品面默认 physical
|
|
143
|
+
const p = await (await fetch(base + "/api/products/p-legacy-1")).json();
|
|
144
|
+
assert.strictEqual(p.product_type, "physical", "无该列 → 默认 physical");
|
|
145
|
+
// 缺地址 → 400
|
|
146
|
+
let r = await post(base, { product_id: "p-legacy-1", quantity: 1, buyer_card: "card_pt3" });
|
|
147
|
+
assert.strictEqual(r.status, 400);
|
|
148
|
+
assert.strictEqual((await r.json()).error, "SHIPPING_ADDRESS_REQUIRED");
|
|
149
|
+
// 带地址 → 200
|
|
150
|
+
r = await post(base, { product_id: "p-legacy-1", quantity: 1, buyer_card: "card_pt3", shipping_address: ADDR });
|
|
151
|
+
assert.strictEqual(r.status, 200, JSON.stringify(await r.json()));
|
|
152
|
+
} finally { srv.close(); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("④ 老订单 (新列为空) 查询正常, shipping_address / delivery_credential 返回空串", async () => {
|
|
156
|
+
const { db, srv, base } = await startServer(seedProductsWithType);
|
|
157
|
+
try {
|
|
158
|
+
// 直插一行"迁移前形状"的老订单 (不指定新列 → DEFAULT '')
|
|
159
|
+
const now = Math.floor(Date.now() / 1000);
|
|
160
|
+
db.prepare(`INSERT INTO agent_pay_sessions
|
|
161
|
+
(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, sub_mchid)
|
|
162
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
163
|
+
.run("buyer:card_old:tb-00000000000000ff", "p-phys-1", 1, 250, "CNY", "buyer:card_old", "tb-00000000000000ff",
|
|
164
|
+
"x".repeat(64), "y".repeat(64), "PAID", "", "", now + 900, now, now, 0, "");
|
|
165
|
+
const r = await fetch(base + "/api/orders/tb-00000000000000ff");
|
|
166
|
+
assert.strictEqual(r.status, 200);
|
|
167
|
+
const o = await r.json();
|
|
168
|
+
assert.strictEqual(o.shipping_address, "", "老订单地址空串");
|
|
169
|
+
assert.strictEqual(o.delivery_credential, "", "老订单凭据空串");
|
|
170
|
+
assert.strictEqual(o.status, "paid", "状态机不受影响");
|
|
171
|
+
assert.strictEqual(o.amount, 2.5);
|
|
172
|
+
} finally { srv.close(); }
|
|
173
|
+
});
|