From 595b31273d583fbe367b3bfbebb6fc02e318abc0 Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期二, 22 九月 2026 16:51:25 +0800
Subject: [PATCH] fix(投资): 明细表取数三处静默缺陷 + 导入两处丢数/选错页签缺陷

---
 traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java |  286 ++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 252 insertions(+), 34 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java b/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
index ac01627..b3a9ff5 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
@@ -1,18 +1,27 @@
 package com.trafficaudit.reportexport.service;
 
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.trafficaudit.dataimport.entity.ImportBatch;
-import com.trafficaudit.dataimport.mapper.ImportBatchMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.PreDestroy;
 import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
@@ -26,10 +35,13 @@
  * 鐩殑锛�2026-09-20 鐢ㄦ埛涓婃姤銆岀偣涓嬭浇瑕佺瓑涓�浼氾紝鏁版嵁澶氫簡浼氫笉浼氬崱姝汇�嶏級锛�
  * 1) 鐐瑰嚮鍚庣珛鍒绘湁鍙嶉锛屼笉鍐嶉暱鏃堕棿鏃犲搷搴旓紱
  * 2) 鐢熸垚杩囩▼涓嶅崰鐢� HTTP 璇锋眰绾跨▼锛岄伩鍏嶅浜哄悓鏃剁偣鎶婂鍣ㄦ嫋姝伙紙宸ヤ綔绾跨▼鍥哄畾 1 涓紝澶氫綑璇锋眰鏄剧ず銆屾帓闃熶腑銆嶏級锛�
- * 3) 鍚屼竴鎶ヨ〃鏈� 10 鍒嗛挓鍐呯殑缁撴灉鍙鐢紝閬垮厤姣忔閲嶇畻鏁存湰绾� 4 涓囨牸鍏紡銆�
+ * 3) 鍚屼竴鎶ヨ〃鏈熺殑缁撴灉鍙鐢紙**钀界洏淇濆瓨锛岃繘绋嬮噸鍚悗渚濈劧鏈夋晥**锛夛紝閬垮厤姣忔閲嶇畻鏁存湰绾� 4 涓囨牸鍏紡銆�
  *
- * 缂撳瓨姝g‘鎬у畧鍗細鍙湁褰撱�岃窛涓婃鐢熸垚涓嶈秴杩� TTL銆嶄笖銆屾湡闂存病鏈変换浣曟柊鐨勬暟鎹鍏ワ紙import_batch 鏈�澶� id 鏈彉锛夈��
- * 鏃舵墠澶嶇敤锛涘鍏ュ嵆澶辨晥锛岄伩鍏嶆妸杩囨湡鎶ヨ〃缁欑敤鎴枫�傚墠绔粛鎻愪緵銆岄噸鏂扮敓鎴愩�嶅己鍒跺埛鏂般��
+ * 缂撳瓨姝g‘鎬у畧鍗紳**鏁版嵁鎸囩汗**锛氭寚绾圭敱銆屾眹鎬诲ぇ琛ㄤ緷璧栫殑婧愯〃鍐呭鏍¢獙鍜屻��+銆屾瘝鐗堢瓑鏂囦欢韬唤锛堝ぇ灏�/淇敼鏃堕棿锛夈�嶇畻鍑猴紝
+ * 鍙湁鎸囩汗涓�鑷达紙涓旂粨鏋滀笉瓒呰繃 7 澶╋級鎵嶅鐢ㄣ�傚洜姝わ細
+ *   - 鎶曡祫/鑳借��/瀹℃牳绛�**涓庢眹鎬诲ぇ琛ㄦ棤鍏�**鐨勫鍏ヤ笉鍐嶈缂撳瓨澶辨晥锛�
+ *   - 鎹㈡瘝鐗堛�佹敼鍙傜収浠躲�佸姩鐩稿叧婧愯〃锛堝惈鍘熷湴 UPDATE锛夐兘浼氳缂撳瓨澶辨晥锛屼笉浼氭妸鏃ц〃褰撴柊琛ㄥ彂鍑恒��
+ * 鍓嶇浠嶆彁渚涖�岄噸鏂扮敓鎴愩�嶅己鍒堕噸绠楋紙璺宠繃缂撳瓨锛夈�傝惤鐩樹綅缃細{summary.template-dir}/_cache/<key>_v<鎸囩汗>.xlsx
  */
 @Slf4j
 @Service
