feat(schedule): 定时任务 8 项 + 用量快照/压缩重试/到期提醒(V17)

PRD 附录 E。每个任务都是两段式:先以平台身份(tenantId=0)扫候选,再逐个租户摆上下文
干活——写数据必须在第二段,否则 tenant_id 被写成 0,通知进平台租户且不报错。

V17 新建 sys_tenant_usage_snapshot(唯一索引保证幂等;tenant_id 必须显式写)并给
collect_file 补 process_retry_count / process_error / last_retry_time + 只索引
「还没处理出规范件」的部分索引;压缩失败重试必须封顶,否则原图损坏的文件会被每 10
分钟重试一次而永远不成功。

跨租户的候选查询全部集中在 ScheduleMapper 一个文件、一律 @InterceptorIgnore + 只读;
Map 结果的别名一律 AS "camelCase"(F-83:PG 会把未加引号的别名折成小写,
表现是任务照跑、日志一行 warn、什么都没做)。

验证:e2e-m7-schedule 38/38(含快照幂等与一致性、重试累加与封顶、到期提醒窗口)。
This commit is contained in:
2026-09-30 09:02:55 +08:00
parent efde4627eb
commit 9216bd66d2
8 changed files with 1193 additions and 0 deletions
@@ -0,0 +1,67 @@
-- ═══════════════════════════════════════════════════════════
-- V17 · 定时任务收尾所需的 schema(PRD 附录 E)
--
-- 这一版只加三样东西,都是"不做就实现不了剩下三项定时任务"的:
-- ① 文件处理失败的重试计数与失败原因("压缩失败重试"要用)
-- ② 租户用量快照表("用量统计"要用 —— 快照必须落库,
-- 否则"上个月的用量"永远算不出来:实时统计只能回答"现在多少")
--
-- ⚠️ 只加列/加表,**不改已有列** —— 滚动发布期间新旧两版同时在跑,
-- 改列类型会让旧实例在新库上出错(见 docs/09-部署与运维.md §2.3)。
-- ═══════════════════════════════════════════════════════════
-- ── ① 文件处理失败:重试计数 + 失败原因 ──────────────────────
ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS process_retry_count SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS process_error VARCHAR(256);
ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS last_retry_time TIMESTAMP;
COMMENT ON COLUMN collect_file.process_retry_count IS
'处理失败后的重试次数。上限 3(PRD 附录 E「压缩失败重试:重试 ≤3 次」)——
到上限就停,不然一个永远失败的图会被每 10 分钟重试一次,把磁盘与 CPU 白烧掉';
COMMENT ON COLUMN collect_file.process_error IS
'最近一次处理失败的原因(PRD FR-611「标记失败原因」)。
⚠️ 与"保留原图"配套:只留原图而不知道为什么失败,运维只能靠猜';
COMMENT ON COLUMN collect_file.last_retry_time IS '最近一次重试时间(观察重试是否在推进)';
-- 部分索引:只索引"还没处理出规范件"的行。
-- 失败是**少数**(正常上传一步就压好了),全表索引会白白变大。
CREATE INDEX IF NOT EXISTS idx_file_retry
ON collect_file (process_retry_count, last_retry_time)
WHERE deleted = 0 AND std_path IS NULL;
-- ── ② 租户用量快照 ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sys_tenant_usage_snapshot (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
period VARCHAR(16) NOT NULL,
student_count BIGINT NOT NULL DEFAULT 0,
storage_mb BIGINT NOT NULL DEFAULT 0,
ocr_count INTEGER NOT NULL DEFAULT 0,
sms_count INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE sys_tenant_usage_snapshot IS
'租户用量快照(PRD 附录 E「用量统计:每日 01:00」)。
⚠️ 为什么必须落库而不是每次实时算:
① 实时统计只能回答"现在多少",而计费与超额告警问的是"**上个月**用了多少";
② 历史快照能回答"用量是什么时候涨上去的" —— 招生季与毕业季的形状完全不同;
③ 存储量尤其重要:原始件 30 天后被清理,**清理前的占用在实时统计里再也看不到**';
COMMENT ON COLUMN sys_tenant_usage_snapshot.period IS
'统计周期,`2026-09`(月)或 `2026Q3`(季)—— 与 §6.7 的 period 同口径';
COMMENT ON COLUMN sys_tenant_usage_snapshot.student_count IS
'按证件号去重的学生数(同一个人多个任务只算一次)';
COMMENT ON COLUMN sys_tenant_usage_snapshot.storage_mb IS
'规范件体积合计(MB)。**不含原始件** —— 原始件 30 天后会被清掉,算进去会让曲线自己往下掉';
COMMENT ON COLUMN sys_tenant_usage_snapshot.ocr_count IS
'识别通道用量。一期恒为 0(M4 未接,等 OCR 选型),不编数字';
-- 一个租户一个周期一行:定时任务重复跑(或手工补跑)不会造出两行
CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_snapshot
ON sys_tenant_usage_snapshot (tenant_id, period);
-- 运营侧"某个月所有租户用了多少"的查询
CREATE INDEX IF NOT EXISTS idx_usage_snapshot_period
ON sys_tenant_usage_snapshot (period, tenant_id);