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「压缩失败重试」)──────────
+
+ /**
+ * 处理失败、还可以重试的文件。
+ *
+ * 候选条件的三段各有理由:
+ *
+ * - {@code std_path IS NULL} —— **还没有规范件**。规范件是唯一产物,
+ * 有它就说明处理成功过,不该再压一遍;
+ * - {@code raw_path IS NOT NULL} —— **还留着原始件**。没有原图就无从重试
+ * (PRD FR-611 的"保留原图"正是为这一步);
+ * - {@code process_retry_count < #{maxRetry}} —— **封顶**。
+ * 原图本身损坏的文件重试一万次也不会成功,而每 10 分钟一次会白烧 CPU。
+ *
+ *
+ * ⚠️ 别名加双引号:返回 `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