@@ -40,13 +52,54 @@
 
     /** 鐢熸垚缁撴灉淇濈暀鏃堕暱锛氭湡闂村彲鍙嶅涓嬭浇锛岃繃鍚庤嚜鍔ㄦ竻鐞� */
     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;
+    private DataSource dataSource;
+
+    /** 姹囨�诲伐浣滅翱鐩綍锛堜笌 ReportExportService 鍚岄厤缃級锛岃惤鐩樼紦瀛樻斁鍦ㄥ叾 _cache 瀛愮洰褰� */
+    @org.springframework.beans.factory.annotation.Value("${summary.template-dir:docs/鐢熸垚姹囨�诲ぇ琛▆")
+    private String summaryTemplateDir;
+
+    /** 鍩庡競瀹㈣繍鐩綍锛氱綉绾﹁溅璁㈠崟鍙婂叏鐪佹�婚噺.xlsx锛堝簱閲岀己璇ユ湀鏃剁殑鍏滃簳杈撳叆锛� */
+    @org.springframework.beans.factory.annotation.Value("${city-passenger.template-dir:docs/鍩庡競瀹㈣繍}")
+    private String cityPassengerTemplateDir;
+
+    /** 璁$畻鎸囩汗鏃�**鎺掗櫎**鐨勮〃锛氭姇璧勭嚎銆佽兘鑰楃嚎銆佸鏍哥嚎銆佺郴缁熻〃銆佸鍏ユ棩蹇椼��
+     *  杩欎簺琛ㄥ彉鍖栦笉褰卞搷姹囨�诲ぇ琛ㄥ唴瀹癸紝娌″繀瑕佽缂撳瓨澶辨晥锛涘叾浣欒〃涓�寰嬬撼鍏ワ紙瀹佸彲澶氱畻涓嶅彲鍑洪敊锛夈�� */
+    private static final Set<String> FINGERPRINT_EXCLUDE = new HashSet<>(Arrays.asList(
+        "investment_project", "investment_monthly", "investment_system",
+        "h204_vehicle_quarterly", "h204_auth_vehicle",
+        "audit_result", "audit_run", "audit_rule", "audit_explanation",
+        "sys_user", "sys_role", "sys_user_role", "sys_dept", "sys_operation_log",
+        "llm_desensitize_map", "import_batch"));
+
+    /** 钀界洏缂撳瓨鐩綍锛涘畾浣嶇瓥鐣ヤ笌妯℃澘涓�鑷达紙閰嶇疆鐩綍 / 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;
+    }
+
+    /** 缂撳瓨鏂囦欢鍚嶅墠缂�锛堜笉鍚暟鎹増鏈笌鎵╁睍鍚嶏級锛歬ey 閲岀殑闈炴硶瀛楃缁熶竴鏇挎崲 */
+    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,24 +117,28 @@
         private final String period;
         private final String mode;
         private final Integer toMonth;
-        private final long createdAt = System.currentTimeMillis();
-        /** 鐢熸垚鏃剁殑鏁版嵁鐗堟湰锛坕mport_batch 鏈�澶� id锛夛紝鐢ㄤ簬鍒ゆ柇缂撳瓨鏄惁杩囨湡 */
-        private final long importVersion;
+        private volatile long createdAt = System.currentTimeMillis();
+        /** 鐢熸垚鏃剁殑鏁版嵁鎸囩汗锛堟簮琛ㄦ牎楠屽拰 + 姣嶇増鏂囦欢韬唤锛夛紝鐢ㄤ簬鍒ゆ柇缂撳瓨鏄惁杩囨湡 */
+        private final String fingerprint;
         private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
         private volatile int percent = 0;
         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 boolean staleCache;
         private volatile long finishedAt;
 
