zhizhijie
9 天以前 2b39c4eb64b194e23f338873e4467eda2b18ccc3
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
@@ -8,6 +8,7 @@
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.io.File;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashMap;
@@ -26,10 +27,11 @@
 * 目的(2026-09-20 用户上报「点下载要等一会,数据多了会不会卡死」):
 * 1) 点击后立刻有反馈,不再长时间无响应;
 * 2) 生成过程不占用 HTTP 请求线程,避免多人同时点把容器拖死(工作线程固定 1 个,多余请求显示「排队中」);
 * 3) 同一报表期 10 分钟内的结果可复用,避免每次重算整本约 4 万格公式。
 * 3) 同一报表期的结果可复用(**落盘保存,进程重启后依然有效**),避免每次重算整本约 4 万格公式。
 *
 * 缓存正确性守卫:只有当「距上次生成不超过 TTL」且「期间没有任何新的数据导入(import_batch 最大 id 未变)」
 * 时才复用;导入即失效,避免把过期报表给用户。前端仍提供「重新生成」强制刷新。
 * 缓存正确性守卫:只有当「期间没有任何新的数据导入(import_batch 最大 id 未变)」且「结果不超过 7 天」时才复用;
 * 导入即失效,避免把过期报表给用户。前端仍提供「重新生成」强制重算(跳过缓存)。
 * 落盘位置:{summary.template-dir}/_cache/<key>_v<数据版本>.xlsx
 */
@Slf4j
@Service
@@ -40,13 +42,41 @@
    /** 生成结果保留时长:期间可反复下载,过后自动清理 */
    private static final long RESULT_TTL_MS = 30 * 60 * 1000L;
    /** 同一报表期结果复用时长(且期间无新导入时才复用) */
    private static final long CACHE_TTL_MS = 10 * 60 * 1000L;
    /** 同一报表期结果复用上限:只要期间没有新导入就一直沿用;超过该天数不再复用(防止陈年结果被当新表发出) */
    private static final long CACHE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000;
    /** 落盘缓存子目录(相对 summary.template-dir) */
    private static final String CACHE_SUBDIR = "_cache";
    @Resource
    private ReportExportService reportService;
    @Resource
    private ImportBatchMapper importBatchMapper;
    /** 汇总工作簿目录(与 ReportExportService 同配置),落盘缓存放在其 _cache 子目录 */
    @org.springframework.beans.factory.annotation.Value("${summary.template-dir:docs/生成汇总大表}")
    private String summaryTemplateDir;
    /** 落盘缓存目录;定位策略与模板一致(配置目录 / user.dir 相对 / 上级目录) */
    private File cacheDir() {
        String rel = summaryTemplateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {rel, System.getProperty("user.dir") + "/" + rel, System.getProperty("user.dir") + "/../" + rel};
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File base = new File(root);
            if (base.isDirectory()) {
                File dir = new File(base, CACHE_SUBDIR);
                if (!dir.isDirectory() && !dir.mkdirs()) continue;
                return dir;
            }
        }
        return null;
    }
    /** 缓存文件名前缀(不含数据版本与扩展名):key 里的非法字符统一替换 */
    private String cacheFilePrefix(String type, String period, String mode, Integer toMonth) {
        return (cacheKey(type, period, mode, toMonth)).replaceAll("[^0-9A-Za-z_-]", "_");
    }
    private final Map<String, Task> tasks = new ConcurrentHashMap<>();
    /** 同报表期最近一次成功结果:key -> task */
@@ -64,7 +94,7 @@
        private final String period;
        private final String mode;
        private final Integer toMonth;
        private final long createdAt = System.currentTimeMillis();
        private volatile long createdAt = System.currentTimeMillis();
        /** 生成时的数据版本(import_batch 最大 id),用于判断缓存是否过期 */
        private final long importVersion;
        private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
@@ -72,6 +102,8 @@
        private volatile String step = "排队中";
        private volatile String error;
        private volatile byte[] data;
        /** 落盘缓存文件:内存数据被清理后仍可从这里取(跨进程重启复用) */
        private volatile File cacheFile;
        private volatile String fileName;
        private volatile boolean fromCache;
        private volatile long finishedAt;
@@ -93,11 +125,24 @@
        public String getStatus() { return status; }
        public boolean isDone() { return "DONE".equals(status) && data != null; }
        public boolean isDone() {
            return "DONE".equals(status) && (data != null || (cacheFile != null && cacheFile.isFile()));
        }
        public boolean isFromCache() { return fromCache; }
        public byte[] getData() { return data; }
        public byte[] getData() {
            if (data != null) return data;
            File f = cacheFile;
            if (f != null && f.isFile()) {
                try {
                    return java.nio.file.Files.readAllBytes(f.toPath());
                } catch (Exception e) {
                    log.warn("读取导出缓存文件失败:{} {}", f.getAbsolutePath(), e.getMessage());
                }
            }
            return null;
        }
        public String getFileName() { return fileName; }
