qq-bot-roost 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
11
  <img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.9.0-blueviolet" alt="版本 0.9.0">
13
+ <img src="https://img.shields.io/badge/version-0.9.2-blueviolet" alt="版本 0.9.2">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
@@ -427,7 +427,7 @@ bot/
427
427
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
428
428
  ├── test/
429
429
  │ ├── selftest.mjs 框架自检(538 项)
430
- │ ├── e2e.mjs 端到端测试(230 项,起真实服务打接口)
430
+ │ ├── e2e.mjs 端到端测试(243 项,起真实服务打接口)
431
431
  │ ├── qrlogin.mjs 扫码建机器人(8 项)
432
432
  │ └── deps.mjs 插件依赖安装(11 项)
433
433
  ├── web/ 面板前端(原生 JS/CSS,无构建)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/bot.js CHANGED
@@ -19,6 +19,7 @@ import { StatsStore } from './storage/index.js';
19
19
  import { createReach } from './reach.js';
20
20
  import { createLedger } from './ledger.js';
21
21
  import { createGate } from './gate.js';
22
+ import { createDeliveryLedger } from './delivery.js';
22
23
 
23
24
  /**
24
25
  * 一个机器人的完整运行时。
@@ -233,6 +234,28 @@ export class BotRuntime {
233
234
 
234
235
  // 统计库要在插件之前打开:插件上报的运行数据也算统计的一部分
235
236
  this.stats.open();
237
+
238
+ /**
239
+ * 投递账本(阶段 5)放在统计库里,所以必须等 `stats.open()` 之后。
240
+ *
241
+ * `outbound` 在更早的地方就构造好了(它不依赖库),这里补上引用——
242
+ * 账本是**增强**,`stats` 打不开时 `db` 是 null,账本自己会退回「照发」。
243
+ */
244
+ this.delivery = createDeliveryLedger({
245
+ db: this.stats.db,
246
+ botId: this.id,
247
+ log: (m) => this.log.warn(m),
248
+ });
249
+ if (this.outbound) this.outbound.delivery = this.delivery;
250
+ /** 插件管理器更早构造(那时还没有库),这里补上引用 */
251
+ if (this.plugins) this.plugins.delivery = this.delivery;
252
+ /**
253
+ * 崩溃恢复:上次进程死掉时还在 `pending` 的,全部标 `unknown`。
254
+ *
255
+ * 现在还没有任何发送在跑,所以那些 `pending` 一定是崩溃残留——
256
+ * 我们不知道发没发出去,只能标未知,等人核对。**不能当失败重发。**
257
+ */
258
+ this.delivery.recoverPending();
236
259
  // 账本更要在插件之前开:插件一加载就可能开始收消息,漏一条就永久没了
237
260
  this.ledger.open();
238
261
 