-        Task(String type, String period, String mode, Integer toMonth, long importVersion) {
+        Task(String type, String period, String mode, Integer toMonth, String fingerprint) {
             this.type = type;
             this.period = period;
             this.mode = mode;
             this.toMonth = toMonth;
-            this.importVersion = importVersion;
+            this.fingerprint = fingerprint;
         }
 
         void progress(int pct, String stepText) {
@@ -93,11 +150,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; }
 
@@ -114,6 +184,7 @@
             m.put("percent", "DONE".equals(status) ? 100 : percent);
             m.put("step", step);
             m.put("fromCache", fromCache);
+            m.put("staleCache", staleCache);
             m.put("fileName", fileName);
             m.put("error", error);
             long end = finishedAt > 0 ? finishedAt : System.currentTimeMillis();
@@ -125,18 +196,28 @@
 
     /**
      * 鎻愪氦瀵煎嚭浠诲姟銆俧orceRefresh=true 鏃跺拷鐣ョ紦瀛橀噸鏂扮敓鎴愶紱
-     * 鍚﹀垯鍦ㄣ�屽悓涓�鎶ヨ〃鏈熴��10 鍒嗛挓鍐呫�佹湡闂存棤鏂板鍏ャ�嶇殑鍓嶆彁涓嬬洿鎺ュ鐢ㄤ笂娆$粨鏋溿��
+     * 鍚﹀垯鍦ㄣ�屽悓涓�鎶ヨ〃鏈� + 鏁版嵁鎸囩汗涓�鑷� + 7 澶╁唴銆嶇殑鍓嶆彁涓嬬洿鎺ュ鐢ㄤ笂娆$粨鏋溿��
      * 鍚屼竴鎶ヨ〃鏈熷凡鏈変换鍔″湪璺戞椂鐩存帴杩斿洖璇ヤ换鍔★紝閬垮厤閲嶅鎺掗槦銆�
      */
     public Task submit(String type, String period, String mode, Integer toMonth, boolean forceRefresh) {
         cleanupExpired();
         String key = cacheKey(type, period, mode, toMonth);
+        String fingerprint = dataFingerprint();
         if (!forceRefresh) {
             Task hit = cache.get(key);
-            if (cacheUsable(hit)) {
+            if (cacheUsable(hit, fingerprint)) {
                 hit.fromCache = true;
-                log.info("鎶ヨ〃瀵煎嚭浠诲姟鍛戒腑缂撳瓨锛歿} {}锛堢敓鎴愪簬 {}锛�", type, period, new Date(hit.finishedAt));
+                hit.staleCache = false; // 鍛戒腑缂撳瓨鏃朵笉鑳芥畫鐣欎笂涓�娆°�岀粨鏋滃凡澶辨晥銆嶇殑鎻愮ず
+                log.info("鎶ヨ〃瀵煎嚭浠诲姟鍛戒腑鍐呭瓨缂撳瓨锛歿} {}锛堢敓鎴愪簬 {}锛�", type, period, new Date(hit.finishedAt));
                 return hit;
+            }
+            Task disk = loadFromDisk(key, type, period, mode, toMonth, fingerprint);
+            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);
@@ -144,7 +225,9 @@
             log.info("鎶ヨ〃瀵煎嚭浠诲姟宸插湪鎵ц锛屽鐢ㄨ繘琛屼腑鐨勪换鍔★細{} {}锛坽}锛�", type, period, running.id);
             return running;
         }
-        Task task = new Task(type, period, mode, toMonth, importVersion());
+        Task task = new Task(type, period, mode, toMonth, fingerprint);
+        // 鏈夋棫缁撴灉浣嗘病鍛戒腑锛堟暟鎹垨姣嶇増鍙樹簡锛夆啋 鍓嶇鎻愮ず涓�鍙ワ紝閬垮厤鐢ㄦ埛浠ヤ负"缂撳瓨鍧忎簡"
+        task.staleCache = !forceRefresh && hasStaleCache(type, period, mode, toMonth, fingerprint);
         tasks.put(task.id, task);
         pool.submit(() -> run(task));
         return task;
@@ -168,6 +251,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";
@@ -186,23 +270,99 @@
         return null;
     }
 
-    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;
-        long now = importVersion();
-        if (now < 0 || t.importVersion < 0) return false; // 鍙栦笉鍒版暟鎹増鏈椂涓嶅啋椋庨櫓澶嶇敤
-        return now == t.importVersion;
+    private boolean cacheUsable(Task t, String fingerprint) {
+        if (t == null || !"DONE".equals(t.status)) return false;
+        if (t.data == null && (t.cacheFile == null || !t.cacheFile.isFile())) return false;
+        if (System.currentTimeMillis() - t.finishedAt > CACHE_MAX_AGE_MS) return false;
+        if (fingerprint == null || t.fingerprint == null) return false; // 绠椾笉鍑烘寚绾规椂涓嶅啋椋庨櫓澶嶇敤
+        return fingerprint.equals(t.fingerprint);
     }
 
-    /** 鏁版嵁鐗堟湰锛漣mport_batch 鏈�澶� id锛氫换浣曚竴娆″鍏ラ兘浼氳瀹冨彉鍖栵紝鍙栦笉鍒拌繑鍥� -1锛堟鏃剁鐢ㄧ紦瀛橈級 */
-    private long importVersion() {
+    /**
+     * 姹囨�诲ぇ琛ㄦ暟鎹寚绾癸紳銆屼緷璧栫殑婧愯〃鍐呭鏍¢獙鍜屻��+銆屾瘝鐗堢瓑鏂囦欢韬唤銆嶃��
+     * - 婧愯〃锛氬簱鍐呴櫎 FINGERPRINT_EXCLUDE锛堟姇璧�/鑳借��/瀹℃牳/绯荤粺/瀵煎叆鏃ュ織锛変互澶栫殑鍏ㄩ儴琛紝鐢� CHECKSUM TABLE 鍙栨牎楠屽拰锛�
+     *   鑳芥崟鎹夎鏁板彉鍖栥�佹柊澧炲垹闄ゃ��**鍘熷湴 UPDATE**锛�
+     * - 鏂囦欢锛氭瘝鐗堢洰褰曚笅鍏ㄩ儴鏂囦欢锛堟瘝鐗� / 鍙傜収浠� / _澶囦唤_ 鍥炲~浠讹級+銆婄綉绾﹁溅璁㈠崟鍙婂叏鐪佹�婚噺.xlsx銆嬶紝鍙栥�屽悕绉�+澶у皬+淇敼鏃堕棿銆嶃��
+     * 浠讳竴椤瑰彉鍖栧嵆鎸囩汗鍙樺寲 鈫� 缂撳瓨澶辨晥銆傝绠楀け璐ヨ繑鍥� null锛堟鏃剁鐢ㄧ紦瀛橈紝鍙噸绠椾笉澶嶇敤锛夈��
+     */
+    private String dataFingerprint() {
         try {
-            List<Object> v = importBatchMapper.selectObjs(new QueryWrapper<ImportBatch>().select("IFNULL(MAX(id),0)"));
-            if (v == null || v.isEmpty() || v.get(0) == null) return 0L;
-            return ((Number) v.get(0)).longValue();
+            List<String> parts = new ArrayList<>();
+            try (Connection conn = dataSource.getConnection()) {
+                List<String> tables = new ArrayList<>();
+                try (Statement st = conn.createStatement();
+                     ResultSet rs = st.executeQuery("SELECT table_name FROM information_schema.tables "
+                             + "WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name")) {
+                    while (rs.next()) tables.add(rs.getString(1));
+                }
+                for (String t : tables) {
+                    if (FINGERPRINT_EXCLUDE.contains(t)) continue;
+                    try (Statement st = conn.createStatement();
+                         ResultSet rs = st.executeQuery("CHECKSUM TABLE `" + t + "`")) {
+                        if (rs.next()) parts.add("T:" + t + ":" + rs.getString(2));
+                    }
+                }
+            }
+            for (File f : listIdentityFiles(resolveBaseDir(summaryTemplateDir))) {
+                parts.add("F:" + f.getName() + ":" + f.length() + ":" + f.lastModified());
+            }
+            File wyc = new File(resolveBaseDir(cityPassengerTemplateDir), "缃戠害杞﹁鍗曞強鍏ㄧ渷鎬婚噺.xlsx");
+            if (wyc.isFile()) parts.add("F:" + wyc.getName() + ":" + wyc.length() + ":" + wyc.lastModified());
+            return sha256Hex(String.join("|", parts)).substring(0, 16);
         } catch (Exception e) {
-            log.warn("璇诲彇瀵煎叆鏁版嵁鐗堟湰澶辫触锛屾湰娆$鐢ㄥ鍑虹紦瀛橈細{}", e.getMessage());
-            return -1L;
+            log.warn("璁$畻鏁版嵁鎸囩汗澶辫触锛屾湰娆$鐢ㄥ鍑虹紦瀛橈細{}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** 姣嶇増鐩綍绛夊熀纭�鐩綍瀹氫綅锛堜笌妯℃澘瑙f瀽鍚岀瓥鐣ワ細閰嶇疆鐩綍 / user.dir 鐩稿 / 涓婄骇鐩綍锛� */
+    private File resolveBaseDir(String dir) {
+        if (dir == null) return null;
+        String rel = dir;
+        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 f = new File(root);
+            if (f.isDirectory()) return f;
+        }
+        return null;
+    }
+
+    /** 鐩綍涓嬬殑鏂囦欢锛堟寜鍚嶅瓧鎺掑簭锛屾帓闄ゅ瓙鐩綍锛屼繚璇佹寚绾圭ǔ瀹氾級 */
+    private List<File> listIdentityFiles(File dir) {
+        List<File> out = new ArrayList<>();
+        if (dir == null) return out;
+        File[] fs = dir.listFiles(File::isFile);
+        if (fs == null) return out;
+        Arrays.sort(fs, Comparator.comparing(File::getName));
+        out.addAll(Arrays.asList(fs));
+        return out;
+    }
+
+    private String sha256Hex(String text) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        byte[] d = md.digest(text.getBytes(StandardCharsets.UTF_8));
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < 8; i++) sb.append(String.format("%02x", d[i])); // 鍙栧墠 8 瀛楄妭锛�16 浣嶅崄鍏繘鍒讹紝澶熺敤涓旀枃浠跺悕鐭�
+        return sb.toString();
+    }
+
+    /** 璇ユ姤琛ㄦ湡鏄惁瀛樺湪銆屼笌褰撳墠鎸囩汗涓嶄竴鑷淬�嶇殑鏃х粨鏋滐紙鐢ㄤ簬鎻愮ず銆屼笂娆$粨鏋滃凡澶辨晥锛屾鍦ㄦ寜鏈�鏂版暟鎹噸绠椼�嶏級 */
+    private boolean hasStaleCache(String type, String period, String mode, Integer toMonth, String fingerprint) {
+        try {
+            File dir = cacheDir();
+            if (dir == null) return false;
+            String prefix = cacheFilePrefix(type, period, mode, toMonth);
+            File[] fs = dir.listFiles((d, n) -> n.startsWith(prefix + "_v") && n.endsWith(".xlsx"));
+            if (fs == null || fs.length == 0) return false;
+            String current = prefix + "_v" + fingerprint + ".xlsx";
+            for (File f : fs) {
+                if (!f.getName().equals(current)) return true;
+            }
+            return false;
+        } catch (Exception e) {
+            return false;
         }
     }
 
@@ -218,7 +378,65 @@
         }
         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;
+            String fingerprint = t.fingerprint;
+            if (fingerprint == null) return;
+            String prefix = cacheFilePrefix(t.type, t.period, t.mode, t.toMonth);
+            File target = new File(dir, prefix + "_v" + fingerprint + ".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, String fingerprint) {
+        try {
+            if (fingerprint == null) return null; // 绠椾笉鍑烘寚绾规椂涓嶅啋椋庨櫓澶嶇敤
+            File dir = cacheDir();
+            if (dir == null) return null;
+            File f = new File(dir, cacheFilePrefix(type, period, mode, toMonth) + "_v" + fingerprint + ".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, fingerprint);
+            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;
         }
     }
 

--
Gitblit v1.9.1