com.qiniu
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
index 6673011..e7ac6c7 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
@@ -4,15 +4,22 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 数据信息填报系统 · 主应用。
*
* 一期范围:学校收集 + 自主认定(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
+ *
+ *
{@code @EnableScheduling} 是给导出包过期清理用的
+ * ({@code ExportCleanupService},契约 §12 的"保留 7 天")。
+ * 目前只有这一个定时任务 —— 别顺手加更多:定时任务是"没有请求也会发生的事",
+ * 每加一个都要能回答"它失败了谁会知道"。
*/
@SpringBootApplication
@ConfigurationPropertiesScan
+@EnableScheduling
@MapperScan("cn.tianbaotong.fill.module.**.mapper")
public class FillApplication {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java
new file mode 100644
index 0000000..7508040
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java
@@ -0,0 +1,39 @@
+package cn.tianbaotong.fill.module.export.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 导出打包的线程池。
+ *
+ *
为什么单独一个池,而不用 {@code @Async} 的默认池
+ * 打包是长任务 + CPU/IO 混合(压缩几百张图、写几十 MB 的 zip),
+ * 而 Spring 的默认异步池是给短任务用的(队列无界 + 线程数少)。
+ * 混在一起的话,一次导出能把所有短任务(比如"通知")排到后面去 ——
+ * 而这类故障的表现是"系统变慢",没人会想到是导出引起的。
+ *
+ *
线程数刻意只有 2:导出是串行的大活,
+ * 同时跑 4 个导出只会让四个都慢,还会把磁盘 IO 打满。
+ * 排队比并发更符合这里的业务(一个任务一学期导出几次,不是高频动作)。
+ */
+@Slf4j
+@Configuration
+public class ExportAsyncConfig {
+
+ @Bean(name = "exportExecutor", destroyMethod = "shutdown")
+ public ExecutorService exportExecutor() {
+ AtomicInteger seq = new AtomicInteger(1);
+ ThreadFactory factory = r -> {
+ Thread t = new Thread(r, "xt-export-" + seq.getAndIncrement());
+ t.setDaemon(true);
+ return t;
+ };
+ return Executors.newFixedThreadPool(2, factory);
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java
new file mode 100644
index 0000000..a983ac2
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java
@@ -0,0 +1,145 @@
+package cn.tianbaotong.fill.module.export.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.module.export.dto.ExportDtos;
+import cn.tianbaotong.fill.module.export.service.ExportCleanupService;
+import cn.tianbaotong.fill.module.export.service.ExportService;
+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.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+/**
+ * 导出与交付(《03-接口设计》§12.4 ~ §12.6)。
+ *
+ *
权限只有两个:{@code export:download}(导出与下载)与 {@code export:unmasked}(脱敏开关)。
+ * 没有单独的"查看导出列表"权限 —— 能看到任务的人本来就该能看到它的导出记录,
+ * 多造一个权限码只会让分配角色时多一个想不起来的东西。
+ */
+@Tag(name = "导出与交付")
+@RestController
+@RequestMapping("/api/v1/exports")
+@RequiredArgsConstructor
+public class ExportController {
+
+ private final ExportService exportService;
+ private final ExportCleanupService exportCleanupService;
+
+ @GetMapping("/precheck")
+ @RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
+ @Operation(summary = "导出前置检查(只检查,不创建)",
+ description = "界面用它决定「开始导出」按钮是否可点。"
+ + "**铁律**:canExport=false 时按钮必须禁用,不允许「警告后放行」")
+ public ApiResult precheck(@RequestParam Long taskId,
+ @RequestParam(required = false) java.util.List orgIds,
+ @RequestParam(required = false) Boolean onlyApproved) {
+ ExportDtos.ScopeReq scope = new ExportDtos.ScopeReq();
+ scope.setOrgIds(orgIds == null ? new java.util.ArrayList<>() : orgIds);
+ scope.setOnlyApproved(Boolean.TRUE.equals(onlyApproved));
+ return ApiResult.ok(exportService.precheck(taskId, scope));
+ }
+
+ @GetMapping("/preview")
+ @RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
+ @Operation(summary = "目录树预览",
+ description = "点「开始导出」之前看到最终交付的样子(PRD §5.9 的目录规范):"
+ + "根目录 = {批次号}-{任务名称},第二层班级,第三层材料类型。"
+ + "**与真正打包共用同一份构建代码**,所以预览不会说谎")
+ public ApiResult preview(@RequestParam Long taskId,
+ @RequestParam(required = false) java.util.List orgIds,
+ @RequestParam(required = false) Boolean onlyApproved) {
+ ExportDtos.ScopeReq scope = new ExportDtos.ScopeReq();
+ scope.setOrgIds(orgIds == null ? new java.util.ArrayList<>() : orgIds);
+ scope.setOnlyApproved(Boolean.TRUE.equals(onlyApproved));
+ return ApiResult.ok(exportService.preview(taskId, scope));
+ }
+
+ @PostMapping
+ @RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
+ @Operation(summary = "创建导出任务",
+ description = "前置检查不过返回 **40035**,并把逐条清单放在 data 里(是谁的哪一项卡住了)。"
+ + "通过则返回 exportId,打包在后台进行,用 GET /exports/{id} 轮询进度")
+ public ApiResult create(@RequestBody ExportDtos.CreateReq req) {
+ return ApiResult.ok(exportService.create(req));
+ }
+
+ @GetMapping
+ @RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
+ @Operation(summary = "导出记录列表")
+ public ApiResult