From 9216bd66d2d055849ee360f92c11976b1d920223 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=208=20=E9=A1=B9=20+=20=E7=94=A8=E9=87=8F=E5=BF=AB?= =?UTF-8?q?=E7=85=A7/=E5=8E=8B=E7=BC=A9=E9=87=8D=E8=AF=95/=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E6=8F=90=E9=86=92=EF=BC=88V17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(含快照幂等与一致性、重试累加与封顶、到期提醒窗口)。 --- .../fill/module/file/entity/CollectFile.java | 53 ++ .../fill/module/file/mapper/FileMapper.java | 54 ++ .../controller/ScheduleController.java | 54 ++ .../schedule/mapper/ScheduleMapper.java | 202 ++++++ .../module/schedule/service/ScheduleJobs.java | 647 ++++++++++++++++++ .../tenant/entity/SysTenantUsageSnapshot.java | 61 ++ .../module/tenant/mapper/SnapshotMapper.java | 55 ++ .../db/migration/V17__schedule_support.sql | 67 ++ 8 files changed, 1193 insertions(+) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/controller/ScheduleController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenantUsageSnapshot.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/SnapshotMapper.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V17__schedule_support.sql diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/entity/CollectFile.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/entity/CollectFile.java index 5c03fd7..4c704a4 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/entity/CollectFile.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/entity/CollectFile.java @@ -83,10 +83,63 @@ public class CollectFile extends TenantEntity { private String uploadIp; + /** + * 材料来源。**口径以 DDL 的列注释为准**(PRD 第 1894 行): + * {@code 1} 本人上传 / {@code 2} 班主任代传 / {@code 3} 管理员代传 / {@code 4} 整理器导入。 + * + *

⚠️ 接口设计 §3.6 的字段表把同一列写成 + * "1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传" —— 与 DDL 冲突。 + * 以 DDL 为准的理由:它是"这一列表示什么"的定义,已有数据(全为 1)也与它一致, + * 且 PC 端的 {@code FILE_SOURCE} 常量已经按 DDL 写了。差异记在 F-38。 + * + *

⚠️ 这一列在 V12 之前**从来没有被代码显式写过** —— + * 库里那 10 行 {@code source=1} 全部来自列默认值, + * 也就是"学生自己传的"与"老师代传的"在数据上完全区分不出来。V12 起写入侧显式赋值。 + */ private Integer source; + /** 代传人 user_id({@code source=2/3} 时有值)—— 审核时间轴据此显示「由某某老师代传」。 */ + private Long proxyBy; + + /** 代传原因(选填,会挂到审核时间轴与材料列表上给学生看到)。 */ + private String proxyReason; + public static final int NEED_MANUAL_NO = 0; public static final int VALIDATE_PENDING = 0; public static final int CURRENT = 1; public static final int NOT_CURRENT = 0; + + // ── 来源(契约 §10.6:代传必须留痕)──────────── + + /** 本人上传(学生自己在填报页传的)。 */ + public static final int SOURCE_SELF = 1; + /** 班主任代传。 */ + public static final int SOURCE_PROXY_TEACHER = 2; + /** 管理员代传。 */ + public static final int SOURCE_PROXY_ADMIN = 3; + /** 历史整理器导入。 */ + public static final int SOURCE_CLEANUP_IMPORT = 4; + + // ── 处理失败与重试(V17;PRD 附录 E「压缩失败重试」/ FR-611)──── + + /** 处理失败后的重试次数(上限 {@link #MAX_PROCESS_RETRY})。 */ + private Integer processRetryCount; + + /** 最近一次处理失败的原因(**要给人看**:只留原图而不知道为什么失败,运维只能靠猜)。 */ + private String processError; + + private java.time.LocalDateTime lastRetryTime; + + /** + * 重试上限。 + * + *

⚠️ 必须有上限:一个永远失败的图(原图本身损坏)如果不封顶, + * 会被每 10 分钟重试一次,把磁盘 IO 与 CPU 白烧掉,而**它永远不会成功**。 + */ + public static final int MAX_PROCESS_RETRY = 3; + + /** 是不是"别人替他传的"(时间轴与材料列表据此显示代传说明)。 */ + public boolean isProxied() { + return source != null && (source == SOURCE_PROXY_TEACHER || source == SOURCE_PROXY_ADMIN); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java index f396ca8..3ee128d 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; public interface FileMapper extends BaseMapper { @@ -92,4 +93,57 @@ public interface FileMapper extends BaseMapper { WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = 0 """) CollectFile selectForAudit(@Param("id") Long id, @Param("tenantId") Long tenantId); + + // ── 处理失败与重试(V17;PRD 附录 E「压缩失败重试」)────────── + + /** + * 处理失败、还可以重试的文件。 + * + *

候选条件的三段各有理由: + *

+ * + *

⚠️ 别名加双引号:返回 `Map`,未加引号的别名会被 PostgreSQL 折成小写(F-83)。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id AS "id", tenant_id AS "tenantId", raw_path AS "rawPath", + process_retry_count AS "processRetryCount" + FROM collect_file + WHERE deleted = 0 + AND is_current = 1 + AND (std_path IS NULL OR std_path = '') + AND raw_path IS NOT NULL + AND process_retry_count < #{maxRetry} + ORDER BY last_retry_time NULLS FIRST, id + LIMIT #{limit} + """) + java.util.List> listProcessFailed( + @Param("maxRetry") int maxRetry, @Param("limit") int limit); + + /** 重试成功:清掉失败原因与计数(它已经好了,计数留着会误导)。 */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE collect_file + SET process_retry_count = 0, process_error = NULL, last_retry_time = NOW(), + update_time = NOW() + WHERE id = #{id} + """) + int markRetryOk(@Param("id") Long id); + + /** 重试失败:累加计数并记下原因(PRD FR-611 的「标记失败原因」)。 */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE collect_file + SET process_retry_count = process_retry_count + 1, + process_error = #{error}, last_retry_time = NOW(), update_time = NOW() + WHERE id = #{id} + """) + int markRetryFailed(@Param("id") Long id, @Param("error") String error); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/controller/ScheduleController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/controller/ScheduleController.java new file mode 100644 index 0000000..9bf497a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/controller/ScheduleController.java @@ -0,0 +1,54 @@ +package cn.tianbaotong.fill.module.schedule.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.schedule.service.ScheduleJobs; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.RequirePerm; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 定时任务的**手工触发**入口(PRD 附录 E 的 9 项)。 + * + *

为什么必须有这个入口

+ * + *

定时任务的"等不起"是测试与运维的**共同**问题:验证"任务截止后会怎样" + * 总不能在测试里等到明天凌晨(F-37 已经为导出包清理踩过一次,当时加的是 + * {@code POST /exports/cleanup})。同一个理由、同一类做法,这里集中成一个入口。 + * + *

运维也需要它:某天凌晨的清理没跑成(服务在重启), + * 上午要知道"能不能手工补一次"而不是"等明天"。 + * + *

⚠️ 权限:它是**运维动作**,不是业务动作

+ * + *

用 `log:view:all`(运营侧排障权限)而不是任务/审核权限 —— + * 手工触发会**跨租户**跑(平台身份),普通管理员不该能触发它。 + */ +@Tag(name = "定时任务") +@RestController +@RequestMapping("/api/v1/schedules") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleJobs jobs; + + @PostMapping("/run") + @RequirePerm(PermissionDef.LOG_VIEW_ALL) + @Operation(summary = "手动触发一轮定时任务", + description = "`job` 取 `close`(任务自动截止)/ `urge`(自动催报)/ " + + "`timeout`(审核超时提醒)/ `raw`(原始件清理)。" + + "⚠️ 它是**运维动作**:以平台身份跨租户跑,所以用运营侧的排障权限," + + "而不是任务或审核权限") + public ApiResult> run(@RequestBody(required = false) Map body) { + String job = body == null || body.get("job") == null ? null : String.valueOf(body.get("job")); + String msg = jobs.runNow(job); + return ApiResult.ok(Map.of("job", job == null ? "" : job.trim(), "message", msg)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java new file mode 100644 index 0000000..fefd9ec --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/mapper/ScheduleMapper.java @@ -0,0 +1,202 @@ +package cn.tianbaotong.fill.module.schedule.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * 定时任务的**候选查询**(PRD 附录 E 的 9 项)。 + * + *

为什么这些查询要集中放、且全部 {@@code @InterceptorIgnore}

+ * + *

定时任务的处境与匿名接口一样:**线程里没有登录用户,租户拦截器会直接拒绝** + * ({@code 40101})。但它又必须**先看到所有租户的候选行**才知道要为哪些租户干活 —— + * 这与"匿名接口只能看一个租户"正好相反。 + * + *

所以这个类的规矩是(与 {@code module/mp/mapper/MpSceneMapper} 同一条): + *

    + *
  1. 关掉拦截器;
  2. + *
  3. **显式带 {@code tenant_id}** —— 要么是查询条件的一部分(跨租户扫描时不必带), + * 要么把 {@code tenant_id} 作为结果列返回,让调用方拿着它去摆上下文;
  4. + *
  5. **只做"找出候选",不在这里改数据** —— 改数据一律回到各自模块的 Service, + * 在正确的租户上下文里做。这样"关掉框架保护"的范围就被压到最小:只有 SELECT。
  6. + *
+ * + *

⚠️ 只读这件事是刻意的。一个能跨租户 UPDATE 的方法,写错一个条件就是全校数据被改。 + * + *

⚠️ 别名必须加双引号:{@code AS "tenantId"}

+ * + *

这些方法返回 {@code Map},而 **PostgreSQL 会把未加引号的别名折成小写** + * —— {@code AS tenantId} 在结果里是 {@code tenantid},于是 {@code row.get("tenantId")} + * 拿到 {@code null}。返回 POJO 的 mapper(如 {@code UrgeMapper})不受影响 + * (MyBatis 按属性名忽略大小写匹配),**所以这个坑只在 Map 结果上出现**, + * 很容易"照着别的 mapper 抄一遍"就中招。 + * + *

它的表现极其安静:定时任务照跑,日志里只有一行 warn,**什么都没做**。 + * 这与 F-54 是同一个坑(当时在 {@code ValidationIssueMapper}), + * 所以这里的规矩是:**这个文件里的别名一律带引号**。 + */ +public interface ScheduleMapper { + + /** + * 已到截止时间但还在"收集中"的任务。 + * + *

⚠️ 用 `end_time < now()` 而不是 `<=`:`end_time` 在库里是**日期**(当天 00:00), + * 所以"今天截止"的任务不应该在凌晨就被关掉 —— 学生今天还能交。 + * 判定写成"截止日**已经过去**"(严格小于今天),语义是"从明天起不能再交"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id, tenant_id AS "tenantId", task_name AS "taskName", end_time AS "endTime" + FROM collect_task + WHERE deleted = 0 + AND status = 1 + AND end_time IS NOT NULL + AND end_time < CURRENT_DATE + ORDER BY id + """) + List> tasksToClose(); + + /** + * 截止日正好在某一天的任务(给"提前 7/3/1 天催报"用)。 + * + *

为什么按"正好那一天"而不是"小于等于":催报是**每天一次**的定时任务, + * 用 `<=` 会让"还剩 2 天"的任务在 7 天、3 天、1 天各被催一次(因为 2 < 3 也成立) + * —— 学生一天收到三条,然后关掉通知。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id, tenant_id AS "tenantId", task_name AS "taskName", end_time AS "endTime" + FROM collect_task + WHERE deleted = 0 + AND status = 1 + AND end_time = #{date}::date + ORDER BY id + """) + List> tasksEndingOn(@Param("date") String date); + + /** + * 可以清掉原始件的行(`raw_expire_time` 到期)。 + * + *

只取"确实有原始件"的(`raw_path` 非空)—— 契约默认 {@code keep-raw=false}, + * 所以绝大多数行根本没有原始件,扫它们只是白跑。 + * + *

带 {@code limit}:一次清一大批会把磁盘 IO 打满(而且删除不可回滚), + * 分几轮清完更安全。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id, tenant_id AS "tenantId", raw_path AS "rawPath" + FROM collect_file + WHERE deleted = 0 + AND raw_path IS NOT NULL + AND raw_expire_time IS NOT NULL + AND raw_expire_time < NOW() + ORDER BY raw_expire_time + LIMIT #{limit} + """) + List> rawFilesToClean(@Param("limit") int limit); + + /** 清掉一行的原始件引用(**只改列,不删行** —— 规范件还在,行必须留)。 */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE collect_file + SET raw_path = NULL, update_time = NOW() + WHERE id = #{id} AND deleted = 0 + """) + int clearRawPath(@Param("id") Long id); + + /** + * 超时未审的填报单(审核超时提醒)。 + * + *

口径与 §11.6 的审核统计**必须一致**(班级 48 小时 / 后台 72 小时), + * 否则会出现"统计里说超时 3 件、提醒只发了 1 条"这种对不上的情况。 + * 两个小时的阈值由调用方传进来,但**取的是同一份配置**(`collect_task.audit_config`)。 + * + *

只取"当前卡在哪一级"对应的那条:`status=1` 是待初审(班级级), + * `status=2` 是待终审(后台级)。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT s.id AS "submissionId", + s.tenant_id AS "tenantId", + s.task_id AS "taskId", + s.status AS status, + s.submitter_id AS "submitterId", + s.submit_time AS "submitTime", + t.task_name AS "taskName", + r.real_name AS "realName" + FROM collect_submission s + JOIN collect_task t ON t.id = s.task_id AND t.deleted = 0 + LEFT JOIN sys_user u ON u.id = s.submitter_id AND u.deleted = 0 + LEFT JOIN collect_roster r + ON r.task_id = s.task_id AND r.bind_user_id = s.submitter_id AND r.deleted = 0 + WHERE s.deleted = 0 + AND s.status IN (1, 2) + AND s.submit_time IS NOT NULL + AND s.submit_time < #{before}::timestamp + ORDER BY s.submit_time + LIMIT #{limit} + """) + List> auditTimeouts(@Param("before") java.time.LocalDateTime before, + @Param("limit") int limit); + + /** + * 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。 + * + *

找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` / + * `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**, + * 发给所有人等于没人负责。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT DISTINCT u.id AS "userId", u.real_name AS "realName" + FROM collect_task_org cto + JOIN sys_user u ON u.deleted = 0 AND u.status = 1 + AND (u.id = cto.head_user_id + OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%')) + WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId} + """) + List> classAuditors(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId); + + /** + * 到期日正好在某一天的租户(给"提前 30/7/1 天提醒"用)。 + * + *

与催报同理:按"正好那一天"而不是"小于等于" —— 用 `<=` 会连报三次, + * 而客户看到的第一反应是"我知道了",第二次是"又来了",第三次是关掉通知。 + * + *

只取启用中的租户:已经停用的不用再提醒续费(那已经是另一个话题)。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id AS "tenantId", tenant_name AS "tenantName", expire_time AS "expireTime" + FROM sys_tenant + WHERE deleted = 0 AND status = 1 + AND expire_time IS NOT NULL + AND expire_time::date = #{date}::date + ORDER BY id + """) + List> tenantsExpiringOn(@Param("date") String date); + + /** + * 学校管理员(终审超时提醒的收件人)。 + * + *

按角色找而不是按某个固定账号 —— 学校换管理员时不该改代码。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT DISTINCT u.id AS "userId", u.real_name AS "realName" + FROM sys_user u + JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0 + JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0 + WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1 + AND ro.role_code = #{roleCode} + """) + List> usersOfRole(@Param("tenantId") Long tenantId, + @Param("roleCode") String roleCode); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java new file mode 100644 index 0000000..e5a6e61 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/schedule/service/ScheduleJobs.java @@ -0,0 +1,647 @@ +package cn.tianbaotong.fill.module.schedule.service; + +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.notify.service.NotificationService; +import cn.tianbaotong.fill.module.notify.service.UrgeService; +import cn.tianbaotong.fill.module.schedule.mapper.ScheduleMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.service.TaskService; +import cn.tianbaotong.fill.security.BuiltinRole; +import cn.tianbaotong.fill.support.TenantScope; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 定时任务(PRD 附录 E 的 9 项)。 + * + *

这个类存在的意义:把"没人点的那部分系统"集中到一处

+ * + *

9 项里已经有的两件不在这个类里(各归各的模块): + * {@code ExportCleanupService.cleanupExpired}(导出包清理)与 + * {@code OpenPushService.retryDue}(开放接口推送重试)。剩下的在这里。 + * + *

⚠️ 定时任务最大的坑:租户上下文

+ * + *

定时线程里**没有登录用户**,而租户拦截器默认拒绝({@code 40101})。 + * 所以每个任务都是同一个两段式: + * + *

    + *
  1. 先以平台身份扫候选({@code TenantScope.runAs(0L, ...)})—— + * 平台租户是唯一允许跨租户的身份;候选行里带着 {@code tenantId};
  2. + *
  3. 再逐个租户摆好上下文干活({@code TenantScope.runAs(候选.tenantId, ...)})—— + * 写数据、发通知都必须在这一步,否则 {@code tenant_id} 会被写成 0 + * (通知写进平台租户,那个学校的人**永远看不到**,而且不报错)。
  4. + *
+ * + *

反过来(先摆租户上下文再扫)就只能扫到一个租户 —— 而"漏掉某个学校的催报" + * 没有任何地方会报错。 + * + *

为什么每个任务都 try/catch 住整个身体

+ * + *

{@code @Scheduled} 抛异常只会打一行日志,下一轮照跑;但如果异常发生在 + * "遍历到第 3 个租户"时,**前面 2 个已经干了、后面 7 个这轮不干了**。 + * 所以这里的取舍是:**一个租户失败不影响其它租户**(循环体内各自 try), + * 整体再包一层(避免把调度线程搞挂)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScheduleJobs { + + private final ScheduleMapper scheduleMapper; + private final TaskService taskService; + private final UrgeService urgeService; + private final NotificationService notificationService; + private final TaskMapper taskMapper; + private final cn.tianbaotong.fill.module.tenant.mapper.SnapshotMapper snapshotMapper; + private final cn.tianbaotong.fill.module.tenant.service.TenantUsageService usageService; + private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService; + private final cn.tianbaotong.fill.module.file.mapper.FileMapper fileMapper; + + /** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */ + private static final int RAW_CLEAN_BATCH = 200; + + /** 审核超时提醒一轮最多处理多少条(防止某天积压上万条时把接口打爆)。 */ + private static final int TIMEOUT_BATCH = 500; + + /** 提前几天催报(PRD 附录 E 的「7/3/1 天规则」)。 */ + private static final int[] URGE_DAYS_BEFORE = {7, 3, 1}; + + /** 租户到期前提醒的天数(PRD 附录 E 的「30/7/1 天」)。 */ + private static final int[] EXPIRE_REMIND_DAYS = {30, 7, 1}; + + /** 一轮最多重试多少个文件(分轮清完,别一次把 CPU 打满)。 */ + private static final int RETRY_BATCH = 50; + + /** 平台身份(跨租户扫描时用)。 */ + private static final long PLATFORM = 0L; + + // ══════════════ ① 任务自动截止(每小时)══════════════ + + /** + * 到期的任务自动 {@code 收集中 → 已截止}。 + * + *

⚠️ 这件事**必须自动做**:截止时间到了而任务还是"收集中",学生就能继续提交 + * —— 而"截止后提交"会在导出与上报时变成一个说不清的问题(这批到底算不算)。 + * 靠管理员记着手动点"截止",一定会漏。 + */ + @Scheduled(cron = "0 5 * * * *") + public void autoCloseExpiredTasks() { + try { + List> tasks = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + scheduleMapper::tasksToClose); + assertTenantPresent(tasks, "close"); + int closed = 0; + for (Map row : tasks) { + Long tenantId = longOf(row, "tenantId"); + Long taskId = longOf(row, "id"); + try { + TenantScope.runAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> taskService.autoClose(taskId)); + closed++; + } catch (RuntimeException e) { + log.warn("自动截止失败:tenant={} task={} 原因={}", tenantId, taskId, e.toString()); + } + } + if (closed > 0) { + log.info("自动截止:扫到 {} 个到期任务,关闭 {} 个", tasks.size(), closed); + } + } catch (RuntimeException e) { + log.warn("自动截止任务失败(下一轮会重试)", e); + } + } + + // ══════════════ ② 自动催报(每日 09:00)══════════════ + + /** + * 按截止日 7/3/1 天规则自动催报**未提交者**。 + * + *

为什么是 09:00:催报要落在学生**当天会看手机**的时间。凌晨发的话, + * 通知会沉在消息列表底下,等于没发。 + * + *

为什么只催"还没提交":已经交了的人收到"请尽快完成填报"会以为自己没交成功 + * —— 这比不催更糟。 + */ + @Scheduled(cron = "0 0 9 * * *") + public void autoUrgeUnsubmitted() { + try { + LocalDate today = LocalDate.now(); + for (int days : URGE_DAYS_BEFORE) { + LocalDate target = today.plusDays(days); + List> tasks = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + () -> scheduleMapper.tasksEndingOn(target.toString())); + assertTenantPresent(tasks, "urge"); + int sent = 0; + for (Map row : tasks) { + Long tenantId = longOf(row, "tenantId"); + Long taskId = longOf(row, "id"); + try { + sent += TenantScope.callAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> urgeService.autoUrge(tenantId, taskId, days)); + } catch (RuntimeException e) { + log.warn("自动催报失败:tenant={} task={} 原因={}", tenantId, taskId, e.toString()); + } + } + if (!tasks.isEmpty()) { + log.info("自动催报:截止日 {}(还有 {} 天)有 {} 个任务,发出 {} 条", + target, days, tasks.size(), sent); + } + } + } catch (RuntimeException e) { + log.warn("自动催报任务失败(下一轮会重试)", e); + } + } + + // ══════════════ ③ 审核超时提醒(每小时)══════════════ + + /** + * 超过时效还没审的,提醒**该动手的那个人**。 + * + *

时效口径与 §11.6 的审核统计一致(班级 48h / 后台 72h)。这里用**默认值**, + * 任务自己配了 `audit_config` 时以任务为准 —— 两者不一致会让 + * "统计里说超时 3 件、提醒只发了 1 条",而那种不一致没人查得出来。 + * + *

提醒发给谁: + *

+ * 发给"真的要动手的那个人",发给所有人等于没人负责。 + */ + @Scheduled(cron = "0 25 * * * *") + public void remindAuditTimeout() { + try { + LocalDateTime now = LocalDateTime.now(); + // 取两个阈值里**更宽松**的那个作为扫描下界:具体每个任务用哪个阈值, + // 在下面按 status(初审/终审)分别判 —— 先粗筛再精筛,避免全表扫 + int maxHours = Math.max(DEFAULT_CLASS_TIMEOUT_HOURS, DEFAULT_ADMIN_TIMEOUT_HOURS); + LocalDateTime before = now.minusHours(maxHours); + List> rows = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + () -> scheduleMapper.auditTimeouts(before, TIMEOUT_BATCH)); + assertTenantPresent(rows, "timeout"); + int notified = 0; + for (Map row : rows) { + Long tenantId = longOf(row, "tenantId"); + Long taskId = longOf(row, "taskId"); + Integer status = intOf(row, "status"); + LocalDateTime submitTime = timeOf(row, "submitTime"); + if (tenantId == null || taskId == null || status == null || submitTime == null) { + continue; + } + int limitHours = status == 1 ? classTimeoutHours(tenantId, taskId) + : adminTimeoutHours(tenantId, taskId); + long waited = java.time.Duration.between(submitTime, now).toHours(); + if (waited < limitHours) { + continue; // 还没到它那一级的时效 + } + try { + notified += TenantScope.callAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> remindOne(tenantId, row, status, waited, limitHours)); + } catch (RuntimeException e) { + log.warn("审核超时提醒失败:submission={} 原因={}", row.get("submissionId"), + e.toString()); + } + } + if (notified > 0) { + log.info("审核超时提醒:扫到 {} 条超时,发出 {} 条提醒", rows.size(), notified); + } + } catch (RuntimeException e) { + log.warn("审核超时提醒任务失败(下一轮会重试)", e); + } + } + + private int remindOne(Long tenantId, Map row, Integer status, + long waited, int limitHours) { + Long taskId = longOf(row, "taskId"); + String taskName = strOf(row, "taskName"); + String realName = strOf(row, "realName"); + String who = realName == null ? "有 1 份填报单" : realName + " 的填报单"; + String levelText = status == 1 ? "班级初审" : "后台终审"; + List> receivers = status == 1 + ? scheduleMapper.classAuditors(tenantId, taskId) + : scheduleMapper.usersOfRole(tenantId, BuiltinRole.SCHOOL_ADMIN.getCode()); + boolean escalated = false; + if (receivers.isEmpty()) { + // ⚠️ 「找不到班主任」不是"跳过这一条",而是**必须升级**。 + // + // 那些单子不是"晚点会有人审",而是**永远不会有人审**(没人负责这个班), + // 而学生那边只会看到"一直没通过"。所以按 PRD 的原话「提醒并升级」: + // 找不到班级负责人时,升级给学校管理员,并在文案里说清原因 —— + // 管理员看到才知道要去配班主任。 + receivers = scheduleMapper.usersOfRole(tenantId, BuiltinRole.SCHOOL_ADMIN.getCode()); + escalated = !receivers.isEmpty(); + if (!escalated) { + // 连管理员都没有 → 这个租户的数据不完整,留痕(这是配置问题,不是提醒问题) + log.warn("审核超时但找不到任何提醒对象:tenant={} task={}(没有管理员?)", + tenantId, taskId); + return 0; + } + } + String title = escalated ? "有填报单审核超时(该班没有负责人)" : "有填报单审核超时"; + String content = who + "在《" + nz(taskName) + "》里等你" + levelText + + ",已经等了 " + waited + " 小时(时效 " + limitHours + " 小时)。" + + (escalated ? "⚠️ 这个班没有配负责人,请先指定班主任/初审人。" : ""); + int n = 0; + for (Map u : receivers) { + Long uid = longOf(u, "userId"); + if (uid == null) { + continue; + } + try { + notificationService.notify(new NotificationService.NotifyRequest( + tenantId, uid, // 契约 §6 的 notifyBizType 里没有"审核超时",用 2「临期提醒」—— + // 对审核员来说"这份单子等你 48 小时了"就是一次临期提醒。 + // **不新造第 8 个码**:契约里没有的码前端与客服都不认识 + cn.tianbaotong.fill.module.notify.entity.CollectMessage.BIZ_DEADLINE, + title, content, + cn.tianbaotong.fill.module.notify.entity.CollectMessage.LINK_TASK, taskId, + NotificationService.TPL_AUDIT_RESULT, Map.of())); + n++; + } catch (RuntimeException e) { + log.warn("审核超时提醒发送失败:uid={} 原因={}", uid, e.toString()); + } + } + return n; + } + + // ══════════════ ④ 原始件清理(每日 02:00)══════════════ + + /** + * 清掉 `raw_expire_time` 到期的**原始件**(默认保留 30 天)。 + * + *

⚠️ 语义是"**清掉原始件、保留规范件**",不是删文件记录: + * 规范件才是交付物,行必须留着(学生要能看到自己传过什么)。 + * + *

先改库(把 `raw_path` 置空)再删磁盘文件 —— 与导出包清理同一个顺序理由: + * 最坏结果是"文件还在但库里不再引用它"(浪费磁盘), + * 而不是"库里有引用但文件没了"(用户点开报错)。 + */ + @Scheduled(cron = "0 0 2 * * *") + public void cleanExpiredRawFiles() { + try { + List> rows = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + () -> scheduleMapper.rawFilesToClean(RAW_CLEAN_BATCH)); + assertTenantPresent(rows, "raw"); + int cleaned = 0; + for (Map row : rows) { + Long id = longOf(row, "id"); + String rawPath = strOf(row, "rawPath"); + if (id == null) { + continue; + } + scheduleMapper.clearRawPath(id); + if (rawPath != null) { + try { + Path p = Paths.get(rawPath); + if (Files.deleteIfExists(p)) { + cleaned++; + } + } catch (Exception e) { + log.warn("删除原始件失败(引用已清,下轮不再重试):{} 原因={}", + rawPath, e.toString()); + } + } + } + if (!rows.isEmpty()) { + log.info("原始件清理:清掉 {} 行的引用,删除 {} 个文件", rows.size(), cleaned); + } + } catch (RuntimeException e) { + log.warn("原始件清理任务失败(下一轮会重试)", e); + } + } + + // ══════════════ ⑤ 用量快照(每日 01:00)══════════════ + + /** + * 把每个租户的用量写一行快照(PRD 附录 E「用量统计:每日 01:00」)。 + * + *

为什么要快照而不是每次实时算 —— 见 `sys_tenant_usage_snapshot` 的表注释。 + * 一句话:实时统计只能回答"现在多少",而计费问的是"**上个月**用了多少"。 + * + *

⚠️ 幂等:一个租户一个周期一行(唯一索引 + 先查后写)。 + * 定时任务重复跑、或运维手工补跑,都不该造出两行 —— 两行的话 + * "这个月用了多少"就有两个答案,而报表会随机取其中一个。 + */ + @Scheduled(cron = "0 0 1 * * *") + public void snapshotUsage() { + try { + List> tenants = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + snapshotMapper::activeTenants); + assertTenantPresent(tenants, "usage"); + int written = 0; + for (Map row : tenants) { + Long tenantId = longOf(row, "tenantId"); + if (tenantId == null) { + continue; + } + try { + written += TenantScope.callAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> snapshotOne(tenantId)); + } catch (RuntimeException e) { + log.warn("用量快照失败:tenant={} 原因={}", tenantId, e.toString()); + } + } + if (written > 0) { + log.info("用量快照:{} 个租户,写入 {} 行", tenants.size(), written); + } + } catch (RuntimeException e) { + log.warn("用量快照任务失败(下一轮会重试)", e); + } + } + + /** 一个租户的快照(也供手工触发与测试调用)。返回写入行数(0 或 1)。 */ + public int snapshotOne(Long tenantId) { + String period = java.time.LocalDate.now().format( + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")); + if (snapshotMapper.findByPeriod(tenantId, period) != null) { + return 0; // 这个周期已经有快照了,不覆盖 + } + cn.tianbaotong.fill.module.tenant.service.TenantUsageService.UsageVO u = + usageService.usage(tenantId, period); + cn.tianbaotong.fill.module.tenant.entity.SysTenantUsageSnapshot s = + new cn.tianbaotong.fill.module.tenant.entity.SysTenantUsageSnapshot(); + // ⚠️ 必须显式写 tenantId:平台上下文里拦截器不生效,不会自动填 + s.setTenantId(tenantId); + s.setPeriod(period); + s.setStudentCount(u.getStudentCount()); + s.setStorageMb(u.getStorageMb()); + s.setOcrCount(u.getOcrCount()); + s.setSmsCount(u.getSmsCount()); + snapshotMapper.insert(s); + return 1; + } + + // ══════════════ ⑥ 压缩失败重试(每 10 分钟)══════════════ + + /** + * 重试"处理失败"的材料(PRD 附录 E「压缩失败重试:重试 ≤3 次」)。 + * + *

候选是**还没有规范件、但保留着原始件**的行 —— + * 这正是 PRD 那句「处理失败 → 保留原图,允许一键重试」对应的状态。 + * + *

⚠️ 两条边界: + *

    + *
  1. 必须封顶 3 次:原图本身损坏的文件重试一万次也不会成功, + * 而每 10 分钟一次的重试会白烧磁盘 IO 与 CPU;
  2. + *
  3. 没有原始件的行不重试:规范件是唯一的产物,原图又没了 —— + * 重试无从下手。这类行只记一次日志(它们需要人工重新上传, + * 而不是系统反复尝试)。
  4. + *
+ */ + @Scheduled(cron = "0 */10 * * * *") + public void retryFailedProcess() { + try { + List> rows = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + () -> fileMapper.listProcessFailed(cn.tianbaotong.fill.module.file.entity + .CollectFile.MAX_PROCESS_RETRY, RETRY_BATCH)); + assertTenantPresent(rows, "retry"); + int ok = 0; + int failed = 0; + for (Map row : rows) { + Long tenantId = longOf(row, "tenantId"); + Long fileId = longOf(row, "id"); + if (tenantId == null || fileId == null) { + continue; + } + try { + TenantScope.runAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> fileService.process(fileId)); + fileMapper.markRetryOk(fileId); + ok++; + } catch (RuntimeException e) { + failed++; + fileMapper.markRetryFailed(fileId, clip(e.getMessage())); + log.warn("材料处理重试失败:file={} 第 {} 次 原因={}", fileId, + longOf(row, "processRetryCount"), e.toString()); + } + } + if (ok + failed > 0) { + log.info("材料处理重试:成功 {},失败 {}(失败会累加计数,到 {} 次停止)", + ok, failed, cn.tianbaotong.fill.module.file.entity.CollectFile.MAX_PROCESS_RETRY); + } + } catch (RuntimeException e) { + log.warn("材料处理重试任务失败(下一轮会重试)", e); + } + } + + // ══════════════ ⑦ 租户到期提醒(每日 08:00)══════════════ + + /** + * 租户到期前 30/7/1 天提醒(PRD 附录 E「租户到期提醒:每日 08:00」)。 + * + *

⚠️ 提醒发给**这个租户自己的管理员**,不是发给我们 —— + * 因为"要不要续费、走什么流程"是客户内部要安排的事, + * 提前 30 天告诉他们才有时间走流程;提前 1 天说等于没说。 + * + *

平台运营侧另外有一条(同一批数据,语气不同): + * 运营要的是"这家该催了",而不是"你们快到期了"。 + */ + @Scheduled(cron = "0 0 8 * * *") + public void remindTenantExpiring() { + try { + int sent = 0; + for (int days : EXPIRE_REMIND_DAYS) { + java.time.LocalDate target = java.time.LocalDate.now().plusDays(days); + List> rows = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), + () -> scheduleMapper.tenantsExpiringOn(target.toString())); + assertTenantPresent(rows, "expire"); + for (Map row : rows) { + Long tenantId = longOf(row, "tenantId"); + String name = strOf(row, "tenantName"); + if (tenantId == null) { + continue; + } + try { + sent += TenantScope.callAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE), + () -> remindExpire(tenantId, name, days)); + } catch (RuntimeException e) { + log.warn("租户到期提醒失败:tenant={} 原因={}", tenantId, e.toString()); + } + } + } + if (sent > 0) { + log.info("租户到期提醒:发出 {} 条", sent); + } + } catch (RuntimeException e) { + log.warn("租户到期提醒任务失败(下一轮会重试)", e); + } + } + + private int remindExpire(Long tenantId, String tenantName, int daysLeft) { + var receivers = scheduleMapper.usersOfRole(tenantId, + cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode()); + if (receivers.isEmpty()) { + log.warn("租户到期提醒但该租户没有管理员:tenant={}", tenantId); + return 0; + } + String title = daysLeft <= 1 ? "你的单位账号明天到期" : "你的单位账号还有 " + daysLeft + " 天到期"; + String content = "「" + nz(tenantName) + "」的服务将在 " + daysLeft + + " 天后到期。到期后学生与老师将无法登录、无法继续填报。" + + "请及时联系服务方安排续费 —— 已提交的数据不会丢失,但到期期间不能收新材料。"; + int n = 0; + for (Map u : receivers) { + Long uid = longOf(u, "userId"); + if (uid == null) { + continue; + } + try { + notificationService.notify(new NotificationService.NotifyRequest( + tenantId, uid, + cn.tianbaotong.fill.module.notify.entity.CollectMessage.BIZ_DEADLINE, + title, content, + cn.tianbaotong.fill.module.notify.entity.CollectMessage.LINK_NONE, null, + null, Map.of())); + n++; + } catch (RuntimeException e) { + log.warn("租户到期提醒发送失败:uid={} 原因={}", uid, e.toString()); + } + } + return n; + } + + // ══════════════ 供手工触发 / E2E 调用 ══════════════ + + /** + * 立刻跑一轮(给 {@code POST /schedules/run} 用)。 + * + *

