| | |
| | | |
| | | import javax.annotation.PreDestroy; |
| | | import javax.annotation.Resource; |
| | | import java.io.File; |
| | | import java.util.Date; |
| | | import java.util.Iterator; |
| | | import java.util.LinkedHashMap; |
| | |
| | | * 目的(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 |
| | |
| | | |
| | | /** 生成结果保留时长:期间可反复下载,过后自动清理 */ |
| | | 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 */ |
| | |
| | | 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 |
| | |
| | | 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; |
| | |
| | | |
| | | 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; } |
| | | |
| | |
| | | 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); |
| | |
| | | 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"; |
| | |
| | | |
| | | 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; |
| | |
| | | } |
| | | 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; |
| | | } |
| | | } |
| | | |