trustbase-seller-backend 0.4.3 → 0.5.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/audit-page-scripts.js +2 -0
- package/audit-prev.js +0 -0
- package/package.json +1 -1
- package/routes/sp-admin.js +557 -0
- package/routes/sp-applyment.js +11 -0
- package/run-unified.js +5 -0
- package/test-sp-admin.js +289 -0
- package/test-sp-applyment.js +3 -0
package/audit-page-scripts.js
CHANGED
|
@@ -34,6 +34,7 @@ function compileScripts(name, html) {
|
|
|
34
34
|
const app = express();
|
|
35
35
|
app.use(express.json({ limit: "20mb" }));
|
|
36
36
|
app.use("/api/sp", require("./routes/sp-applyment").createSpApplymentRouter({ db }));
|
|
37
|
+
app.use("/api/sp", require("./routes/sp-admin").createSpAdminRouter({ db }));
|
|
37
38
|
app.use("/m2m", require("./routes/m2m-viewer").createM2MViewerRouter({ db }));
|
|
38
39
|
const srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
|
|
39
40
|
const base = `http://127.0.0.1:${srv.address().port}`;
|
|
@@ -42,6 +43,7 @@ function compileScripts(name, html) {
|
|
|
42
43
|
for (const [name, url] of [
|
|
43
44
|
["sp-config", base + "/api/sp/config"],
|
|
44
45
|
["sp-apply", base + "/api/sp/apply"],
|
|
46
|
+
["sp-admin", base + "/api/sp/admin"],
|
|
45
47
|
["m2m-viewer", base + "/m2m"],
|
|
46
48
|
]) {
|
|
47
49
|
try {
|
package/audit-prev.js
ADDED
|
File without changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sp-admin.js — SP Admin 服务商运营台 (2026-10-06, v0.5.0)
|
|
4
|
+
*
|
|
5
|
+
* 背景: 首个真实特约商户 (南京港思恒 sub_mchid=1118152327) 进件→签约→授权→收款
|
|
6
|
+
* 全链路打通后复盘发现, 商户入驻后的"授权邀请/AppID绑定/首单验证"全是隐藏步骤。
|
|
7
|
+
* 本运营台把这些运营动作产品化: 一屏多卡片 (风格与 /sp/config 一致)。
|
|
8
|
+
*
|
|
9
|
+
* 页面: GET /sp/admin (Accept: text/html → 页面, 无 key 也 200, key 页面内输入)
|
|
10
|
+
* API (全部 admin 鉴权 x-sp-admin-key, 同 sp-commission 口径 — env 未配置全拒 401):
|
|
11
|
+
* GET /admin/overview 概览统计 (商户数/交易笔数总额/待办数/近7天)
|
|
12
|
+
* GET /admin/merchants 商户列表 (联 sp_applyments 状态 + 首单标记 + checklist)
|
|
13
|
+
* PUT /admin/checklist 入驻待办人工勾选持久化 {sub_mchid, item, done}
|
|
14
|
+
* GET /admin/facts?limit=100 交易记录 (sp_payment_facts)
|
|
15
|
+
* GET /admin/profitsharings 分账 (sp_ps_receivers + ps_status 非空的分账记录)
|
|
16
|
+
* GET /admin/applyments 进件通道列表 (sp_applyments)
|
|
17
|
+
*
|
|
18
|
+
* 入驻待办清单 (U1 核心): 5 项 — 前 4 项微信侧无 API 可查 (服务商平台 UI 操作),
|
|
19
|
+
* 人工确认勾选持久化到 sp_admin_checklist (sub_mchid+item 主键, done_at);
|
|
20
|
+
* 第 5 项"首单验证"自动判定 (该 sub_mchid 在 sp_payment_facts 有记录即完成),
|
|
21
|
+
* 不可手改 (PUT first_order → 400)。
|
|
22
|
+
*
|
|
23
|
+
* 建表: 路由工厂函数里直接 CREATE TABLE IF NOT EXISTS sp_admin_checklist
|
|
24
|
+
* (0.4.3 教训: 写路径不能依赖读路径先建表)。其他表 (sp_sub_merchants/
|
|
25
|
+
* sp_applyments/sp_payment_facts/sp_ps_receivers) 归各自模块所有, 本模块只读,
|
|
26
|
+
* 缺表时容忍返回空 (运营台不应因别的模块没挂载而 500)。
|
|
27
|
+
*
|
|
28
|
+
* 金额一律整数分; SQL 全 prepared statement 参数绑定; 不回显任何敏感配置。
|
|
29
|
+
*/
|
|
30
|
+
const express = require("express");
|
|
31
|
+
const chainWrites = require("./chain-writes");
|
|
32
|
+
|
|
33
|
+
const HttpError = chainWrites.HttpError;
|
|
34
|
+
|
|
35
|
+
function nowSec() { return Math.floor(Date.now() / 1000); }
|
|
36
|
+
|
|
37
|
+
/** 入驻待办清单: 人工确认 4 项 (微信侧状态平台 UI 操作, 无 API 可查) + 首单自动判定 */
|
|
38
|
+
const CHECKLIST_ITEMS = [
|
|
39
|
+
{ item: "native_auth", label: "Native 授权邀请",
|
|
40
|
+
guide: "服务商平台→产品中心→特约商户授权产品→服务商Native支付→发起邀请;商户在商家助手确认" },
|
|
41
|
+
{ item: "profitsharing_auth", label: "分账授权邀请",
|
|
42
|
+
guide: "电商服务商场景:买家付款后,按约定比例将技术服务费从交易款中实时分账至服务商账户,剩余资金结算给特约商户" },
|
|
43
|
+
{ item: "payment_code_auth", label: "付款码邀请(可选)",
|
|
44
|
+
guide: "服务商平台→产品中心→特约商户授权产品→服务商付款码支付→发起邀请;商户在商家助手确认(可选,线下扫码场景才需要)" },
|
|
45
|
+
{ item: "appid_bind", label: "AppID 绑定",
|
|
46
|
+
guide: "服务商平台→产品中心→AppID账号管理→关联AppID→管理员在公众平台/开放平台确认;注意 sp_appid 必须绑在服务商号名下" },
|
|
47
|
+
];
|
|
48
|
+
const MANUAL_ITEMS = CHECKLIST_ITEMS.map((i) => i.item);
|
|
49
|
+
const FIRST_ORDER_ITEM = "first_order"; // 自动判定项 (sp_payment_facts 有记录), 不可手改
|
|
50
|
+
const ALL_ITEMS = MANUAL_ITEMS.concat([FIRST_ORDER_ITEM]);
|
|
51
|
+
|
|
52
|
+
/** 本模块自有的表: 勾选持久化 (sub_mchid+item 主键; done=false 时删行) */
|
|
53
|
+
function ensureSpAdminTables(db) {
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS sp_admin_checklist (
|
|
56
|
+
sub_mchid TEXT NOT NULL, -- 特约商户号
|
|
57
|
+
item TEXT NOT NULL, -- 待办项 key (native_auth/profitsharing_auth/payment_code_auth/appid_bind)
|
|
58
|
+
done_at INTEGER NOT NULL, -- 人工确认时间 (秒)
|
|
59
|
+
PRIMARY KEY (sub_mchid, item)
|
|
60
|
+
);
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 别的模块拥有的表可能未建 (模块未挂载/全新库) — 只读端点容忍缺表返回空 */
|
|
65
|
+
function hasTable(db, name) {
|
|
66
|
+
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 商户全集: sp_sub_merchants ∪ sp_applyments.sub_mchid (进件通过但路由表漏写的兜底) */
|
|
70
|
+
function listMerchants(db) {
|
|
71
|
+
const byMchid = new Map();
|
|
72
|
+
if (hasTable(db, "sp_sub_merchants")) {
|
|
73
|
+
for (const r of db.prepare("SELECT sub_mchid, seller_address, shop_name, status, created_at FROM sp_sub_merchants").all()) {
|
|
74
|
+
byMchid.set(r.sub_mchid, {
|
|
75
|
+
sub_mchid: r.sub_mchid, seller_address: r.seller_address || "",
|
|
76
|
+
shop_name: r.shop_name || "", status: r.status || "", created_at: r.created_at,
|
|
77
|
+
applyment_status: "", applyment_state_msg: "", business_code: "",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (hasTable(db, "sp_applyments")) {
|
|
82
|
+
// 每个 sub_mchid 取最新一条进件单 (状态随刷新更新)
|
|
83
|
+
const rows = db.prepare("SELECT sub_mchid, business_code, status, state_msg, updated_at FROM sp_applyments WHERE sub_mchid <> '' ORDER BY updated_at DESC").all();
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
const m = byMchid.get(r.sub_mchid);
|
|
86
|
+
if (m) {
|
|
87
|
+
if (!m.applyment_status) { // 已按 updated_at DESC 排序, 首个即最新
|
|
88
|
+
m.applyment_status = r.status || "";
|
|
89
|
+
m.applyment_state_msg = r.state_msg || "";
|
|
90
|
+
m.business_code = r.business_code || "";
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
byMchid.set(r.sub_mchid, {
|
|
94
|
+
sub_mchid: r.sub_mchid, seller_address: "", shop_name: "", status: "", created_at: r.updated_at,
|
|
95
|
+
applyment_status: r.status || "", applyment_state_msg: r.state_msg || "", business_code: r.business_code || "",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 首单标记 + checklist 勾选状态
|
|
101
|
+
const hasFacts = hasTable(db, "sp_payment_facts");
|
|
102
|
+
const firstOrderStmt = hasFacts ? db.prepare("SELECT 1 FROM sp_payment_facts WHERE sub_mchid = ? LIMIT 1") : null;
|
|
103
|
+
const clStmt = db.prepare("SELECT item, done_at FROM sp_admin_checklist WHERE sub_mchid = ?");
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const m of byMchid.values()) {
|
|
106
|
+
const checklist = {};
|
|
107
|
+
for (const it of MANUAL_ITEMS) checklist[it] = null;
|
|
108
|
+
for (const c of clStmt.all(m.sub_mchid)) if (c.item in checklist) checklist[c.item] = c.done_at;
|
|
109
|
+
checklist[FIRST_ORDER_ITEM] = firstOrderStmt && firstOrderStmt.get(m.sub_mchid) ? -1 : null; // -1 = 自动完成 (无人工 done_at)
|
|
110
|
+
const todoPending = MANUAL_ITEMS.filter((it) => !checklist[it]).length + (checklist[FIRST_ORDER_ITEM] ? 0 : 1);
|
|
111
|
+
out.push({ ...m, first_order: !!checklist[FIRST_ORDER_ITEM], checklist, todo_pending: todoPending });
|
|
112
|
+
}
|
|
113
|
+
out.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 交易行 → 页面友好结构 (链上状态归一: disabled/pending/chained) */
|
|
118
|
+
function factToJson(r) {
|
|
119
|
+
const chainStatus = r.chain_txhash === "disabled" ? "disabled" : (r.chain_txhash ? "chained" : "pending");
|
|
120
|
+
return {
|
|
121
|
+
txn_id: r.txn_id, sub_mchid: r.sub_mchid, out_trade_no: r.out_trade_no,
|
|
122
|
+
amount: r.amount, amount_yuan: (r.amount / 100).toFixed(2),
|
|
123
|
+
chain_txhash: r.chain_txhash || "", chain_status: chainStatus,
|
|
124
|
+
forward_status: r.forward_status || "",
|
|
125
|
+
ps_out_order_no: r.ps_out_order_no || "", ps_status: r.ps_status || "",
|
|
126
|
+
ps_amount: r.ps_amount == null ? null : r.ps_amount,
|
|
127
|
+
ps_at: r.ps_at == null ? null : r.ps_at,
|
|
128
|
+
created_at: r.created_at,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const ADMIN_PAGE_HTML = `<!DOCTYPE html>
|
|
133
|
+
<html lang="zh-CN">
|
|
134
|
+
<head>
|
|
135
|
+
<meta charset="UTF-8">
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
137
|
+
<title>SP 运营台 · TrustBase</title>
|
|
138
|
+
<style>
|
|
139
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
140
|
+
body{background:#F7F3EB;color:#33302B;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;line-height:1.7}
|
|
141
|
+
.wrap{max-width:860px;margin:0 auto;padding:20px 16px 60px}
|
|
142
|
+
h1{font-size:20px;margin-bottom:4px}
|
|
143
|
+
.sub{font-size:13px;color:#8A857B;margin-bottom:16px}
|
|
144
|
+
.hd{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
145
|
+
.hd .upbtn{margin-top:6px}
|
|
146
|
+
.box{background:#FCFAF4;border:1px solid #EAE3D6;border-radius:14px;padding:18px;margin-bottom:14px}
|
|
147
|
+
.box h3{font-size:15px;color:#C4633C;margin-bottom:10px}
|
|
148
|
+
label{display:block;font-size:13px;color:#6B655B;margin:10px 0 4px}
|
|
149
|
+
input,select{width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff}
|
|
150
|
+
input:focus,select:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px rgba(196,99,60,.15)}
|
|
151
|
+
.btn{display:inline-block;background:#C4633C;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:15px;cursor:pointer;margin-top:12px}
|
|
152
|
+
.btn.ghost{background:#8A857B;margin-left:8px}
|
|
153
|
+
.upbtn{display:inline-block;background:#fff;border:1px solid #C4633C;color:#C4633C;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;margin:2px}
|
|
154
|
+
.msg{font-size:13px;margin-top:8px}
|
|
155
|
+
.msg.err{color:#DC2626}.msg.ok{color:#16A34A}
|
|
156
|
+
.cur{font-size:12px;color:#8A857B;word-break:break-all}
|
|
157
|
+
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:10px}
|
|
158
|
+
.stat{border:1px solid #EAE3D6;border-radius:10px;background:#FFFDF9;padding:10px;text-align:center}
|
|
159
|
+
.stat .v{font-size:20px;font-weight:700;color:#C4633C}
|
|
160
|
+
.stat .k{font-size:12px;color:#8A857B}
|
|
161
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
162
|
+
th,td{padding:6px 8px;border-bottom:1px solid #EAE3D6;text-align:left;vertical-align:top}
|
|
163
|
+
th{color:#6B655B;font-weight:600;white-space:nowrap}
|
|
164
|
+
td{word-break:break-all}
|
|
165
|
+
.tbl-wrap{overflow-x:auto}
|
|
166
|
+
.mch{border:1px solid #EAE3D6;border-radius:10px;background:#FFFDF9;padding:10px 12px;margin-bottom:8px}
|
|
167
|
+
.mch-hd{display:flex;justify-content:space-between;align-items:center;gap:10px;cursor:pointer}
|
|
168
|
+
.mch-hd .t{font-size:14px;font-weight:600}
|
|
169
|
+
.tag{font-size:11px;border:1px solid #D4CDBF;border-radius:999px;padding:1px 8px;color:#6B655B;margin-left:4px;white-space:nowrap}
|
|
170
|
+
.tag.ok{background:#F0FDF4;border-color:#BBF7D0;color:#166534}
|
|
171
|
+
.tag.warn{background:#FFFBEB;border-color:#FDE68A;color:#92400E}
|
|
172
|
+
.ck{margin-top:8px;border-top:1px dashed #EAE3D6;padding-top:8px}
|
|
173
|
+
.ck-row{display:flex;align-items:flex-start;gap:8px;padding:4px 0;font-size:13px}
|
|
174
|
+
.ck-row input{width:auto;margin-top:5px}
|
|
175
|
+
.ck-row .lb{flex:1}
|
|
176
|
+
.ck-row .gd{font-size:12px;color:#8A857B}
|
|
177
|
+
#toast{position:fixed;top:16px;right:16px;z-index:99;display:flex;flex-direction:column;gap:8px;pointer-events:none}
|
|
178
|
+
.toast{pointer-events:auto;border-radius:10px;padding:12px 18px;font-size:14px;box-shadow:0 4px 16px rgba(51,48,43,.12);animation:tin .18s ease-out}
|
|
179
|
+
.toast.ok{background:#F0FDF4;border:1px solid #BBF7D0;color:#166534}
|
|
180
|
+
.toast.err{background:#FEF2F2;border:1px solid #FCA5A5;color:#991B1B}
|
|
181
|
+
@keyframes tin{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
|
|
182
|
+
@media (max-width:640px){.stats{grid-template-columns:repeat(2,1fr)}}
|
|
183
|
+
</style>
|
|
184
|
+
</head>
|
|
185
|
+
<body>
|
|
186
|
+
<div class="wrap">
|
|
187
|
+
<div class="hd"><div>
|
|
188
|
+
<h1>SP 运营台</h1>
|
|
189
|
+
<div class="sub">TrustBase 服务商运营台 · 商户/交易/费率/分账/进件 一屏管理</div>
|
|
190
|
+
</div><button class="upbtn" onclick="logout()" title="清除本机保存的管理密钥,下次进入需重新输入">退出登录</button></div>
|
|
191
|
+
|
|
192
|
+
<div id="toast"></div>
|
|
193
|
+
|
|
194
|
+
<div class="box">
|
|
195
|
+
<h3>① 概览</h3>
|
|
196
|
+
<div class="stats" id="ov-stats"></div>
|
|
197
|
+
<div class="cur" id="ov-7d"></div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="box">
|
|
201
|
+
<h3>② 特约商户 (点商户行展开入驻待办清单)</h3>
|
|
202
|
+
<div id="mch-list"></div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="box">
|
|
206
|
+
<h3>③ 交易记录 <span class="cur">(sp_payment_facts, 最近 100 条)</span></h3>
|
|
207
|
+
<div class="tbl-wrap"><table>
|
|
208
|
+
<thead><tr><th>时间</th><th>商户</th><th>金额</th><th>单号</th><th>链上状态</th><th>分账状态</th></tr></thead>
|
|
209
|
+
<tbody id="facts-body"></tbody>
|
|
210
|
+
</table></div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div class="box">
|
|
214
|
+
<h3>④ 抽佣费率 <span class="cur">(官方政策: 默认 0 零抽成, 30% 硬顶; bp = 万分比, 100 = 1.00%)</span></h3>
|
|
215
|
+
<div class="tbl-wrap"><table>
|
|
216
|
+
<thead><tr><th>sub_mchid</th><th>费率</th><th>更新人</th><th>更新时间</th></tr></thead>
|
|
217
|
+
<tbody id="rates-body"></tbody>
|
|
218
|
+
</table></div>
|
|
219
|
+
<label>sub_mchid (__default__ = 公示默认费率)</label><input id="rate_mchid" placeholder="如: 1118152327">
|
|
220
|
+
<label>rate_bp (0..3000)</label><input id="rate_bp" type="number" min="0" max="3000">
|
|
221
|
+
<button class="btn" onclick="saveRate()">登记/更新费率</button>
|
|
222
|
+
<div class="msg" id="rate-msg"></div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div class="box">
|
|
226
|
+
<h3>⑤ 分账</h3>
|
|
227
|
+
<h3 style="font-size:13px;color:#6B655B">接收方 (sp_ps_receivers)</h3>
|
|
228
|
+
<div class="tbl-wrap"><table>
|
|
229
|
+
<thead><tr><th>account</th><th>注册时间</th><th>mock</th></tr></thead>
|
|
230
|
+
<tbody id="ps-recv-body"></tbody>
|
|
231
|
+
</table></div>
|
|
232
|
+
<h3 style="font-size:13px;color:#6B655B;margin-top:12px">分账记录 (ps_status 非空)</h3>
|
|
233
|
+
<div class="tbl-wrap"><table>
|
|
234
|
+
<thead><tr><th>时间</th><th>商户</th><th>分账单号</th><th>分账金额</th><th>状态</th></tr></thead>
|
|
235
|
+
<tbody id="ps-body"></tbody>
|
|
236
|
+
</table></div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div class="box">
|
|
240
|
+
<h3>⑥ 进件通道 <span class="cur">(sp_applyments)</span></h3>
|
|
241
|
+
<div class="tbl-wrap"><table>
|
|
242
|
+
<thead><tr><th>business_code</th><th>applyment_id</th><th>状态</th><th>说明</th><th>操作</th></tr></thead>
|
|
243
|
+
<tbody id="apply-body"></tbody>
|
|
244
|
+
</table></div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div class="box">
|
|
248
|
+
<h3>⑦ 应用配置</h3>
|
|
249
|
+
<div class="cur">应用/名片/素材配置在独立配置页维护, 不在此重复:</div>
|
|
250
|
+
<a class="upbtn" href="/sp/config" target="_blank">打开 /sp/config 服务商配置页 →</a>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<script>
|
|
255
|
+
var CHECKLIST_DEFS = [
|
|
256
|
+
{item:'native_auth', label:'Native 授权邀请', guide:'服务商平台→产品中心→特约商户授权产品→服务商Native支付→发起邀请;商户在商家助手确认'},
|
|
257
|
+
{item:'profitsharing_auth', label:'分账授权邀请', guide:'电商服务商场景:买家付款后,按约定比例将技术服务费从交易款中实时分账至服务商账户,剩余资金结算给特约商户'},
|
|
258
|
+
{item:'payment_code_auth', label:'付款码邀请(可选)', guide:'服务商平台→产品中心→特约商户授权产品→服务商付款码支付→发起邀请;商户在商家助手确认(可选,线下扫码场景才需要)'},
|
|
259
|
+
{item:'appid_bind', label:'AppID 绑定', guide:'服务商平台→产品中心→AppID账号管理→关联AppID→管理员在公众平台/开放平台确认;注意 sp_appid 必须绑在服务商号名下'},
|
|
260
|
+
{item:'first_order', label:'首单验证(自动)', guide:'该商户产生第一笔真实收款后自动打勾,不可手改'}
|
|
261
|
+
];
|
|
262
|
+
function adminKey(){
|
|
263
|
+
var k=localStorage.getItem('spAdminKey')||'';
|
|
264
|
+
if(!k){k=prompt('请输入管理密钥 (x-sp-admin-key)')||''; if(k)localStorage.setItem('spAdminKey',k);}
|
|
265
|
+
return k;
|
|
266
|
+
}
|
|
267
|
+
function api(path,opts){
|
|
268
|
+
opts=opts||{};
|
|
269
|
+
opts.headers=Object.assign({'Content-Type':'application/json','Accept':'application/json','x-sp-admin-key':adminKey()},opts.headers||{});
|
|
270
|
+
return fetch(path,opts).then(function(r){
|
|
271
|
+
if(r.status===401){localStorage.removeItem('spAdminKey');}
|
|
272
|
+
return r.json().then(function(j){return{status:r.status,json:j};});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<');}
|
|
276
|
+
function fmtTime(ts){if(!ts)return '-';var d=new Date(ts*1000);function p(n){return (n<10?'0':'')+n;}return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes());}
|
|
277
|
+
function showToast(ok,text){
|
|
278
|
+
var box=document.getElementById('toast');
|
|
279
|
+
var t=document.createElement('div');
|
|
280
|
+
t.className='toast '+(ok?'ok':'err');
|
|
281
|
+
t.textContent=text;
|
|
282
|
+
box.appendChild(t);
|
|
283
|
+
setTimeout(function(){t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(function(){t.remove();},300);},2500);
|
|
284
|
+
}
|
|
285
|
+
function logout(){
|
|
286
|
+
localStorage.removeItem('spAdminKey');
|
|
287
|
+
showToast(true,'已退出,即将重新验证密钥');
|
|
288
|
+
setTimeout(function(){location.reload();},700);
|
|
289
|
+
}
|
|
290
|
+
function copyText(text,btn){
|
|
291
|
+
function done(){showToast(true,'✓ 指引文案已复制,可发给商户');}
|
|
292
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(done,function(){fallback();});}
|
|
293
|
+
else fallback();
|
|
294
|
+
function fallback(){
|
|
295
|
+
var ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();
|
|
296
|
+
try{document.execCommand('copy');done();}catch(e){showToast(false,'复制失败,请手动选择复制');}
|
|
297
|
+
ta.remove();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderOverview(o){
|
|
302
|
+
document.getElementById('ov-stats').innerHTML=
|
|
303
|
+
'<div class="stat"><div class="v">'+o.merchants+'</div><div class="k">特约商户</div></div>'
|
|
304
|
+
+'<div class="stat"><div class="v">'+o.txn_count+'</div><div class="k">交易笔数</div></div>'
|
|
305
|
+
+'<div class="stat"><div class="v">¥'+o.total_amount_yuan+'</div><div class="k">交易总额</div></div>'
|
|
306
|
+
+'<div class="stat"><div class="v">'+o.todo_pending+'</div><div class="k">待办事项</div></div>';
|
|
307
|
+
var d7=(o.last7d||[]).map(function(d){return d.day+': '+d.count+'笔 ¥'+(d.amount/100).toFixed(2);});
|
|
308
|
+
document.getElementById('ov-7d').textContent='近 7 天交易: '+(d7.length?d7.join(' · '):'无');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderMerchants(list){
|
|
312
|
+
var el=document.getElementById('mch-list');
|
|
313
|
+
if(!list.length){el.innerHTML='<div class="cur">还没有特约商户, 先去 /sp/apply 提交进件</div>';return;}
|
|
314
|
+
el.innerHTML=list.map(function(m,idx){
|
|
315
|
+
var ck=CHECKLIST_DEFS.map(function(def){
|
|
316
|
+
var doneAt=m.checklist?m.checklist[def.item]:null;
|
|
317
|
+
var done=!!doneAt;
|
|
318
|
+
var auto=def.item==='first_order';
|
|
319
|
+
return '<div class="ck-row">'
|
|
320
|
+
+'<input type="checkbox" '+(done?'checked':'')+(auto?' disabled title="自动判定,不可手改"':'')
|
|
321
|
+
+' onchange="toggleCheck('+idx+',\\''+def.item+'\\',this.checked,this)">'
|
|
322
|
+
+'<div class="lb"><div>'+esc(def.label)+(done?' <span class="tag ok">✓ 已完成'+(auto?'(自动)':' '+fmtTime(doneAt))+'</span>':' <span class="tag warn">待办</span>')+'</div>'
|
|
323
|
+
+'<div class="gd">'+esc(def.guide)+' <button class="upbtn" onclick="copyGuide('+idx+',\\''+def.item+'\\')">复制指引文案</button></div>'
|
|
324
|
+
+'</div>';
|
|
325
|
+
}).join('');
|
|
326
|
+
return '<div class="mch">'
|
|
327
|
+
+'<div class="mch-hd" onclick="var c=document.getElementById(\\'ck-'+idx+'\\');c.style.display=c.style.display===\\'none\\'?\\'\\':\\'none\\'">'
|
|
328
|
+
+'<div class="t">'+esc(m.shop_name||'(未命名商户)')+' <span class="cur">'+esc(m.sub_mchid)+'</span></div>'
|
|
329
|
+
+'<div>'
|
|
330
|
+
+(m.first_order?'<span class="tag ok">✓ 已首单</span>':'<span class="tag warn">未首单</span>')
|
|
331
|
+
+(m.status?'<span class="tag">'+esc(m.status)+'</span>':'')
|
|
332
|
+
+(m.applyment_status?'<span class="tag">进件 '+esc(m.applyment_status)+'</span>':'')
|
|
333
|
+
+(m.todo_pending?'<span class="tag warn">待办 '+m.todo_pending+'</span>':'<span class="tag ok">待办清零</span>')
|
|
334
|
+
+'</div></div>'
|
|
335
|
+
+'<div class="cur">seller_address: '+esc(m.seller_address||'-')+(m.applyment_state_msg?' · '+esc(m.applyment_state_msg):'')+'</div>'
|
|
336
|
+
+'<div class="ck" id="ck-'+idx+'" style="display:none">'+ck+'</div>'
|
|
337
|
+
+'</div>';
|
|
338
|
+
}).join('');
|
|
339
|
+
}
|
|
340
|
+
var MERCHANTS=[];
|
|
341
|
+
function copyGuide(idx,item){
|
|
342
|
+
var m=MERCHANTS[idx];
|
|
343
|
+
var def=CHECKLIST_DEFS.filter(function(d){return d.item===item;})[0];
|
|
344
|
+
copyText('【'+(m.shop_name||m.sub_mchid)+' 入驻指引 · '+def.label+'】'+def.guide);
|
|
345
|
+
}
|
|
346
|
+
async function toggleCheck(idx,item,done,input){
|
|
347
|
+
var m=MERCHANTS[idx];
|
|
348
|
+
var r=await api('/api/sp/admin/checklist',{method:'PUT',body:JSON.stringify({sub_mchid:m.sub_mchid,item:item,done:done})});
|
|
349
|
+
if(r.status===200&&r.json.ok){showToast(true,done?'✓ 已确认完成':'已取消勾选');loadMerchants();}
|
|
350
|
+
else{showToast(false,(r.json&&r.json.message)||'操作失败');input.checked=!done;}
|
|
351
|
+
}
|
|
352
|
+
async function loadMerchants(){
|
|
353
|
+
var r=await api('/api/sp/admin/merchants');
|
|
354
|
+
if(r.status!==200){showToast(false,(r.json&&r.json.message)||'商户列表加载失败');return;}
|
|
355
|
+
MERCHANTS=r.json.merchants||[];
|
|
356
|
+
renderMerchants(MERCHANTS);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function renderFacts(items){
|
|
360
|
+
document.getElementById('facts-body').innerHTML=items.length?items.map(function(f){
|
|
361
|
+
var chain=f.chain_status==='chained'?'<span class="tag ok">已上链</span>':(f.chain_status==='disabled'?'<span class="tag">链禁用</span>':'<span class="tag warn">待上链</span>');
|
|
362
|
+
var ps=f.ps_status?('<span class="tag '+(f.ps_status==='done'?'ok':'warn')+'">'+esc(f.ps_status)+'</span>'):'<span class="cur">-</span>';
|
|
363
|
+
return '<tr><td>'+fmtTime(f.created_at)+'</td><td>'+esc(f.sub_mchid)+'</td><td>¥'+f.amount_yuan+'</td>'
|
|
364
|
+
+'<td>'+esc(f.out_trade_no)+'<br><span class="cur">'+esc(f.txn_id)+'</span></td>'
|
|
365
|
+
+'<td>'+chain+(f.chain_status==='chained'?'<br><span class="cur">'+esc(f.chain_txhash.slice(0,16))+'…</span>':'')+'</td>'
|
|
366
|
+
+'<td>'+ps+'</td></tr>';
|
|
367
|
+
}).join(''):'<tr><td colspan="6" class="cur">暂无交易记录</td></tr>';
|
|
368
|
+
}
|
|
369
|
+
function renderRates(items){
|
|
370
|
+
document.getElementById('rates-body').innerHTML=items.length?items.map(function(r){
|
|
371
|
+
return '<tr><td>'+esc(r.sub_mchid)+'</td><td>'+r.rate_bp+' bp ('+esc(r.rate_pct)+')</td><td>'+esc(r.updated_by||'-')+'</td><td>'+fmtTime(r.updated_at)+'</td></tr>';
|
|
372
|
+
}).join(''):'<tr><td colspan="4" class="cur">暂无登记 (默认 0 零抽成)</td></tr>';
|
|
373
|
+
}
|
|
374
|
+
async function saveRate(){
|
|
375
|
+
var mchid=document.getElementById('rate_mchid').value.trim();
|
|
376
|
+
var bp=Number(document.getElementById('rate_bp').value);
|
|
377
|
+
var m=document.getElementById('rate-msg');
|
|
378
|
+
if(!mchid){m.className='msg err';m.textContent='sub_mchid 必填';return;}
|
|
379
|
+
var r=await api('/api/sp/commission-rate',{method:'PUT',body:JSON.stringify({sub_mchid:mchid,rate_bp:bp})});
|
|
380
|
+
m.className='msg '+(r.json&&r.json.ok?'ok':'err');
|
|
381
|
+
m.textContent=r.json&&r.json.ok?('✓ 已登记 '+mchid+' = '+bp+' bp'):((r.json&&r.json.message)||'保存失败');
|
|
382
|
+
if(r.json&&r.json.ok){showToast(true,'✓ 费率已更新');loadRates();}
|
|
383
|
+
else showToast(false,(r.json&&r.json.message)||'保存失败');
|
|
384
|
+
}
|
|
385
|
+
async function loadRates(){
|
|
386
|
+
var r=await api('/api/sp/commission-rates');
|
|
387
|
+
if(r.status===200) renderRates(r.json.items||[]);
|
|
388
|
+
}
|
|
389
|
+
function renderPs(j){
|
|
390
|
+
var recv=j.receivers||[];
|
|
391
|
+
document.getElementById('ps-recv-body').innerHTML=recv.length?recv.map(function(r){
|
|
392
|
+
return '<tr><td>'+esc(r.account)+'</td><td>'+fmtTime(r.registered_at)+'</td><td>'+(r.mock?'是':'否')+'</td></tr>';
|
|
393
|
+
}).join(''):'<tr><td colspan="3" class="cur">暂无接收方</td></tr>';
|
|
394
|
+
var recs=j.records||[];
|
|
395
|
+
document.getElementById('ps-body').innerHTML=recs.length?recs.map(function(f){
|
|
396
|
+
return '<tr><td>'+fmtTime(f.ps_at||f.created_at)+'</td><td>'+esc(f.sub_mchid)+'</td><td>'+esc(f.ps_out_order_no||'-')+'</td>'
|
|
397
|
+
+'<td>'+(f.ps_amount==null?'-':'¥'+(f.ps_amount/100).toFixed(2))+'</td>'
|
|
398
|
+
+'<td><span class="tag '+(f.ps_status==='done'?'ok':'warn')+'">'+esc(f.ps_status)+'</span></td></tr>';
|
|
399
|
+
}).join(''):'<tr><td colspan="5" class="cur">暂无分账记录</td></tr>';
|
|
400
|
+
}
|
|
401
|
+
function renderApplyments(items){
|
|
402
|
+
document.getElementById('apply-body').innerHTML=items.length?items.map(function(a){
|
|
403
|
+
return '<tr><td>'+esc(a.business_code)+'</td><td>'+esc(a.applyment_id||'-')+'</td>'
|
|
404
|
+
+'<td><span class="tag '+(a.status==='APPLYMENT_STATE_FINISHED'?'ok':'warn')+'">'+esc(a.status)+'</span></td>'
|
|
405
|
+
+'<td>'+esc(a.state_msg||'-')+(a.sub_mchid?'<br><span class="cur">sub: '+esc(a.sub_mchid)+'</span>':'')+'</td>'
|
|
406
|
+
+'<td><button class="upbtn" onclick="refreshApplyment(\\''+esc(a.business_code)+'\\')">刷新状态</button></td></tr>';
|
|
407
|
+
}).join(''):'<tr><td colspan="5" class="cur">暂无进件单</td></tr>';
|
|
408
|
+
}
|
|
409
|
+
async function refreshApplyment(bc){
|
|
410
|
+
var r=await api('/api/sp/applyment/status/'+encodeURIComponent(bc));
|
|
411
|
+
if(r.status===200){showToast(true,'✓ 状态已刷新: '+(r.json.status||''));loadAll();}
|
|
412
|
+
else showToast(false,(r.json&&r.json.message)||'刷新失败');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function loadAll(){
|
|
416
|
+
var r=await api('/api/sp/admin/overview');
|
|
417
|
+
if(r.status!==200){showToast(false,(r.json&&r.json.message)||'概览加载失败');return;}
|
|
418
|
+
renderOverview(r.json);
|
|
419
|
+
loadMerchants();
|
|
420
|
+
var rf=await api('/api/sp/admin/facts?limit=100');
|
|
421
|
+
if(rf.status===200) renderFacts(rf.json.items||[]);
|
|
422
|
+
loadRates();
|
|
423
|
+
var rp=await api('/api/sp/admin/profitsharings');
|
|
424
|
+
if(rp.status===200) renderPs(rp.json);
|
|
425
|
+
var ra=await api('/api/sp/admin/applyments');
|
|
426
|
+
if(ra.status===200) renderApplyments(ra.json.items||[]);
|
|
427
|
+
}
|
|
428
|
+
adminKey(); loadAll();
|
|
429
|
+
</script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>`;
|
|
432
|
+
|
|
433
|
+
function createSpAdminRouter(deps) {
|
|
434
|
+
const router = express.Router();
|
|
435
|
+
const { db } = deps;
|
|
436
|
+
ensureSpAdminTables(db); // 路由创建即建表 (0.4.3 教训: 写路径不依赖读路径先建表)
|
|
437
|
+
|
|
438
|
+
/** admin 鉴权: env 未配置 → 全部拒绝 (401); 配置了 → header 精确比对 (同 sp-commission 口径) */
|
|
439
|
+
function checkAdmin(req, res) {
|
|
440
|
+
const expected = process.env.SP_ADMIN_API_KEY || "";
|
|
441
|
+
if (!expected) {
|
|
442
|
+
res.status(401).json({ error: "admin_key_not_configured", message: "SP_ADMIN_API_KEY 未配置, 管理接口全部拒绝" });
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
const got = String(req.headers["x-sp-admin-key"] || "");
|
|
446
|
+
if (!got || got !== expected) {
|
|
447
|
+
res.status(401).json({ error: "unauthorized", message: "x-sp-admin-key 无效" });
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** GET /admin — 运营台页面 (Accept: text/html 无 key 也 200, key 页面内输入; 同 /sp/config 口径)
|
|
454
|
+
* API 调用 (Accept: application/json) → admin 鉴权后返回端点索引 */
|
|
455
|
+
router.get("/admin", (req, res) => {
|
|
456
|
+
const wantsHtml = String(req.headers.accept || "").includes("text/html");
|
|
457
|
+
if (wantsHtml) {
|
|
458
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
459
|
+
return res.send(ADMIN_PAGE_HTML);
|
|
460
|
+
}
|
|
461
|
+
if (!checkAdmin(req, res)) return;
|
|
462
|
+
res.json({ ok: true, service: "sp-admin", endpoints: ["overview", "merchants", "checklist", "facts", "profitsharings", "applyments"] });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/** GET /admin/overview — 概览: 商户数 / 交易笔数总额 / 待办数 / 近 7 天交易 */
|
|
466
|
+
router.get("/admin/overview", (req, res) => {
|
|
467
|
+
if (!checkAdmin(req, res)) return;
|
|
468
|
+
const merchants = listMerchants(db);
|
|
469
|
+
let txnCount = 0, totalAmount = 0, last7d = [];
|
|
470
|
+
if (hasTable(db, "sp_payment_facts")) {
|
|
471
|
+
const agg = db.prepare("SELECT COUNT(*) n, COALESCE(SUM(amount),0) total FROM sp_payment_facts").get();
|
|
472
|
+
txnCount = agg.n; totalAmount = agg.total;
|
|
473
|
+
const since = nowSec() - 7 * 86400;
|
|
474
|
+
last7d = db.prepare(`SELECT date(created_at, 'unixepoch') day, COUNT(*) count, COALESCE(SUM(amount),0) amount
|
|
475
|
+
FROM sp_payment_facts WHERE created_at >= ? GROUP BY day ORDER BY day DESC`).all(since);
|
|
476
|
+
}
|
|
477
|
+
const todoPending = merchants.reduce((s, m) => s + m.todo_pending, 0);
|
|
478
|
+
res.json({
|
|
479
|
+
ok: true,
|
|
480
|
+
merchants: merchants.length,
|
|
481
|
+
txn_count: txnCount,
|
|
482
|
+
total_amount: totalAmount,
|
|
483
|
+
total_amount_yuan: (totalAmount / 100).toFixed(2),
|
|
484
|
+
todo_pending: todoPending,
|
|
485
|
+
last7d,
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
/** GET /admin/merchants — 商户列表 (联进件状态 + 首单标记 + checklist 勾选) */
|
|
490
|
+
router.get("/admin/merchants", (req, res) => {
|
|
491
|
+
if (!checkAdmin(req, res)) return;
|
|
492
|
+
res.json({ ok: true, items: ALL_ITEMS, merchants: listMerchants(db) });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
/** PUT /admin/checklist — 人工勾选持久化 {sub_mchid, item, done}
|
|
496
|
+
* first_order 是自动判定项, 不可手改 (400) */
|
|
497
|
+
router.put("/admin/checklist", (req, res) => {
|
|
498
|
+
if (!checkAdmin(req, res)) return;
|
|
499
|
+
try {
|
|
500
|
+
const b = req.body || {};
|
|
501
|
+
const subMchid = chainWrites.assertSafe(b.sub_mchid, "sub_mchid");
|
|
502
|
+
const item = String(b.item || "");
|
|
503
|
+
if (!ALL_ITEMS.includes(item)) {
|
|
504
|
+
throw new HttpError(400, "invalid_param", "item 必须是 " + ALL_ITEMS.join("/"));
|
|
505
|
+
}
|
|
506
|
+
if (item === FIRST_ORDER_ITEM) {
|
|
507
|
+
throw new HttpError(400, "invalid_param", "first_order 由交易记录自动判定, 不可手改");
|
|
508
|
+
}
|
|
509
|
+
if (b.done) {
|
|
510
|
+
db.prepare("INSERT INTO sp_admin_checklist (sub_mchid, item, done_at) VALUES (?,?,?) ON CONFLICT(sub_mchid, item) DO UPDATE SET done_at=excluded.done_at")
|
|
511
|
+
.run(subMchid, item, nowSec());
|
|
512
|
+
} else {
|
|
513
|
+
db.prepare("DELETE FROM sp_admin_checklist WHERE sub_mchid = ? AND item = ?").run(subMchid, item);
|
|
514
|
+
}
|
|
515
|
+
console.log(`[sp-admin] checklist ${b.done ? "✓" : "✗"} sub=${subMchid} item=${item}`);
|
|
516
|
+
res.json({ ok: true, sub_mchid: subMchid, item, done: !!b.done });
|
|
517
|
+
} catch (e) {
|
|
518
|
+
return chainWrites.handleError(res, e, "/api/sp/admin/checklist");
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
/** GET /admin/facts?limit=100 — 交易记录 (sp_payment_facts 倒序; limit 1..500, 默认 100) */
|
|
523
|
+
router.get("/admin/facts", (req, res) => {
|
|
524
|
+
if (!checkAdmin(req, res)) return;
|
|
525
|
+
if (!hasTable(db, "sp_payment_facts")) return res.json({ ok: true, items: [] });
|
|
526
|
+
let limit = Number(req.query.limit || 100);
|
|
527
|
+
if (!Number.isInteger(limit) || limit < 1) limit = 100;
|
|
528
|
+
if (limit > 500) limit = 500;
|
|
529
|
+
const rows = db.prepare("SELECT * FROM sp_payment_facts ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
530
|
+
res.json({ ok: true, items: rows.map(factToJson) });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
/** GET /admin/profitsharings — 分账: 接收方台账 + ps_status 非空的分账记录 */
|
|
534
|
+
router.get("/admin/profitsharings", (req, res) => {
|
|
535
|
+
if (!checkAdmin(req, res)) return;
|
|
536
|
+
const receivers = hasTable(db, "sp_ps_receivers")
|
|
537
|
+
? db.prepare("SELECT account, registered_at, mock FROM sp_ps_receivers ORDER BY registered_at DESC").all()
|
|
538
|
+
: [];
|
|
539
|
+
const records = hasTable(db, "sp_payment_facts")
|
|
540
|
+
? db.prepare("SELECT * FROM sp_payment_facts WHERE ps_status IS NOT NULL AND ps_status <> '' ORDER BY COALESCE(ps_at, created_at) DESC LIMIT 100").all().map(factToJson)
|
|
541
|
+
: [];
|
|
542
|
+
res.json({ ok: true, receivers, records });
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
/** GET /admin/applyments — 进件通道列表 (business_code/applyment_id/状态/state_msg) */
|
|
546
|
+
router.get("/admin/applyments", (req, res) => {
|
|
547
|
+
if (!checkAdmin(req, res)) return;
|
|
548
|
+
if (!hasTable(db, "sp_applyments")) return res.json({ ok: true, items: [] });
|
|
549
|
+
const rows = db.prepare(`SELECT business_code, applyment_id, status, state_msg, sign_url, sub_mchid, created_at, updated_at
|
|
550
|
+
FROM sp_applyments ORDER BY created_at DESC LIMIT 100`).all();
|
|
551
|
+
res.json({ ok: true, items: rows });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
return router;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
module.exports = { createSpAdminRouter, ensureSpAdminTables, CHECKLIST_ITEMS, MANUAL_ITEMS, FIRST_ORDER_ITEM };
|
package/routes/sp-applyment.js
CHANGED
|
@@ -458,6 +458,8 @@ h1{font-size:20px;margin-bottom:4px}
|
|
|
458
458
|
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
|
459
459
|
.chip{font-size:12px;border-radius:999px;padding:5px 12px;border:1px solid #EAE3D6;background:#FCFAF4;color:#8A857B}
|
|
460
460
|
.chip.ok{background:#F0FDF4;border-color:#BBF7D0;color:#166534}
|
|
461
|
+
.hd{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
462
|
+
.hd .upbtn{margin-top:6px}
|
|
461
463
|
.box{background:#FCFAF4;border:1px solid #EAE3D6;border-radius:14px;padding:18px;margin-bottom:14px}
|
|
462
464
|
.box h3{font-size:15px;color:#C4633C;margin-bottom:10px}
|
|
463
465
|
label{display:block;font-size:13px;color:#6B655B;margin:10px 0 4px}
|
|
@@ -490,8 +492,10 @@ input:focus,select:focus{outline:none;border-color:#C4633C;box-shadow:0 0 0 3px
|
|
|
490
492
|
</head>
|
|
491
493
|
<body>
|
|
492
494
|
<div class="wrap">
|
|
495
|
+
<div class="hd"><div>
|
|
493
496
|
<h1>服务商配置</h1>
|
|
494
497
|
<div class="sub">TrustBase 服务商运营台 · 应用/名片/状态 一屏三件事</div>
|
|
498
|
+
</div><button class="upbtn" onclick="logout()" title="清除本机保存的管理密钥,下次进入需重新输入">退出登录</button></div>
|
|
495
499
|
|
|
496
500
|
<div id="toast"></div>
|
|
497
501
|
|
|
@@ -562,6 +566,13 @@ function busy(btn,on,txtBusy){
|
|
|
562
566
|
else{btn.disabled=false;btn.textContent=btn.dataset.txt||btn.textContent;}
|
|
563
567
|
}
|
|
564
568
|
|
|
569
|
+
// 退出登录: 清掉本机 localStorage 里的 admin key, 刷新后重新输入 (2026-09-28 xiaomu 实测: 页面无退出入口)
|
|
570
|
+
function logout(){
|
|
571
|
+
localStorage.removeItem('spAdminKey');
|
|
572
|
+
showToast(true,'已退出,即将重新验证密钥');
|
|
573
|
+
setTimeout(function(){location.reload();},700);
|
|
574
|
+
}
|
|
575
|
+
|
|
565
576
|
function render(){
|
|
566
577
|
var c=CFG, comp=c.completeness;
|
|
567
578
|
document.getElementById('chips').innerHTML=
|
package/run-unified.js
CHANGED
|
@@ -126,6 +126,7 @@ const agent_serviceprovider_1 = require("./routes/agent-serviceprovider");
|
|
|
126
126
|
const sp_notify_1 = require("./routes/sp-notify");
|
|
127
127
|
const sp_commission_1 = require("./routes/sp-commission");
|
|
128
128
|
const sp_applyment_1 = require("./routes/sp-applyment");
|
|
129
|
+
const sp_admin_1 = require("./routes/sp-admin");
|
|
129
130
|
const chain_writes_pledge_1 = require("./routes/chain-writes-pledge");
|
|
130
131
|
const seller_products_chain_1 = require("./api/seller-products-chain");
|
|
131
132
|
const crosschain_1 = require("./routes/crosschain");
|
|
@@ -631,6 +632,10 @@ async function startUnifiedServer() {
|
|
|
631
632
|
const spApplymentRouter = (0, sp_applyment_1.createSpApplymentRouter)({ db: sqliteIndexer.getRawDb() });
|
|
632
633
|
app.use('/api/sp', spApplymentRouter);
|
|
633
634
|
app.use('/sp', spApplymentRouter);
|
|
635
|
+
// 2026-10-06: SP Admin 运营台 — 页面 GET /sp/admin + 运营 API /api/sp/admin/* (概览/商户待办清单/交易/分账/进件)
|
|
636
|
+
const spAdminRouter = (0, sp_admin_1.createSpAdminRouter)({ db: sqliteIndexer.getRawDb() });
|
|
637
|
+
app.use('/api/sp', spAdminRouter);
|
|
638
|
+
app.use('/sp', spAdminRouter);
|
|
634
639
|
app.use('/api/treasury/pledge', (0, chain_writes_pledge_1.createChainWritesPledgeRouter)({ db: sqliteIndexer.getRawDb() }));
|
|
635
640
|
app.use('/api/seller', (0, seller_products_chain_1.createSellerProductsChainRouter)({ db: sqliteIndexer.getRawDb() }));
|
|
636
641
|
console.log(' ✅ TrustCoin router mounted at /api/trustcoin/{accounts,sellers-by-category,transfer-limit}');
|
package/test-sp-admin.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-sp-admin.js — SP Admin 服务商运营台 离线单测 (2026-10-06, v0.5.0)
|
|
4
|
+
* node --test test-sp-admin.js
|
|
5
|
+
*
|
|
6
|
+
* 照 test-sp-applyment.js 模式: :memory: 库起 mock server, 不碰真微信/真链。
|
|
7
|
+
* 覆盖: 鉴权 (无 key 401 / 页面无 key 200) / 各端点空库行为 / checklist 往返 /
|
|
8
|
+
* 首单自动打勾 / 页面内联脚本编译断言 (0.4.1 回归守门)。
|
|
9
|
+
*/
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert");
|
|
12
|
+
const express = require("express");
|
|
13
|
+
const Database = require("better-sqlite3");
|
|
14
|
+
|
|
15
|
+
const ADMIN_KEY = "test-sp-admin-key";
|
|
16
|
+
|
|
17
|
+
// env 必须在 require 前设好
|
|
18
|
+
process.env.SP_ADMIN_API_KEY = ADMIN_KEY;
|
|
19
|
+
|
|
20
|
+
const spAdmin = require("./routes/sp-admin");
|
|
21
|
+
const spNotify = require("./routes/sp-notify");
|
|
22
|
+
const spApplyment = require("./routes/sp-applyment");
|
|
23
|
+
const profitsharing = require("./payments/wechat/profitsharing");
|
|
24
|
+
|
|
25
|
+
/** 起被测服务 (页面 /sp/admin + API /api/sp/admin/*) */
|
|
26
|
+
async function startServer() {
|
|
27
|
+
const db = new Database(":memory:");
|
|
28
|
+
const app = express();
|
|
29
|
+
app.use(express.json());
|
|
30
|
+
const router = spAdmin.createSpAdminRouter({ db });
|
|
31
|
+
app.use("/api/sp", router);
|
|
32
|
+
app.use("/sp", router);
|
|
33
|
+
const srv = app.listen(0);
|
|
34
|
+
await new Promise((r) => srv.once("listening", r));
|
|
35
|
+
return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const H = { "Content-Type": "application/json", "x-sp-admin-key": ADMIN_KEY };
|
|
39
|
+
|
|
40
|
+
/** 造齐别的模块拥有的表 (sp-admin 只读) + 一条商户/进件/交易/接收方 */
|
|
41
|
+
function seedAll(db) {
|
|
42
|
+
spNotify.ensureTables(db); // sp_payment_facts + sp_sub_merchants
|
|
43
|
+
spApplyment.ensureApplymentTables(db); // sp_applyments
|
|
44
|
+
profitsharing.ensurePsReceiverTable(db); // sp_ps_receivers
|
|
45
|
+
const now = Math.floor(Date.now() / 1000);
|
|
46
|
+
db.prepare("INSERT INTO sp_sub_merchants (sub_mchid, seller_address, shop_name, webhook_url, status, created_at) VALUES (?,?,?,?,?,?)")
|
|
47
|
+
.run("1118152327", "tct1qpz7x0f2fakeselleraddress0000aaaa12", "南京港思恒", "", "active", now);
|
|
48
|
+
db.prepare(`INSERT INTO sp_applyments (business_code, applyment_id, status, sign_url, sub_mchid, state_msg, created_at, updated_at)
|
|
49
|
+
VALUES (?,?,?,?,?,?,?,?)`)
|
|
50
|
+
.run("TB1790000000000000001", "2000002124775691", "APPLYMENT_STATE_FINISHED", "", "1118152327", "已完成", now, now);
|
|
51
|
+
db.prepare(`INSERT INTO sp_payment_facts (txn_id, sub_mchid, out_trade_no, amount, plain_json, chain_txhash, forward_status, created_at)
|
|
52
|
+
VALUES (?,?,?,?,?,?,?,?)`)
|
|
53
|
+
.run("4200009999000001", "1118152327", "ORDER-TEST-1", 12345, "{}", "ABCDEF1234", "ok", now);
|
|
54
|
+
db.prepare(`INSERT INTO sp_payment_facts (txn_id, sub_mchid, out_trade_no, amount, plain_json, chain_txhash, forward_status, created_at,
|
|
55
|
+
ps_out_order_no, ps_status, ps_amount, ps_at)
|
|
56
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
57
|
+
.run("4200009999000002", "1118152327", "ORDER-TEST-2", 20000, "{}", "", "ok", now,
|
|
58
|
+
"PS-OUT-1", "done", 200, now);
|
|
59
|
+
db.prepare("INSERT INTO sp_ps_receivers (account, registered_at, mock) VALUES (?,?,?)")
|
|
60
|
+
.run("1000686974", now, 0);
|
|
61
|
+
return now;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("GET /sp/admin 页面: 无 key 200 + 板块标题 + admin key 逻辑", async () => {
|
|
65
|
+
const { srv, base } = await startServer();
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(base + "/sp/admin", { headers: { Accept: "text/html" } });
|
|
68
|
+
const html = await r.text();
|
|
69
|
+
assert.strictEqual(r.status, 200);
|
|
70
|
+
assert.match(r.headers.get("content-type"), /text\/html/);
|
|
71
|
+
for (const kw of ["SP 运营台", "概览", "特约商户", "交易记录", "抽佣费率", "分账", "进件通道", "应用配置"]) {
|
|
72
|
+
assert.ok(html.includes(kw), "板块: " + kw);
|
|
73
|
+
}
|
|
74
|
+
assert.ok(html.includes("x-sp-admin-key"), "admin key 逻辑");
|
|
75
|
+
assert.ok(html.includes("localStorage"), "key 页面内输入 (localStorage)");
|
|
76
|
+
// 入驻待办清单 5 项 + 复制指引 + 退出登录 (照 /sp/config 口径)
|
|
77
|
+
for (const kw of ["Native 授权邀请", "分账授权邀请", "付款码邀请", "AppID 绑定", "首单验证", "复制指引文案", "退出登录", "showToast", "/sp/config"]) {
|
|
78
|
+
assert.ok(html.includes(kw), "页面元素: " + kw);
|
|
79
|
+
}
|
|
80
|
+
// 微信侧状态无 API 可查 → 人工确认勾选说明 + 首单自动不可手改
|
|
81
|
+
assert.ok(html.includes("不可手改"), "首单自动判定不可手改提示");
|
|
82
|
+
// 敏感配置不回显
|
|
83
|
+
assert.ok(!html.includes(ADMIN_KEY), "页面不含 admin key 原文");
|
|
84
|
+
} finally { srv.close(); }
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("页面内联脚本编译断言 (0.4.1 回归守门): 所有 <script> 块 new Function 不抛错", async () => {
|
|
88
|
+
const { srv, base } = await startServer();
|
|
89
|
+
try {
|
|
90
|
+
const r = await fetch(base + "/sp/admin", { headers: { Accept: "text/html" } });
|
|
91
|
+
const html = await r.text();
|
|
92
|
+
const blocks = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi)];
|
|
93
|
+
assert.ok(blocks.length >= 1, "运营台页应有内联脚本");
|
|
94
|
+
for (const b of blocks) new Function(b[1]); // 语法错误会抛 (模板转义回归守门)
|
|
95
|
+
} finally { srv.close(); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("鉴权: API 无 key/错 key → 401; env 未配置 → 全拒; 页面始终 200", async () => {
|
|
99
|
+
const { srv, base } = await startServer();
|
|
100
|
+
try {
|
|
101
|
+
for (const p of ["/api/sp/admin/overview", "/api/sp/admin/merchants", "/api/sp/admin/facts", "/api/sp/admin/profitsharings", "/api/sp/admin/applyments"]) {
|
|
102
|
+
let r = await fetch(base + p);
|
|
103
|
+
assert.strictEqual(r.status, 401, p + " 无 key 401");
|
|
104
|
+
r = await fetch(base + p, { headers: { "x-sp-admin-key": "wrong" } });
|
|
105
|
+
assert.strictEqual(r.status, 401, p + " 错 key 401");
|
|
106
|
+
}
|
|
107
|
+
let r = await fetch(base + "/api/sp/admin/checklist", {
|
|
108
|
+
method: "PUT", headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ sub_mchid: "1", item: "native_auth", done: true }),
|
|
110
|
+
});
|
|
111
|
+
assert.strictEqual(r.status, 401, "checklist 无 key 401");
|
|
112
|
+
// env 未配置 → 带对 key 也 401
|
|
113
|
+
const saved = process.env.SP_ADMIN_API_KEY;
|
|
114
|
+
delete process.env.SP_ADMIN_API_KEY;
|
|
115
|
+
r = await fetch(base + "/api/sp/admin/overview", { headers: H });
|
|
116
|
+
assert.strictEqual(r.status, 401);
|
|
117
|
+
assert.strictEqual((await r.json()).error, "admin_key_not_configured");
|
|
118
|
+
// 页面不受 env 影响 (key 页面内输入)
|
|
119
|
+
r = await fetch(base + "/sp/admin", { headers: { Accept: "text/html" } });
|
|
120
|
+
assert.strictEqual(r.status, 200, "env 未配置页面仍 200");
|
|
121
|
+
process.env.SP_ADMIN_API_KEY = saved;
|
|
122
|
+
} finally { srv.close(); }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("空库行为: 各端点 200 + 空结构 (别的模块的表不存在也容忍)", async () => {
|
|
126
|
+
const { srv, base } = await startServer();
|
|
127
|
+
try {
|
|
128
|
+
let j = await (await fetch(base + "/api/sp/admin/overview", { headers: H })).json();
|
|
129
|
+
assert.strictEqual(j.ok, true);
|
|
130
|
+
assert.strictEqual(j.merchants, 0);
|
|
131
|
+
assert.strictEqual(j.txn_count, 0);
|
|
132
|
+
assert.strictEqual(j.total_amount, 0);
|
|
133
|
+
assert.strictEqual(j.total_amount_yuan, "0.00");
|
|
134
|
+
assert.strictEqual(j.todo_pending, 0);
|
|
135
|
+
assert.deepStrictEqual(j.last7d, []);
|
|
136
|
+
j = await (await fetch(base + "/api/sp/admin/merchants", { headers: H })).json();
|
|
137
|
+
assert.deepStrictEqual(j.merchants, []);
|
|
138
|
+
assert.ok(Array.isArray(j.items) && j.items.includes("native_auth") && j.items.includes("first_order"), "待办项定义");
|
|
139
|
+
j = await (await fetch(base + "/api/sp/admin/facts", { headers: H })).json();
|
|
140
|
+
assert.deepStrictEqual(j.items, []);
|
|
141
|
+
j = await (await fetch(base + "/api/sp/admin/profitsharings", { headers: H })).json();
|
|
142
|
+
assert.deepStrictEqual(j.receivers, []);
|
|
143
|
+
assert.deepStrictEqual(j.records, []);
|
|
144
|
+
j = await (await fetch(base + "/api/sp/admin/applyments", { headers: H })).json();
|
|
145
|
+
assert.deepStrictEqual(j.items, []);
|
|
146
|
+
} finally { srv.close(); }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("有数据: overview 统计 + merchants 联进件状态/首单 + facts/profitsharings/applyments 结构", async () => {
|
|
150
|
+
const { db, srv, base } = await startServer();
|
|
151
|
+
try {
|
|
152
|
+
const now = seedAll(db);
|
|
153
|
+
// overview
|
|
154
|
+
let j = await (await fetch(base + "/api/sp/admin/overview", { headers: H })).json();
|
|
155
|
+
assert.strictEqual(j.merchants, 1);
|
|
156
|
+
assert.strictEqual(j.txn_count, 2);
|
|
157
|
+
assert.strictEqual(j.total_amount, 32345);
|
|
158
|
+
assert.strictEqual(j.total_amount_yuan, "323.45");
|
|
159
|
+
assert.strictEqual(j.todo_pending, 4, "4 项人工待办未勾 (首单已自动完成)");
|
|
160
|
+
assert.strictEqual(j.last7d.length, 1, "近7天有一天");
|
|
161
|
+
assert.strictEqual(j.last7d[0].count, 2);
|
|
162
|
+
assert.strictEqual(j.last7d[0].amount, 32345);
|
|
163
|
+
// merchants
|
|
164
|
+
j = await (await fetch(base + "/api/sp/admin/merchants", { headers: H })).json();
|
|
165
|
+
assert.strictEqual(j.merchants.length, 1);
|
|
166
|
+
const m = j.merchants[0];
|
|
167
|
+
assert.strictEqual(m.sub_mchid, "1118152327");
|
|
168
|
+
assert.strictEqual(m.shop_name, "南京港思恒");
|
|
169
|
+
assert.strictEqual(m.seller_address, "tct1qpz7x0f2fakeselleraddress0000aaaa12");
|
|
170
|
+
assert.strictEqual(m.status, "active");
|
|
171
|
+
assert.strictEqual(m.applyment_status, "APPLYMENT_STATE_FINISHED", "联 sp_applyments 状态");
|
|
172
|
+
assert.strictEqual(m.first_order, true, "首单自动打勾 (sp_payment_facts 有记录)");
|
|
173
|
+
assert.ok(m.checklist.first_order, "checklist 首单项完成");
|
|
174
|
+
assert.strictEqual(m.checklist.native_auth, null, "人工项未勾");
|
|
175
|
+
assert.strictEqual(m.todo_pending, 4);
|
|
176
|
+
// facts
|
|
177
|
+
j = await (await fetch(base + "/api/sp/admin/facts", { headers: H })).json();
|
|
178
|
+
assert.strictEqual(j.items.length, 2);
|
|
179
|
+
const f1 = j.items.find((x) => x.txn_id === "4200009999000001");
|
|
180
|
+
assert.strictEqual(f1.amount, 12345);
|
|
181
|
+
assert.strictEqual(f1.amount_yuan, "123.45");
|
|
182
|
+
assert.strictEqual(f1.out_trade_no, "ORDER-TEST-1");
|
|
183
|
+
assert.strictEqual(f1.chain_status, "chained");
|
|
184
|
+
assert.strictEqual(f1.ps_status, "");
|
|
185
|
+
const f2 = j.items.find((x) => x.txn_id === "4200009999000002");
|
|
186
|
+
assert.strictEqual(f2.chain_status, "pending", "chain_txhash 空 → pending");
|
|
187
|
+
assert.strictEqual(f2.ps_status, "done");
|
|
188
|
+
assert.strictEqual(f2.ps_amount, 200);
|
|
189
|
+
// limit 参数
|
|
190
|
+
j = await (await fetch(base + "/api/sp/admin/facts?limit=1", { headers: H })).json();
|
|
191
|
+
assert.strictEqual(j.items.length, 1, "limit 生效");
|
|
192
|
+
// profitsharings
|
|
193
|
+
j = await (await fetch(base + "/api/sp/admin/profitsharings", { headers: H })).json();
|
|
194
|
+
assert.strictEqual(j.receivers.length, 1);
|
|
195
|
+
assert.strictEqual(j.receivers[0].account, "1000686974");
|
|
196
|
+
assert.strictEqual(j.records.length, 1, "只有 ps_status 非空的行");
|
|
197
|
+
assert.strictEqual(j.records[0].ps_out_order_no, "PS-OUT-1");
|
|
198
|
+
// applyments
|
|
199
|
+
j = await (await fetch(base + "/api/sp/admin/applyments", { headers: H })).json();
|
|
200
|
+
assert.strictEqual(j.items.length, 1);
|
|
201
|
+
assert.strictEqual(j.items[0].business_code, "TB1790000000000000001");
|
|
202
|
+
assert.strictEqual(j.items[0].applyment_id, "2000002124775691");
|
|
203
|
+
assert.strictEqual(j.items[0].status, "APPLYMENT_STATE_FINISHED");
|
|
204
|
+
assert.strictEqual(j.items[0].state_msg, "已完成");
|
|
205
|
+
assert.ok(now > 0);
|
|
206
|
+
} finally { srv.close(); }
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("首单自动打勾: 无交易 → first_order false; 插入 sp_payment_facts → true", async () => {
|
|
210
|
+
const { db, srv, base } = await startServer();
|
|
211
|
+
try {
|
|
212
|
+
spNotify.ensureTables(db);
|
|
213
|
+
const now = Math.floor(Date.now() / 1000);
|
|
214
|
+
db.prepare("INSERT INTO sp_sub_merchants (sub_mchid, seller_address, shop_name, webhook_url, status, created_at) VALUES (?,?,?,?,?,?)")
|
|
215
|
+
.run("1600000999", "", "未首单商户", "", "active", now);
|
|
216
|
+
let j = await (await fetch(base + "/api/sp/admin/merchants", { headers: H })).json();
|
|
217
|
+
assert.strictEqual(j.merchants[0].first_order, false);
|
|
218
|
+
assert.strictEqual(j.merchants[0].todo_pending, 5, "5 项全待办");
|
|
219
|
+
db.prepare(`INSERT INTO sp_payment_facts (txn_id, sub_mchid, out_trade_no, amount, created_at) VALUES (?,?,?,?,?)`)
|
|
220
|
+
.run("4200009999000099", "1600000999", "ORDER-FIRST", 100, now);
|
|
221
|
+
j = await (await fetch(base + "/api/sp/admin/merchants", { headers: H })).json();
|
|
222
|
+
assert.strictEqual(j.merchants[0].first_order, true, "有交易记录 → 首单自动打勾");
|
|
223
|
+
assert.strictEqual(j.merchants[0].todo_pending, 4);
|
|
224
|
+
} finally { srv.close(); }
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("checklist PUT/GET 往返: 勾选持久化 + 取消 + first_order 拒手改 + 非法 item 400", async () => {
|
|
228
|
+
const { db, srv, base } = await startServer();
|
|
229
|
+
try {
|
|
230
|
+
spNotify.ensureTables(db);
|
|
231
|
+
const now = Math.floor(Date.now() / 1000);
|
|
232
|
+
db.prepare("INSERT INTO sp_sub_merchants (sub_mchid, seller_address, shop_name, webhook_url, status, created_at) VALUES (?,?,?,?,?,?)")
|
|
233
|
+
.run("1118152327", "", "南京港思恒", "", "active", now);
|
|
234
|
+
// 勾选 native_auth
|
|
235
|
+
let r = await fetch(base + "/api/sp/admin/checklist", {
|
|
236
|
+
method: "PUT", headers: H,
|
|
237
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "native_auth", done: true }),
|
|
238
|
+
});
|
|
239
|
+
let j = await r.json();
|
|
240
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
241
|
+
assert.strictEqual(j.ok, true);
|
|
242
|
+
assert.strictEqual(j.done, true);
|
|
243
|
+
const row = db.prepare("SELECT * FROM sp_admin_checklist WHERE sub_mchid=? AND item=?").get("1118152327", "native_auth");
|
|
244
|
+
assert.ok(row && row.done_at > 0, "勾选已持久化");
|
|
245
|
+
// GET merchants 反映勾选
|
|
246
|
+
j = await (await fetch(base + "/api/sp/admin/merchants", { headers: H })).json();
|
|
247
|
+
assert.ok(j.merchants[0].checklist.native_auth > 0, "merchants 带回 done_at");
|
|
248
|
+
assert.strictEqual(j.merchants[0].todo_pending, 4, "待办减 1");
|
|
249
|
+
// 重复勾选幂等 (done_at 更新不报错)
|
|
250
|
+
r = await fetch(base + "/api/sp/admin/checklist", {
|
|
251
|
+
method: "PUT", headers: H,
|
|
252
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "native_auth", done: true }),
|
|
253
|
+
});
|
|
254
|
+
assert.strictEqual(r.status, 200);
|
|
255
|
+
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_admin_checklist").get().n, 1, "不重复落行");
|
|
256
|
+
// 取消勾选 → 删行
|
|
257
|
+
r = await fetch(base + "/api/sp/admin/checklist", {
|
|
258
|
+
method: "PUT", headers: H,
|
|
259
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "native_auth", done: false }),
|
|
260
|
+
});
|
|
261
|
+
assert.strictEqual((await r.json()).done, false);
|
|
262
|
+
assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM sp_admin_checklist").get().n, 0, "取消勾选删行");
|
|
263
|
+
// first_order 不可手改
|
|
264
|
+
r = await fetch(base + "/api/sp/admin/checklist", {
|
|
265
|
+
method: "PUT", headers: H,
|
|
266
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "first_order", done: true }),
|
|
267
|
+
});
|
|
268
|
+
assert.strictEqual(r.status, 400);
|
|
269
|
+
// 非法 item
|
|
270
|
+
r = await fetch(base + "/api/sp/admin/checklist", {
|
|
271
|
+
method: "PUT", headers: H,
|
|
272
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "hacker_item", done: true }),
|
|
273
|
+
});
|
|
274
|
+
assert.strictEqual(r.status, 400);
|
|
275
|
+
} finally { srv.close(); }
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("回归 0.4.3: 全新库直接 PUT /admin/checklist (不先 GET) 不能报 no such table", async () => {
|
|
279
|
+
const { db, srv, base } = await startServer();
|
|
280
|
+
try {
|
|
281
|
+
const r = await fetch(base + "/api/sp/admin/checklist", {
|
|
282
|
+
method: "PUT", headers: H,
|
|
283
|
+
body: JSON.stringify({ sub_mchid: "1118152327", item: "appid_bind", done: true }),
|
|
284
|
+
});
|
|
285
|
+
const j = await r.json();
|
|
286
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
287
|
+
assert.ok(db.prepare("SELECT * FROM sp_admin_checklist WHERE sub_mchid='1118152327' AND item='appid_bind'").get(), "路由创建即建表");
|
|
288
|
+
} finally { srv.close(); }
|
|
289
|
+
});
|
package/test-sp-applyment.js
CHANGED
|
@@ -579,6 +579,9 @@ test("配置页操作反馈: toast 组件 + 成功/失败文案 + 保存中禁
|
|
|
579
579
|
assert.ok(html.includes("r.json.message"), "失败显示后端 message 原文");
|
|
580
580
|
// 保存中禁用
|
|
581
581
|
assert.ok(html.includes("busy(btn,true)") && html.includes("保存中…"), "保存中禁用+文案");
|
|
582
|
+
// 退出登录入口 (0.4.4): 清 localStorage 密钥 + 刷新
|
|
583
|
+
assert.ok(html.includes("退出登录") && html.includes("function logout()"), "退出登录按钮+函数");
|
|
584
|
+
assert.ok(html.includes("removeItem('spAdminKey')"), "logout 清密钥");
|
|
582
585
|
// 所有 mutating 操作接入: 应用保存/设默认/删除/素材上传/名片保存
|
|
583
586
|
for (const fn of ["saveApp", "setDefault", "delApp", "upFormAsset", "saveProfile"]) {
|
|
584
587
|
assert.ok(html.includes(fn), fn + " 存在");
|