@@ -135,8 +180,16 @@
            Task hit = cache.get(key);
            if (cacheUsable(hit)) {
                hit.fromCache = true;
                log.info("报表导出任务命中缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                log.info("报表导出任务命中内存缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                return hit;
            }
            Task disk = loadFromDisk(key, type, period, mode, toMonth);
            if (disk != null) {
                tasks.put(disk.id, disk);
                cache.put(key, disk);
                log.info("报表导出任务命中落盘缓存:{} {}(生成于 {},文件 {})", type, period,
                        new Date(disk.finishedAt), disk.cacheFile.getName());
                return disk;
            }
        }
        Task running = findRunning(key);
@@ -168,6 +221,7 @@
            t.status = "DONE";
            t.finishedAt = System.currentTimeMillis();
            cache.put(cacheKey(t.type, t.period, t.mode, t.toMonth), t);
            writeToDisk(t, data);
            log.info("报表导出任务完成:{} {} 用时 {} ms,{} 字节", t.type, t.period, t.finishedAt - start, data.length);
        } catch (Throwable e) {
            t.status = "FAILED";
@@ -188,7 +242,7 @@
    private boolean cacheUsable(Task t) {
        if (t == null || !"DONE".equals(t.status) || t.data == null) return false;
        if (System.currentTimeMillis() - t.finishedAt > CACHE_TTL_MS) return false;
        if (System.currentTimeMillis() - t.finishedAt > CACHE_MAX_AGE_MS) return false;
        long now = importVersion();
        if (now < 0 || t.importVersion < 0) return false; // 取不到数据版本时不冒风险复用
        return now == t.importVersion;
@@ -218,7 +272,66 @@
        }
        for (Iterator<Map.Entry<String, Task>> it = cache.entrySet().iterator(); it.hasNext(); ) {
            Task t = it.next().getValue();
            if (t.finishedAt <= 0 || now - t.finishedAt > CACHE_TTL_MS) it.remove();
            if (t.finishedAt <= 0 || now - t.finishedAt > CACHE_MAX_AGE_MS) it.remove();
        }
        File dir = cacheDir();
        if (dir == null) return;
        File[] fs = dir.listFiles((d, n) -> n.endsWith(".xlsx"));
        if (fs == null) return;
        for (File f : fs) {
            if (now - f.lastModified() > CACHE_MAX_AGE_MS && !f.delete()) {
                log.warn("清理过期导出缓存失败:{}", f.getAbsolutePath());
            }
        }
    }
    /** 生成成功后把结果落盘,供进程重启后复用;写盘失败只告警,不影响本次导出 */
    private void writeToDisk(Task t, byte[] data) {
        try {
            File dir = cacheDir();
            if (dir == null) return;
            long version = t.importVersion;
            if (version < 0) return;
            String prefix = cacheFilePrefix(t.type, t.period, t.mode, t.toMonth);
            File target = new File(dir, prefix + "_v" + version + ".xlsx");
            java.nio.file.Files.write(target.toPath(), data);
            File[] siblings = dir.listFiles((d, n) -> n.startsWith(prefix + "_v") && n.endsWith(".xlsx"));
            if (siblings != null) {
                for (File f : siblings) {
                    if (!f.getName().equals(target.getName()) && !f.delete()) {
                        log.warn("清理旧版本导出缓存失败:{}", f.getAbsolutePath());
                    }
                }
            }
        } catch (Exception e) {
            log.warn("导出结果落盘失败(不影响本次导出,仅无法跨重启复用):{}", e.getMessage());
        }
    }
    /** 从落盘缓存里找「同一报表期 + 同一数据版本 + 未超期」的结果;没有返回 null */
    private Task loadFromDisk(String key, String type, String period, String mode, Integer toMonth) {
        try {
            File dir = cacheDir();
            if (dir == null) return null;
            long version = importVersion();
            if (version < 0) return null; // 取不到数据版本时不冒风险复用
            File f = new File(dir, cacheFilePrefix(type, period, mode, toMonth) + "_v" + version + ".xlsx");
            if (!f.isFile()) return null;
            long finished = f.lastModified();
            if (finished <= 0 || System.currentTimeMillis() - finished > CACHE_MAX_AGE_MS) return null;
            Task t = new Task(type, period, mode, toMonth, version);
            t.cacheFile = f;
            t.createdAt = finished; // 复用历史结果:以生成时间作为起点,避免 elapsedMs 出现负数
            t.fileName = "生成_道路运输量汇总表_" + period + ".xlsx";
            t.status = "DONE";
            t.percent = 100;
            t.step = "完成";
            t.finishedAt = finished;
            t.fromCache = true;
            return t;
        } catch (Exception e) {
            log.warn("读取落盘导出缓存失败:{}", e.getMessage());
            return null;
        }
    }