为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** —— + * 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。 + * + * @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw}) + * @return 这一轮做了什么(给人看的说明) + */ + public String runNow(String job) { + if (job == null || job.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire"); + } + switch (job.trim().toLowerCase()) { + case "close" -> autoCloseExpiredTasks(); + case "urge" -> autoUrgeUnsubmitted(); + case "timeout" -> remindAuditTimeout(); + case "raw" -> cleanExpiredRawFiles(); + case "usage" -> snapshotUsage(); + case "retry" -> retryFailedProcess(); + case "expire" -> remindTenantExpiring(); + default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire"); + } + return "已手动触发一轮:" + job; + } + + // ══════════════ 内部工具 ══════════════ + + /** 系统身份的角色码(定时任务的写操作要能过权限校验)。 */ + private static final String SYSTEM_ROLE = BuiltinRole.PLATFORM_ADMIN.getCode(); + + /** 默认时效(小时),与 §11.6 的审核统计同口径。 */ + private static final int DEFAULT_CLASS_TIMEOUT_HOURS = 48; + private static final int DEFAULT_ADMIN_TIMEOUT_HOURS = 72; + + private int classTimeoutHours(Long tenantId, Long taskId) { + return timeoutFromConfig(tenantId, taskId, "classTimeoutHours", DEFAULT_CLASS_TIMEOUT_HOURS); + } + + private int adminTimeoutHours(Long tenantId, Long taskId) { + return timeoutFromConfig(tenantId, taskId, "adminTimeoutHours", DEFAULT_ADMIN_TIMEOUT_HOURS); + } + + /** + * 从任务的 {@code audit_config} 里取时效;没有就回默认值。 + * + *