@@ -0,0 +1,346 @@
1
+ /**
2
+ * 投递账本
3
+ * ========
4
+ *
5
+ * 一句话:**发出的每一条消息都有据可查,且永远不会因为「不确定」而被重发。**
6
+ *
7
+ * ── 为什么需要 ──
8
+ *
9
+ * 现在「我说过 X」靠**调用顺序**保证——必须挂在发送成功之后。
10
+ * 这在 `msg_seq` 群 1–5 / 单聊 1–4 的约束下很脆:一次超时重发就撞上限流,
11
+ * 还会把「我说过」写进记忆,形成自洽幻觉(她坚称自己说过一句没人看见的话)。
12
+ *
13
+ * 改造后靠**状态查询**:顺序会被并发打乱,状态不会。
14
+ *
15
+ * ── 状态机 ──
16
+ *
17
+ * ┌──────── 304023 ────────→ audited(等审核事件结算)
18
+ * │
19
+ * created → pending ┼──────── 有 msg_id ──────→ sent
20
+ * │
21
+ * ├── 超窗/配额/离线/限流 ──→ failed(可重试)
22
+ * │
23
+ * └── 超时/5xx/连接重置 ───→ unknown(**永不自动重发**)
24
+ *
25
+ * 三个非显然的点:
26
+ *
27
+ * 1. **`audited` 不是成功也不是失败。** 官方文档里的 `pending` 指「结果未确定」,
28
+ * 而账本里的 `pending` 指「尚未发出」——两个 `pending` 会打架,所以这里叫 `audited`。
29
+ * 审核期间**不能回写「我说过」**,否则审核不通过就变成她坚称说过一句没人看见的话。
30
+ *
31
+ * 2. **`unknown` 必须存在。** 网络超时后我们**无法知道**服务端是否收到。
32
+ * 原则是「未知结果必须人工核对,绝不自动重发」——官方配额下重发的代价更高
33
+ * (撞限流 + 重复消息)。
34
+ *
35
+ * 3. **`pending` 会在崩溃后残留。** 启动时扫一遍全部转 `unknown`。
36
+ * 不做这一步,重启后这些记录永远卡在「正在发送」。
37
+ */
38
+
39
+ import { createHash } from 'node:crypto';
40
+
41
+ export const DELIVERY_STATE = {
42
+ PENDING: 'pending',
43
+ SENT: 'sent',
44
+ AUDITED: 'audited',
45
+ FAILED: 'failed',
46
+ UNKNOWN: 'unknown',
47
+ };
48
+
49
+ const ALL_STATES = new Set(Object.values(DELIVERY_STATE));
50
+
51
+ /**
52
+ * 摘要:前 120 字 + 总长 + sha1 前 8 位。
53
+ *
54
+ * **不存全文**——沿用 `schema.js` 的原则「只存聚合结果,不存消息正文」。
55
+ * 全文只在事件账本里(且受 7 天保留策略约束)。
56
+ * 摘要的用途是「人工核对时认出是哪一条」,不是「重发时照抄」。
57
+ *
58
+ * @param {string} text
59
+ * @returns {string}
60
+ */
61
+ export function digestOf(text) {
62
+ const s = String(text ?? '');
63
+ const sha = createHash('sha1').update(s, 'utf8').digest('hex').slice(0, 8);
64
+ return `${s.slice(0, 120)}…(${s.length})#${sha}`;
65
+ }
66
+
67
+ /**
68
+ * @param {object} o
69
+ * @param {object} o.db StatsStore 的 DatabaseSync(库不可用时为 null)
70
+ * @param {string} o.botId
71
+ * @param {(m:string)=>void} [o.log]
72
+ */
73
+ export function createDeliveryLedger({ db, botId, log = () => {} }) {
74
+ const ready = () => Boolean(db);
75
+
76
+ /**
77
+ * 认领一次发送。
78
+ *
79
+ * 这是整个账本的入口:**同一个 requestId 调多少次,都只有第一次会真的发**。
80
+ *
81
+ * @param {object} p
82
+ * @param {string} p.requestId 幂等键,由调用方给(`qq_send` 生成)
83
+ * @param {'group'|'c2c'} p.kind
84
+ * @param {string} p.chatId
85
+ * @param {string} [p.replyTo] 被动回复时的 msg_id
86
+ * @param {number} [p.msgSeq]
87
+ * @param {string} p.digest
88
+ * @returns {{action:'send'|'reuse'|'unknown'|'wait', row?:object}}
89
+ * - `send` 没发过 / 上次 failed,可以发
90
+ * - `reuse` 已经 sent/audited,直接返回上次结果
91
+ * - `unknown` 上次结果未知,**不能重发**
92
+ * - `wait` 正在发送中(并发调用)
93
+ */
94
+ function claim({ requestId, kind, chatId, replyTo, msgSeq, digest }) {
95
+ if (!ready()) return { action: 'send' };
96
+ const id = String(requestId ?? '').trim();
97
+ if (!id) return { action: 'send' };
98
+
99
+ try {
100
+ const row = db.prepare('SELECT * FROM deliveries WHERE request_id = ?').get(id);
101
+ if (!row) {
102
+ db.prepare(
103
+ `INSERT INTO deliveries
104
+ (request_id, bot_id, kind, chat_id, reply_to, msg_seq, digest, state, attempts, created_at)
105
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
106
+ ).run(
107
+ id,
108
+ botId,
109
+ String(kind ?? ''),
110
+ String(chatId ?? ''),
111
+ replyTo ?? null,
112
+ msgSeq ?? null,
113
+ String(digest ?? ''),
114
+ DELIVERY_STATE.PENDING,
115
+ 0,
116
+ Date.now(),
117
+ );
118
+ return { action: 'send' };
119
+ }
120
+
121
+ if (row.state === DELIVERY_STATE.SENT || row.state === DELIVERY_STATE.AUDITED) {
122
+ return { action: 'reuse', row };
123
+ }
124
+ if (row.state === DELIVERY_STATE.UNKNOWN) return { action: 'unknown', row };
125
+ if (row.state === DELIVERY_STATE.PENDING) return { action: 'wait', row };
126
+
127
+ // failed:允许重试,复用同一行(不新建,否则同一个 requestId 会散成好几条)
128
+ db.prepare(
129
+ 'UPDATE deliveries SET state = ?, attempts = attempts + 1, code = NULL, settled_at = NULL WHERE request_id = ?',
130
+ ).run(DELIVERY_STATE.PENDING, id);
131
+ return { action: 'send', row };
132
+ } catch (e) {
133
+ /**
134
+ * 账本坏了不能把消息也堵死。
135
+ * 记账是增强,发送是主链路——这里退回「照发」,并留一条日志。
136
+ */
137
+ log(`投递账本认领失败(照常发送):${e.message}`);
138
+ return { action: 'send' };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 结算。
144
+ *
145
+ * @param {string} requestId
146
+ * @param {string} state `sent` / `audited` / `failed` / `unknown`
147
+ * @param {{msgId?:string, code?:string}} [extra]
148
+ */
149
+ function settle(requestId, state, { msgId, code } = {}) {
150
+ if (!ready()) return false;
151
+ const id = String(requestId ?? '').trim();
152
+ if (!id) return false;
153
+ if (!ALL_STATES.has(state) || state === DELIVERY_STATE.PENDING) {
154
+ log(`投递账本结算状态不合法:${state}`);
155
+ return false;
156
+ }
157
+ try {
158
+ const r = db
159
+ .prepare(
160
+ 'UPDATE deliveries SET state = ?, msg_id = ?, code = ?, settled_at = ? WHERE request_id = ?',
161
+ )
162
+ .run(state, msgId ?? null, code ?? null, Date.now(), id);
163
+ /**
164
+ * 返回**是否真的更新了一行**。
165
+ *
166
+ * 结算一个不存在的 requestId 会静默 0 行——以前这里无条件 `return true`,
167
+ * 调用方以为结算成功,实际那条记录还是 pending,重启后又被转成 unknown。
168
+ */
169
+ return Number(r?.changes ?? 0) > 0;
170
+ } catch (e) {
171
+ log(`投递账本结算失败:${e.message}`);
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 崩溃恢复:把所有 `pending` 转 `unknown`。
178
+ *
179
+ * 只在**启动时**调。那时没有任何发送在跑,所以 `pending` 一定是上次崩了
180
+ * 留下的——那些消息我们不知道发没发出去,只能标未知,等人核对。
181
+ *
182
+ * @returns {number} 转了多少条
183
+ */
184
+ function recoverPending() {
185
+ if (!ready()) return 0;
186
+ try {
187
+ const r = db
188
+ .prepare(
189
+ `UPDATE deliveries SET state = ?, code = 'crash', settled_at = ? WHERE state = ?`,
190
+ )
191
+ .run(DELIVERY_STATE.UNKNOWN, Date.now(), DELIVERY_STATE.PENDING);
192
+ const n = Number(r?.changes ?? 0);
193
+ if (n > 0) log(`${n} 条投递记录在崩溃前没结算,已标为未知(不会自动重发)`);
194
+ return n;
195
+ } catch (e) {
196
+ log(`投递账本恢复失败:${e.message}`);
197
+ return 0;
198
+ }
199
+ }
200
+
201
+ /** 查一条(面板 / 跨进程桥 3 用) */
202
+ function get(requestId) {
203
+ if (!ready()) return null;
204
+ try {
205
+ return db.prepare('SELECT * FROM deliveries WHERE request_id = ?').get(String(requestId ?? '')) ?? null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** 最近若干条,面板用 */
212
+ function recent({ limit = 50, state = '' } = {}) {
213
+ if (!ready()) return [];
214
+ try {
215
+ const n = Math.min(200, Math.max(1, Number(limit) || 50));
216
+ return state
217
+ ? db.prepare('SELECT * FROM deliveries WHERE state = ? ORDER BY created_at DESC LIMIT ?').all(state, n)
218
+ : db.prepare('SELECT * FROM deliveries ORDER BY created_at DESC LIMIT ?').all(n);
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /** 统计:每种状态多少条。面板卡片用 */
225
+ function stats() {
226
+ if (!ready()) return { available: false };
227
+ try {
228
+ const rows = db.prepare('SELECT state, COUNT(*) AS c FROM deliveries GROUP BY state').all();
229
+ const by = Object.fromEntries(rows.map((r) => [r.state, Number(r.c)]));
230
+ return { available: true, by, total: Object.values(by).reduce((a, b) => a + b, 0) };
231
+ } catch {
232
+ return { available: false };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 审核事件结算(`MESSAGE_AUDIT_PASS` / `MESSAGE_AUDIT_REJECT`)。
238
+ *
239
+ * 官方在审核有结果时推事件,事件里带的是 `msg_id`(不是我们给的 requestId),
240
+ * 所以按 `msg_id` 反查。
241
+ *
242
+ * @param {string} msgId
243
+ * @param {boolean} passed
244
+ * @returns {boolean} 有没有找到并结算
245
+ */
246
+ function settleByMsgId(msgId, passed) {
247
+ if (!ready()) return false;
248
+ const id = String(msgId ?? '').trim();
249
+ if (!id) return false;
250
+ try {
251
+ const r = db
252
+ .prepare(
253
+ `UPDATE deliveries SET state = ?, code = ?, settled_at = ?
254
+ WHERE msg_id = ? AND state = ?`,
255
+ )
256
+ .run(
257
+ passed ? DELIVERY_STATE.SENT : DELIVERY_STATE.FAILED,
258
+ passed ? null : 'audit-reject',
259
+ Date.now(),
260
+ id,
261
+ DELIVERY_STATE.AUDITED,
262
+ );
263
+ return Number(r?.changes ?? 0) > 0;
264
+ } catch (e) {
265
+ log(`审核结算失败:${e.message}`);
266
+ return false;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * 一条逻辑消息可能对应好几行。
272
+ *
273
+ * 为什么:被动回复超过 1800 字时,发送层会切成几块各发一次,
274
+ * 每块一个幂等键(`${base}#1`、`${base}#2`…,见 ai-chat 的 `sendReply`)。
275
+ * 这是必须的——共用一个 id 的话第 2 块起会被 `claim` 判成 `reuse` 直接丢掉。
276
+ *
277
+ * 但「这条消息到底送没送到」问的是**整条**,不是某一块。所以这里把
278
+ * base 和它的所有分块聚合成一个状态。
279
+ *
280
+ * 聚合规则按「坏消息优先」:只要有一块 `unknown`,整条就是 `unknown`
281
+ * (**绝不能重发**——重发会把已经出去的那块再发一遍);有一块 `failed`
282
+ * 就是 `failed`(可重试,重试只会补没发出去的那块,因为 id 是确定的);
283
+ * 还在飞就是 `pending`;等审核是 `audited`;全成功才是 `sent`。
284
+ *
285
+ * @param {string} requestId base id
286
+ * @returns {null | {state:string, parts:number, msgId:string, code:string,
287
+ * attempts:number, at:number, rows:object[]}}
288
+ */
289
+ function family(requestId) {
290
+ if (!ready()) return null;
291
+ const base = String(requestId ?? '').trim();
292
+ if (!base) return null;
293
+ /**
294
+ * `LIKE` 里的 `%` `_` 要转义,否则 base 里带这两个字符会误匹配到别人的行。
295
+ * requestId 是我们自己生成的(`qs-…` / `sp-…`),正常不会有,
296
+ * 但这里是公共入口,不能靠上游自觉。
297
+ */
298
+ const esc = base.replace(/[\\%_]/g, (m) => `\\${m}`);
299
+ try {
300
+ const rows = db
301
+ .prepare(`SELECT * FROM deliveries WHERE request_id = ? OR request_id LIKE ? ESCAPE '\\' ORDER BY request_id`)
302
+ .all(base, `${esc}#%`);
303
+ if (!rows.length) return null;
304
+
305
+ const rank = {
306
+ [DELIVERY_STATE.SENT]: 0,
307
+ [DELIVERY_STATE.AUDITED]: 1,
308
+ [DELIVERY_STATE.PENDING]: 2,
309
+ [DELIVERY_STATE.FAILED]: 3,
310
+ [DELIVERY_STATE.UNKNOWN]: 4,
311
+ };
312
+ /**
313
+ * 认不出的状态**不参与比较**,不能给它一个默认名次。
314
+ *
315
+ * 给 0(等于 sent)会让一条状态坏掉的行被当成已送达;
316
+ * 给 99(等于最坏)又会让它压过真正的 `unknown`,把「绝对不能重发」
317
+ * 这条信息淹掉。两种情况都会让人对着一个假答案往下走。
318
+ *
319
+ * 正常不会出现(`settle` 会校验状态),但账本是跨版本存活的,
320
+ * 老版本写过什么值谁也不敢保证。
321
+ */
322
+ const known = rows.filter((r) => r.state in rank);
323
+ const pool = known.length ? known : rows;
324
+ let worst = pool[0];
325
+ for (const r of pool) if (rank[r.state] > rank[worst.state]) worst = r;
326
+ if (!known.length) log(`投递账本里有认不出的状态:${rows.map((r) => r.state).join('、')}`);
327
+
328
+ /** 报最坏那块的 msg_id / code,人核对时想知道的是「哪块出问题了」 */
329
+ const at = rows.reduce((m, r) => Math.max(m, Number(r.settled_at || r.created_at || 0)), 0);
330
+ return {
331
+ state: worst.state,
332
+ parts: rows.length,
333
+ msgId: worst.msg_id || '',
334
+ code: worst.code || '',
335
+ attempts: rows.reduce((m, r) => Math.max(m, Number(r.attempts || 0)), 0),
336
+ at,
337
+ rows,
338
+ };
339
+ } catch (e) {
340
+ log(`投递账本家族查询失败:${e.message}`);
341
+ return null;
342
+ }
343
+ }
344
+
345
+ return { claim, settle, settleByMsgId, recoverPending, get, family, recent, stats };
346
+ }
package/src/message.js CHANGED
@@ -365,7 +365,15 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
365
365
  // 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
366
366
  const send = (opts, passive) => {
367
367
  if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
368
- const body = passive ? { msgId: msg.id, ...opts } : { ...opts };
368
+ /**
369
+ * `requestId` **不是官方字段**,要从 body 里摘出来单独传给 outbound——
370
+ * 混进去会被 api.js 当成未知参数一路带到接口上。
371
+ *
372
+ * 它是投递账本的幂等键(阶段 5):同一个 id 再调一次只发一条。
373
+ * 没有它就退回原来的「发一次算一次」。
374
+ */
375
+ const { requestId, ...rest } = opts ?? {};
376
+ const body = passive ? { msgId: msg.id, ...rest } : { ...rest };
369
377
  msg.replied = true;
370
378
 
371
379
  if (!outbound) {
@@ -378,6 +386,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
378
386
  chatKey: `${kind}:${chatId}`,
379
387
  payload: body,
380
388
  receivedAt,
389
+ ...(requestId ? { requestId: String(requestId) } : {}),
381
390
  });
382
391
  };
383
392
 
@@ -540,11 +549,17 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
540
549
  return send({ content: String(text), ...opts }, false);
541
550
  };
542
551
 
543
- /** Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉 */
544
- msg.replyMarkdown = async (markdown, keyboard) => {
552
+ /**
553
+ * Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉。
554
+ *
555
+ * `opts.requestId`:投递账本的幂等键。以前这条路完全绕开账本——
556
+ * 插件给了 id 也只对纯文本生效,markdown 那条永远记不上,重发就会重复。
557
+ * 第三个参数是可选的,老调用方不传,行为不变。
558
+ */
559
+ msg.replyMarkdown = async (markdown, keyboard, opts = {}) => {
545
560
  await msg.flushReplies();
546
561
  try {
547
- return await send({ markdown, keyboard }, true);
562
+ return await send({ markdown, keyboard, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true);
548
563
  } catch (e) {
549
564
  /**
550
565
  * Markdown 是**独立消息类型**,没有纯文本兜底:平台不放行(没权限、
@@ -572,7 +587,13 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
572
587
  * 按钮里的指令本来也会出现在正文里(ctx.menu 渲染时带上),信息不丢。
573
588
  */
574
589
  log.warn(`Markdown 被拒(${e.message}),降级为纯文本重发`);
575
- return send({ content: plain }, true).catch((e2) => {
590
+ /**
591
+ * 降级重发**沿用同一个 requestId**:对用户来说这仍然是「一条消息」,
592
+ * 账本里就该只有一行。上一步抛错时已经结算成 `failed`(可重试),
593
+ * 所以这里 claim 会放行并复用同一行,最终状态是 `sent`。
594
+ * 万一是 `unknown`,claim 会抛错——那正是我们要的「不重复发」。
595
+ */
596
+ return send({ content: plain, ...(opts?.requestId ? { requestId: opts.requestId } : {}) }, true).catch((e2) => {
576
597
  log.error('降级纯文本也没发出去:', e2.message);
577
598
  return null;
578
599
  });
package/src/outbound.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createLogger } from './logger.js';
4
+ import { DELIVERY_STATE, digestOf } from './delivery.js';
4
5
  import { DIRS } from './paths.js';
5
6
 
6
7
  const log = createLogger('outbound');
@@ -103,6 +104,23 @@ export class SendError extends Error {
103
104
  }
104
105
  }
105
106
 
107
+ /**
108
+ * 这次失败是「确定没发出去」还是「不知道发没发出去」。
109
+ *
110
+ * 只有 `api` 这一类可能不确定:超时、5xx、连接重置——请求发出去了但响应没回来。
111
+ * 其余四种(`offline` / `expired` / `quota` / `rate`)都是**本地判定**,
112
+ * 消息一定没发出去,记 `failed` 可以放心重试。
113
+ *
114
+ * 判错的代价不对称:把「确定失败」误判成 unknown,用户要人工核对(麻烦但安全);
115
+ * 把 unknown 误判成「确定失败」,就会重发一条可能已经发出去的消息(重复打扰)。
116
+ * 所以这里宁可保守——拿不准就当 unknown。
117
+ */
118
+ function uncertainOf(e) {
119
+ if (e?.code !== 'api') return false;
120
+ const s = String(e?.detail ?? e?.message ?? '');
121
+ return /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|socket hang up|aborted|\b5\d\d\b/i.test(s);
122
+ }
123
+
106
124
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
107
125
 
108
126
  /** 本地日期键,按用户所在时区算,不用 UTC 免得跨天错位 */
@@ -200,8 +218,10 @@ export class Outbound {
200
218
  * @param {object} opts.cfg 全局配置
201
219
  * @param {() => boolean} opts.isOnline 机器人是否已连上网关
202
220
  */
203
- constructor({ api, cfg, isOnline = () => true, quotaFile }) {
221
+ constructor({ api, cfg, isOnline = () => true, quotaFile, delivery = null }) {
204
222
  this.api = api;
223
+ /** 投递账本(阶段 5)。没有它就退回原来的「发一次算一次」 */
224
+ this.delivery = delivery;
205
225
  this.cfg = cfg;
206
226
  this.isOnline = isOnline;
207
227
 
@@ -257,13 +277,91 @@ export class Outbound {
257
277
  * @param {number} [p.receivedAt] 触发本次回复的消息接收时间
258
278
  * @returns {Promise<{id?:string, pending?:boolean, degraded?:boolean}>}
259
279
  */
260
- async send({ kind, target, chatKey, payload, receivedAt }) {
280
+ /**
281
+ * 发送。**有 `requestId` 就走投递账本,没有就照旧发一次。**
282
+ *
283
+ * 为什么不是「所有发送都记账」:`requestId` 由调用方生成,只有它知道
284
+ * 「一段话」的语义边界(`qq_send` 每调一次是一段独立消息)。插件直接
285
+ * `msg.reply()` 那种没有 id,按原行为走——被动回复本身有 `msg_seq` 兜底。
286
+ *
287
+ * 账本是**增强**不是主链路:`claim` 失败一律退回「照发」,不能让记账把消息堵死。
288
+ */
289
+ async send(opts) {
290
+ const requestId = String(opts?.requestId ?? '').trim();
291
+ if (!requestId || !this.delivery) return this.#sendOnce(opts);
292
+
293
+ const c = this.delivery.claim({
294
+ requestId,
295
+ kind: opts.kind,
296
+ chatId: opts.target,
297
+ replyTo: opts.payload?.msgId,
298
+ msgSeq: opts.payload?.msgSeq,
299
+ digest: digestOf(opts.payload?.content ?? opts.payload?.text ?? ''),
300
+ });
301
+
302
+ if (c.action === 'reuse') {
303
+ // 已经发过:直接回上次的结果,**不再打接口**
304
+ return {
305
+ id: c.row?.msg_id || '',
306
+ reused: true,
307
+ pending: c.row?.state === DELIVERY_STATE.AUDITED,
308
+ requestId,
309
+ };
310
+ }
311
+ if (c.action === 'unknown') {
312
+ /**
313
+ * 上次结果未知,**绝不自动重发**。
314
+ *
315
+ * 官方配额下重发的代价是双份:可能重复发一条,还会撞上限流。
316
+ * 宁可让人去面板核对一次。
317
+ */
318
+ throw new SendError(
319
+ '这条消息上次的发送结果未知(超时或进程崩溃),不会自动重发。请到面板「投递」页核对',
320
+ 'unknown',
321
+ );
322
+ }
323
+ if (c.action === 'wait') {
324
+ throw new SendError('这条消息正在发送中,别重复调用', 'pending');
325
+ }
326
+
327
+ try {
328
+ const r = await this.#sendOnce(opts);
329
+ this.delivery.settle(
330
+ requestId,
331
+ r?.pending ? DELIVERY_STATE.AUDITED : DELIVERY_STATE.SENT,
332
+ { msgId: r?.id },
333
+ );
334
+ return { ...r, requestId };
335
+ } catch (e) {
336
+ /**
337
+ * 失败也要结算,否则这一行永远卡在 `pending`。
338
+ *
339
+ * `unknown` 只给「不知道发没发出去」的情况(超时 / 5xx / 连接重置)。
340
+ * `offline` / `expired` / `quota` / `rate` 是**确定没发出去**,记 `failed` 允许重试。
341
+ */
342
+ this.delivery.settle(requestId, uncertainOf(e) ? DELIVERY_STATE.UNKNOWN : DELIVERY_STATE.FAILED, {
343
+ code: e?.code || 'error',
344
+ });
345
+ throw e;
346
+ }
347
+ }
348
+
349
+ async #sendOnce({ kind, target, chatKey, payload, receivedAt }) {
261
350
  if (kind !== 'group' && kind !== 'c2c') {
262
351
  throw new SendError(`不支持的会话类型: ${kind}`, 'api');
263
352
  }
264
353
  if (!this.isOnline()) {
265
354
  this.stats.offline += 1;
266
- throw new SendError('机器人当前没有连上 QQ,消息发不出去', 'offline');
355
+ /**
356
+ * 错误消息要**带上下一步怎么办**。
357
+ *
358
+ * 只解释原因(「没连上」)时,调用方只能自己猜是重试还是放弃;
359
+ * 写明「等它上线」,它就知道这类错误不该重试。
360
+ */
361
+ throw new SendError(
362
+ '机器人当前没有连上 QQ,消息发不出去。等它重新上线再试(面板「运行」页能看状态)。',
363
+ 'offline',
364
+ );
267
365
  }
268
366
 
269
367
  const body = { ...payload };
@@ -351,7 +449,7 @@ export class Outbound {
351
449
  if (!this.#allowActiveThisMinute(chatKey)) {
352
450
  this.stats.rateBlocked += 1;
353
451
  throw new SendError(
354
- `这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),稍后再试。`,
452
+ `这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),**等 1 分钟**就好。`,
355
453
  'rate',
356
454
  );
357
455
  }
@@ -39,6 +39,23 @@ function readOnlyLedger(ledger) {
39
39
  *
40
40
  * `by` 记成 `plugin:<id>`,面板上一眼能看出是哪个插件停的。
41
41
  */
42
+ /**
43
+ * 给插件的投递账本视图:**只能标记失败,不能标记成功**。
44
+ *
45
+ * 为什么只给 `fail`:`settle(requestId, 'sent')` 一暴露,插件就能把一条
46
+ * 根本没发出去的消息标成「已发送」——而账本的全部价值就是「记录的事实」。
47
+ * 插件唯一正当的需求是「这段我不发了」,所以只给这一个动作。
48
+ */
49
+ function readOnlyDelivery(ledger) {
50
+ return Object.freeze({
51
+ /** 标记这条没发出去(截断、被跳过),允许以后重试 */
52
+ fail: (requestId, code = 'plugin') =>
53
+ ledger.settle(String(requestId ?? ''), 'failed', { code }),
54
+ /** 查一条(只读) */
55
+ get: (requestId) => ledger.get(requestId),
56
+ });
57
+ }
58
+
42
59
  function readOnlyGate(gate, pluginId) {
43
60
  const by = `plugin:${pluginId}`;
44
61
  return Object.freeze({
@@ -348,7 +365,7 @@ export class PluginStorage {
348
365
  /**
349
366
  * 插件上下文:插件能用的全部能力都在这里,不暴露内部实现。
350
367
  */
351
- export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, reach, ledger, gate, isOwner, pages = null, routes = null, state = null }) {
368
+ export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, reach, ledger, gate, delivery, isOwner, pages = null, routes = null, state = null }) {
352
369
  const id = meta.id;
353
370
  // 日志 tag 带机器人 id,多机器人下同名插件的日志不会串
354
371
  const logger = createLogger(`plugin:${paths?.id ?? '-'}:${id}`);
@@ -416,6 +433,8 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
416
433
  * 所以一定要能带时长,`ms <= 0` 表示解除。
417
434
  */
418
435
  gate: gate ? readOnlyGate(gate, id) : null,
436
+ /** 投递账本(阶段 5):只能 `fail`,见 readOnlyDelivery */
437
+ delivery: delivery ? readOnlyDelivery(delivery) : null,
419
438
  storage: new PluginStorage(paths.pluginData(id)),
420
439
 
421
440
  /**
@@ -773,13 +792,18 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
773
792
  *
774
793
  * await ctx.push(groupOpenid, '今天的魂兽森林刷新了')
775
794
  */
776
- push(chatId, text, { kind = 'group' } = {}) {
795
+ push(chatId, text, { kind = 'group', requestId = '' } = {}) {
777
796
  if (!outbound) throw new Error('当前环境不支持主动推送');
778
797
  return outbound.send({
779
798
  kind,
780
799
  target: chatId,
781
800
  chatKey: `${kind}:${chatId}`,
782
801
  payload: { content: String(text ?? '') },
802
+ /**
803
+ * 幂等键(阶段 5)。同一个 id 再推一次只发一条——
804
+ * 调用方(`qq_send`)知道「一段话」的语义边界,只有它给得出这个 id。
805
+ */
806
+ ...(requestId ? { requestId: String(requestId) } : {}),
783
807
  });
784
808
  },
785
809
 
@@ -27,7 +27,7 @@ const log = createLogger('plugins');
27
27
  * 启用状态存在 data/bots/<机器人id>/plugins.json,删掉文件夹即等于卸载。
28
28
  */
29
29
  export class PluginManager {
30
- constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ledger, gate, ignoreCompat }) {
30
+ constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ledger, gate, delivery, ignoreCompat }) {
31
31
  this.dir = pluginsDir;
32
32
  // 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
33
33
  this.paths = paths;
@@ -47,6 +47,8 @@ export class PluginManager {
47
47
  this.ledger = ledger;
48
48
  /** 暂停闸(只给会话级,见 context.js 的 readOnlyGate),见 src/gate.js */
49
49
  this.gate = gate;
50
+ /** 投递账本(只给 fail,见 context.js 的 readOnlyDelivery),见 src/delivery.js */
51
+ this.delivery = delivery;
50
52
  this.isOwner = isOwner;
51
53
  /** 跳过插件声明的 frameworkVersion 校验(作者本地调试用) */
52
54
  this.ignoreCompat = Boolean(ignoreCompat);
@@ -416,6 +418,7 @@ export class PluginManager {
416
418
  reach: this.reach,
417
419
  ledger: this.ledger,
418
420
  gate: this.gate,
421
+ delivery: this.delivery,
419
422
  isOwner: this.isOwner,
420
423
  pages: this.pages,
421
424
  routes: this.routes,
@@ -14,7 +14,7 @@
14
14
  * 那个存群,这个存人。
15
15
  */
16
16
 
17
- export const SCHEMA_VERSION = 1;
17
+ export const SCHEMA_VERSION = 2;
18
18
 
19
19
  const V1 = `
20
20
  CREATE TABLE IF NOT EXISTS messages (
@@ -60,6 +60,35 @@ CREATE TABLE IF NOT EXISTS commands (
60
60
  );
61
61
  `;
62
62
 
63
+ /**
64
+ * v2:投递账本(阶段 5)。
65
+ *
66
+ * 为什么放在统计库而不是事件账本库:deliveries **不含正文**(只有 `digest` 摘要),
67
+ * 和 `stats` 同一个隐私口径(「只存聚合结果」),生命周期也一样(bot 侧、跟着机器人走)。
68
+ * events 库是 7 天滚动删的全文库,投递记录要活得更久。
69
+ *
70
+ * `digest` 不存全文,沿用 `schema.js` 的原则。全文只在事件账本里。
71
+ */
72
+ const V2 = `
73
+ CREATE TABLE IF NOT EXISTS deliveries (
74
+ request_id TEXT PRIMARY KEY, -- 幂等键,由调用方给(qq_send 生成)
75
+ bot_id TEXT NOT NULL,
76
+ kind TEXT NOT NULL, -- group / c2c
77
+ chat_id TEXT NOT NULL,
78
+ reply_to TEXT, -- 被动回复时的 msg_id
79
+ msg_seq INTEGER, -- 群 1..5 / 单聊 1..4,官方硬限制
80
+ digest TEXT NOT NULL, -- 前 120 字 + 总长 + sha1 前 8 位
81
+ state TEXT NOT NULL, -- pending/sent/audited/failed/unknown
82
+ msg_id TEXT,
83
+ code TEXT,
84
+ attempts INTEGER NOT NULL DEFAULT 0,
85
+ created_at INTEGER NOT NULL,
86
+ settled_at INTEGER
87
+ );
88
+ CREATE INDEX IF NOT EXISTS deliveries_chat ON deliveries(bot_id, chat_id, created_at);
89
+ CREATE INDEX IF NOT EXISTS deliveries_open ON deliveries(state) WHERE state IN ('pending','unknown');
90
+ `;
91
+
63
92
  /**
64
93
  * 把库升到最新结构。
65
94
  * @param {object} db DatabaseSync
@@ -68,8 +97,9 @@ export function migrate(db) {
68
97
  const cur = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
69
98
  if (cur >= SCHEMA_VERSION) return { from: cur, to: cur };
70
99
 
71
- // 以后加表就往这里追加:if (cur < 2) { ... }
100
+ // 以后加表就往这里追加:if (cur < 3) { ... }
72
101
  if (cur < 1) db.exec(V1);
102
+ if (cur < 2) db.exec(V2);
73
103
 
74
104
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
75
105
  return { from: cur, to: SCHEMA_VERSION };
package/src/web/server.js CHANGED
@@ -2433,6 +2433,30 @@ export async function startWebServer({
2433
2433
  return json(res, 400, { error: `不认识的 scope:${scope}(global / bot / chat / silence)` });
2434
2434
  }
2435
2435
 
2436
+ /* ---------------- 投递账本 ---------------- */
2437
+
2438
+ /**
2439
+ * `GET /api/delivery?requestId=` 查一条;不带就列最近的。
2440
+ *
2441
+ * 给「这条到底发出去没有」一个可查的地方——`unknown` 状态的全部意义
2442
+ * 就是让人能核对,没有这个接口那个状态就只能靠翻日志。
2443
+ */
2444
+ if (pathname === '/api/delivery' && req.method === 'GET') {
2445
+ const rid = String(url.searchParams.get('requestId') ?? '').trim();
2446
+ if (rid) {
2447
+ const row = rt.delivery?.get(rid) ?? null;
2448
+ if (!row) return json(res, 404, { ok: false, error: '没有这条投递记录' });
2449
+ return json(res, 200, { ok: true, item: row });
2450
+ }
2451
+ const limit = Number(url.searchParams.get('limit')) || 50;
2452
+ const state = String(url.searchParams.get('state') ?? '').trim();
2453
+ return json(res, 200, {
2454
+ ok: true,
2455
+ items: rt.delivery?.recent({ limit, state }) ?? [],
2456
+ stats: rt.delivery?.stats() ?? { available: false },
2457
+ });
2458
+ }
2459
+
2436
2460
  // 网关操作
2437
2461
  if (pathname === '/api/gateway/reconnect' && req.method === 'POST') {
2438
2462
  // 已经下线时没有「重连」可言,直接当上线处理,免得点了没反应
@@ -2465,7 +2489,7 @@ export async function startWebServer({
2465
2489
  */
2466
2490
  if (pathname === '/api/send' && req.method === 'POST') {
2467
2491
  const body = await readBody(req);
2468
- const { scene, target, content } = body;
2492
+ const { scene, target, content, requestId } = body;
2469
2493
  if (!target || !content) return json(res, 400, { error: '需要 target 和 content' });
2470
2494
  const kind = scene === 'c2c' ? 'c2c' : 'group';
2471
2495
  const sender = kind === 'c2c' ? api?.sendC2C : api?.sendGroup;
@@ -2478,9 +2502,20 @@ export async function startWebServer({
2478
2502
  target,
2479
2503
  chatKey: `${kind}:${target}`,
2480
2504
  payload: { content },
2505
+ /**
2506
+ * 幂等键(阶段 5)。面板重试、脚本重放时带上同一个 id,
2507
+ * 第二次不会真的再发一遍,直接回上次的结果。
2508
+ */
2509
+ ...(requestId ? { requestId: String(requestId) } : {}),
2481
2510
  });
2482
2511
  // pending = 主动消息进了审核。既不是失败,也不是「已经送达」
2483
- return json(res, 200, { ok: true, id: r?.id ?? '', pending: Boolean(r?.pending) });
2512
+ return json(res, 200, {
2513
+ ok: true,
2514
+ id: r?.id ?? '',
2515
+ pending: Boolean(r?.pending),
2516
+ // reused = 这条 requestId 之前已经发过,这次没打接口
2517
+ ...(r?.reused ? { reused: true } : {}),
2518
+ });
2484
2519
  } catch (e) {
2485
2520
  const hint = userFacingSendError(e);
2486
2521
  if (!hint) throw e; // 真正的接口错误交给外层兜底(500 + 原始信息)
package/web/app.js CHANGED
@@ -233,6 +233,7 @@ async function selectBot(id) {
233
233
  if (tab === 'plugins') loadPlugins();
234
234
  if (tab === 'events') loadEvents();
235
235
  if (tab === 'reach') loadReach();
236
+ if (tab === 'delivery') loadDelivery();
236
237
  if (tab === 'groups') loadGroups();
237
238
  if (tab === 'roles') { loadRoles(); loadPeople(); }
238
239
  if (tab === 'stats') loadStats();
@@ -599,6 +600,7 @@ function activateTab(name) {
599
600
  if (name === 'config') loadConfig();
600
601
  if (name === 'groups') loadGroups();
601
602
  if (name === 'reach') loadReach();
603
+ if (name === 'delivery') loadDelivery();
602
604
  if (name === 'roles') { loadRoles(); loadPeople(); }
603
605
  }
604
606
 
@@ -3040,6 +3042,71 @@ function triSelect(field, v, yes = '是', no = '否') {
3040
3042
  }</select>`;
3041
3043
  }
3042
3044
 
3045
+ /* ---------------- 投递账本 ---------------- */
3046
+
3047
+ /**
3048
+ * 状态文案。
3049
+ *
3050
+ * `unknown` 是唯一需要人动手的状态——**它不会自动重发**,
3051
+ * 所以文案要把「去核对」写清楚,而不是只显示「未知」。
3052
+ */
3053
+ const DELIVERY_LABEL = {
3054
+ pending: { text: '发送中', tip: '正在发' },
3055
+ sent: { text: '已发送', tip: '确认送达' },
3056
+ audited: { text: '审核中', tip: '主动消息进了审核,既不是成功也不是失败' },
3057
+ failed: { text: '失败', tip: '确定没发出去,可以重试' },
3058
+ unknown: { text: '结果未知', tip: '超时或崩溃,不知道服务端收没收到。不会自动重发,需要人工核对' },
3059
+ };
3060
+
3061
+ async function loadDelivery() {
3062
+ const body = $('#deliveryBody');
3063
+ if (!body) return;
3064
+ try {
3065
+ renderDelivery(await api('/api/delivery?limit=50'));
3066
+ } catch (e) {
3067
+ body.innerHTML = `<tr><td colspan="6" class="empty">加载失败: ${escapeHtml(e.message)}</td></tr>`;
3068
+ }
3069
+ }
3070
+
3071
+ function renderDelivery(d) {
3072
+ const stats = d?.stats ?? {};
3073
+ const el = $('#deliveryStats');
3074
+ if (el) {
3075
+ const by = stats.by ?? {};
3076
+ el.innerHTML = Object.keys(DELIVERY_LABEL)
3077
+ .map((k) => {
3078
+ const v = by[k] ?? 0;
3079
+ // 有值才显眼:全 0 的时候整排彩色卡片只会让人以为出事了
3080
+ const hot = k === 'unknown' && v > 0;
3081
+ return `<div class="card"><div class="k">${DELIVERY_LABEL[k].text}</div>`
3082
+ + `<div class="v"${hot ? ' style="color:var(--warn-text)"' : ''}>${v}</div></div>`;
3083
+ })
3084
+ .join('');
3085
+ }
3086
+
3087
+ const body = $('#deliveryBody');
3088
+ const items = d?.items ?? [];
3089
+ if (!items.length) {
3090
+ body.innerHTML = '<tr><td colspan="6" class="empty">还没有记录(只有带幂等键的发送才记账)</td></tr>';
3091
+ return;
3092
+ }
3093
+ body.innerHTML = items
3094
+ .map((x) => {
3095
+ const st = DELIVERY_LABEL[x.state] ?? { text: x.state, tip: '' };
3096
+ const when = x.created_at ? new Date(Number(x.created_at)).toLocaleString('zh-CN', { hour12: false }) : '-';
3097
+ const warn = x.state === 'unknown' || x.state === 'failed';
3098
+ return `<tr>
3099
+ <td>${escapeHtml(when)}</td>
3100
+ <td class="mono">${escapeHtml(String(x.chat_id ?? '').slice(0, 12))}</td>
3101
+ <td${warn ? ' style="color:var(--warn-text)"' : ''} title="${escapeHtml(st.tip)}">${escapeHtml(st.text)}</td>
3102
+ <td>${escapeHtml(String(x.digest ?? '').slice(0, 60))}</td>
3103
+ <td class="num">${Number(x.attempts ?? 0)}</td>
3104
+ <td class="mono">${escapeHtml(String(x.code ?? ''))}</td>
3105
+ </tr>`;
3106
+ })
3107
+ .join('');
3108
+ }
3109
+
3043
3110
  async function loadReach() {
3044
3111
  const ub = $('#reachUserBody');
3045
3112
  const gb = $('#reachGroupBody');
package/web/index.html CHANGED
@@ -57,6 +57,7 @@
57
57
  <button class="tab-item" data-tab="stats">统计</button>
58
58
  <button class="tab-item" data-tab="events">事件</button>
59
59
  <button class="tab-item" data-tab="reach">可达性</button>
60
+ <button class="tab-item" data-tab="delivery">投递</button>
60
61
  <button class="tab-item" data-tab="logs">日志</button>
61
62
  </div>
62
63
  </div>
@@ -514,6 +515,34 @@
514
515
  </div>
515
516
  </section>
516
517
 
518
+ <!-- 投递账本 -->
519
+ <section id="tab-delivery" class="panel">
520
+ <div class="box">
521
+ <div class="box-hd">
522
+ <h3>投递账本</h3>
523
+ <span class="hint">带幂等键的消息发没发出去,这里查得到</span>
524
+ </div>
525
+ <ul class="tips">
526
+ <li><b>已发送</b>确认送达;<b>审核中</b>是主动消息进了审核——既不是成功也不是失败,
527
+ 等审核事件回来才会落定</li>
528
+ <li><b>结果未知</b>:超时或进程崩了,我们<b>不知道服务端收没收到</b>。
529
+ 这种<b>不会自动重发</b>——宁可让人核对一次,也不重复打扰</li>
530
+ <li><b>失败</b>:确定没发出去(离线 / 超窗 / 额度 / 限流),可以重试</li>
531
+ <li>只有<b>带幂等键</b>的发送才记账(`qq_send` 那类「一次调用 = 一段话」的)。
532
+ 插件直接回一句话不记账,靠 `msg_seq` 天然去重</li>
533
+ </ul>
534
+ <div class="cards" id="deliveryStats"></div>
535
+ <div class="table-wrap">
536
+ <table>
537
+ <thead>
538
+ <tr><th>时间</th><th>会话</th><th>状态</th><th>摘要</th><th class="num">重试</th><th>原因码</th></tr>
539
+ </thead>
540
+ <tbody id="deliveryBody"><tr><td colspan="6" class="empty">暂无数据</td></tr></tbody>
541
+ </table>
542
+ </div>
543
+ </div>
544
+ </section>
545
+
517
546
  <!-- 可达性 -->
518
547
  <section id="tab-reach" class="panel">
519
548
  <div class="box">