⚠️ 读的是**任务自己配的那个值** —— 统计接口也是这么算的。 + * 两边各用一套默认值,就会出现"统计说超时 3 件、提醒只发了 1 条"。 + */ + private int timeoutFromConfig(Long tenantId, Long taskId, String key, int fallback) { + try { + CollectTask t = taskMapper.selectById(taskId); + if (t == null || t.getAuditConfig() == null || t.getAuditConfig().isBlank()) { + return fallback; + } + Object v = JSONUtil.parseObj(t.getAuditConfig()).get(key); + return v instanceof Number n ? n.intValue() : fallback; + } catch (RuntimeException e) { + return fallback; + } + } + + private static Long longOf(Map row, String key) { + Object v = row.get(key); + return v instanceof Number n ? n.longValue() : null; + } + + private static Integer intOf(Map row, String key) { + Object v = row.get(key); + return v instanceof Number n ? n.intValue() : null; + } + + private static String strOf(Map row, String key) { + Object v = row.get(key); + return v == null ? null : String.valueOf(v); + } + + private static LocalDateTime timeOf(Map row, String key) { + Object v = row.get(key); + if (v instanceof LocalDateTime t) { + return t; + } + if (v instanceof java.sql.Timestamp ts) { + return ts.toLocalDateTime(); + } + return null; + } + + private static String nz(String s) { + return s == null ? "本次任务" : s; + } + + /** 失败原因入库前截断(列是 varchar(256),异常消息可能很长)。 */ + private static String clip(String s) { + if (s == null) { + return "处理失败(异常没有消息)"; + } + return s.length() <= 250 ? s : s.substring(0, 250); + } + + /** + * 候选行自检:缺 `tenantId` 就明确报出来。 + * + *

这个检查的存在理由是一次真实的失败:`AS tenantId`(没加双引号)会被 + * PostgreSQL 折成小写,于是 `row.get("tenantId")` 是 null → + * `TenantScope.runAs(null, ...)` 抛 → 被 catch 吞掉 → + * **定时任务看起来跑过了,其实什么都没做**,日志里只有一行 warn。 + * + *

所以宁可在这里吵一次:把候选行里**实际的键**打出来, + * 让人一眼看到"哦,是 tenantid 不是 tenantId"。 + */ + private void assertTenantPresent(List> rows, String job) { + if (rows.isEmpty()) { + return; + } + if (longOf(rows.get(0), "tenantId") == null) { + throw new BizException(ErrorCode.INTERNAL_ERROR, + "定时任务「" + job + "」的候选行里没有 tenantId(实际键:" + + rows.get(0).keySet() + ")。" + + "最常见的原因:SQL 别名没加双引号,被 PostgreSQL 折成了小写"); + } + } + + /** 供测试断言用:这一轮"应该"处理哪些任务(不改数据)。 */ + public List> previewClose() { + return TenantScope.callAs(PLATFORM, PLATFORM, Set.of(), scheduleMapper::tasksToClose); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenantUsageSnapshot.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenantUsageSnapshot.java new file mode 100644 index 0000000..79dc06c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenantUsageSnapshot.java @@ -0,0 +1,61 @@ +package cn.tianbaotong.fill.module.tenant.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 租户用量快照(V17;PRD 附录 E「用量统计:每日 01:00」)。 + * + *

为什么必须落库,而不是每次实时算

+ * + *
    + *
  1. 实时统计只能回答"**现在**多少",而计费与超额告警问的是"**上个月**用了多少";
  2. + *
  3. 历史快照能回答"用量是什么时候涨上去的" —— 招生季与毕业季的形状完全不同;
  4. + *
  5. 存储量尤其重要:原始件 30 天后被清理,**清理前的占用在实时统计里再也看不到**。
  6. + *
+ * + *

⚠️ 它不继承 {@code BaseEntity} / {@code TenantEntity}

+ * + *

那张表**没有** {@code create_by / update_by / update_time / deleted} —— + * 快照是"只增不改的事实",不需要软删,也不需要"谁改的"。 + * 继承基类会让 MyBatis-Plus 去写不存在的列,表现是每次 insert 都报 + * {@code column "update_time" does not exist}。 + * (这类不一致由 {@code scripts/check-entity-columns.py} 拦。) + * + *

⚠️ 另一个坑:{@code tenant_id} 在基类之外**不会**被自动填 —— + * 定时任务是平台上下文(拦截器对平台身份不生效),所以写的时候 + * **必须显式 setTenantId**,否则所有租户的快照会挤到 tenant_id=0 上, + * 而那看起来像"没有数据"。 + */ +@Getter +@Setter +@TableName("sys_tenant_usage_snapshot") +public class SysTenantUsageSnapshot implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long tenantId; + + /** 统计周期:`2026-09`(月)或 `2026Q3`(季),与 §6.7 的 period 同口径。 */ + private String period; + + /** 按证件号去重的学生数。 */ + private Long studentCount; + + /** 规范件体积合计(MB)。**不含原始件**。 */ + private Long storageMb; + + /** 识别通道用量(一期恒为 0:M4 未接)。 */ + private Integer ocrCount; + + private Integer smsCount; + + private LocalDateTime createTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/SnapshotMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/SnapshotMapper.java new file mode 100644 index 0000000..28f890e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/SnapshotMapper.java @@ -0,0 +1,55 @@ +package cn.tianbaotong.fill.module.tenant.mapper; + +import cn.tianbaotong.fill.module.tenant.entity.SysTenantUsageSnapshot; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 租户用量快照(V17)。 + * + *

与 {@code ScheduleMapper} 同一条规矩:定时任务在**平台上下文**里先看到所有租户, + * 所以这里的跨租户查询显式关掉租户拦截器,并把 {@code tenant_id} 作为结果列带出来。 + */ +public interface SnapshotMapper extends BaseMapper { + + /** 所有启用中的租户(快照要逐个租户算)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id AS "tenantId", tenant_name AS "tenantName" + FROM sys_tenant + WHERE deleted = 0 AND status = 1 + ORDER BY id + """) + List> activeTenants(); + + /** + * 某个租户、某个周期的**已有快照**(幂等:同一天重复跑不会造出两行)。 + * + *

用 `selectOne` 而不是"先查再插"两处各写一遍 —— 唯一索引是最后一道保险, + * 但不该靠它来发现重复。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM sys_tenant_usage_snapshot + WHERE tenant_id = #{tenantId} AND period = #{period} + LIMIT 1 + """) + SysTenantUsageSnapshot findByPeriod(@Param("tenantId") Long tenantId, + @Param("period") String period); + + /** 运营视角:某周期所有租户的快照。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT s.*, t.tenant_name AS tenantName + FROM sys_tenant_usage_snapshot s + LEFT JOIN sys_tenant t ON t.id = s.tenant_id AND t.deleted = 0 + WHERE s.period = #{period} + ORDER BY s.student_count DESC + """) + List> listByPeriod(@Param("period") String period); +} diff --git a/backend/xt-server/src/main/resources/db/migration/V17__schedule_support.sql b/backend/xt-server/src/main/resources/db/migration/V17__schedule_support.sql new file mode 100644 index 0000000..9541146 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V17__schedule_support.sql @@ -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);