From 71a77e721619e956cab116be691cc3ab58e18093 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期二, 22 九月 2026 15:35:09 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/main'

---
 traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java |  177 +++++++++++++++++++++++++++++++++++++++++++++++------------
 1 files changed, 141 insertions(+), 36 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 6c430eb..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,19 +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;
@@ -29,9 +37,11 @@
  * 2) 鐢熸垚杩囩▼涓嶅崰鐢� HTTP 璇锋眰绾跨▼锛岄伩鍏嶅浜哄悓鏃剁偣鎶婂鍣ㄦ嫋姝伙紙宸ヤ綔绾跨▼鍥哄畾 1 涓紝澶氫綑璇锋眰鏄剧ず銆屾帓闃熶腑銆嶏級锛�
  * 3) 鍚屼竴鎶ヨ〃鏈熺殑缁撴灉鍙鐢紙**钀界洏淇濆瓨锛岃繘绋嬮噸鍚悗渚濈劧鏈夋晥**锛夛紝閬垮厤姣忔閲嶇畻鏁存湰绾� 4 涓囨牸鍏紡銆�
  *
- * 缂撳瓨姝g‘鎬у畧鍗細鍙湁褰撱�屾湡闂存病鏈変换浣曟柊鐨勬暟鎹鍏ワ紙import_batch 鏈�澶� id 鏈彉锛夈�嶄笖銆岀粨鏋滀笉瓒呰繃 7 澶┿�嶆椂鎵嶅鐢紱
- * 瀵煎叆鍗冲け鏁堬紝閬垮厤鎶婅繃鏈熸姤琛ㄧ粰鐢ㄦ埛銆傚墠绔粛鎻愪緵銆岄噸鏂扮敓鎴愩�嶅己鍒堕噸绠楋紙璺宠繃缂撳瓨锛夈��
- * 钀界洏浣嶇疆锛歿summary.template-dir}/_cache/<key>_v<鏁版嵁鐗堟湰>.xlsx
+ * 缂撳瓨姝g‘鎬у畧鍗紳**鏁版嵁鎸囩汗**锛氭寚绾圭敱銆屾眹鎬诲ぇ琛ㄤ緷璧栫殑婧愯〃鍐呭鏍¢獙鍜屻��+銆屾瘝鐗堢瓑鏂囦欢韬唤锛堝ぇ灏�/淇敼鏃堕棿锛夈�嶇畻鍑猴紝
+ * 鍙湁鎸囩汗涓�鑷达紙涓旂粨鏋滀笉瓒呰繃 7 澶╋級鎵嶅鐢ㄣ�傚洜姝わ細
+ *   - 鎶曡祫/鑳借��/瀹℃牳绛�**涓庢眹鎬诲ぇ琛ㄦ棤鍏�**鐨勫鍏ヤ笉鍐嶈缂撳瓨澶辨晥锛�
+ *   - 鎹㈡瘝鐗堛�佹敼鍙傜収浠躲�佸姩鐩稿叧婧愯〃锛堝惈鍘熷湴 UPDATE锛夐兘浼氳缂撳瓨澶辨晥锛屼笉浼氭妸鏃ц〃褰撴柊琛ㄥ彂鍑恒��
+ * 鍓嶇浠嶆彁渚涖�岄噸鏂扮敓鎴愩�嶅己鍒堕噸绠楋紙璺宠繃缂撳瓨锛夈�傝惤鐩樹綅缃細{summary.template-dir}/_cache/<key>_v<鎸囩汗>.xlsx
  */
 @Slf4j
 @Service
@@ -50,11 +60,24 @@
     @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() {
@@ -95,8 +118,8 @@
         private final String mode;
         private final Integer toMonth;
         private volatile long createdAt = System.currentTimeMillis();
-        /** 鐢熸垚鏃剁殑鏁版嵁鐗堟湰锛坕mport_batch 鏈�澶� id锛夛紝鐢ㄤ簬鍒ゆ柇缂撳瓨鏄惁杩囨湡 */
-        private final long importVersion;
+        /** 鐢熸垚鏃剁殑鏁版嵁鎸囩汗锛堟簮琛ㄦ牎楠屽拰 + 姣嶇増鏂囦欢韬唤锛夛紝鐢ㄤ簬鍒ゆ柇缂撳瓨鏄惁杩囨湡 */
+        private final String fingerprint;
         private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
         private volatile int percent = 0;
         private volatile String step = "鎺掗槦涓�";
@@ -106,14 +129,16 @@
         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) {
@@ -159,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();
@@ -170,20 +196,22 @@
 
     /**
      * 鎻愪氦瀵煎嚭浠诲姟銆俧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;
+                hit.staleCache = false; // 鍛戒腑缂撳瓨鏃朵笉鑳芥畫鐣欎笂涓�娆°�岀粨鏋滃凡澶辨晥銆嶇殑鎻愮ず
                 log.info("鎶ヨ〃瀵煎嚭浠诲姟鍛戒腑鍐呭瓨缂撳瓨锛歿} {}锛堢敓鎴愪簬 {}锛�", type, period, new Date(hit.finishedAt));
                 return hit;
             }
-            Task disk = loadFromDisk(key, type, period, mode, toMonth);
+            Task disk = loadFromDisk(key, type, period, mode, toMonth, fingerprint);
             if (disk != null) {
                 tasks.put(disk.id, disk);
                 cache.put(key, disk);
@@ -197,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;
@@ -240,23 +270,99 @@
         return null;
     }
 
-    private boolean cacheUsable(Task t) {
-        if (t == null || !"DONE".equals(t.status) || t.data == null) return false;
+    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;
-        long now = importVersion();
-        if (now < 0 || t.importVersion < 0) return false; // 鍙栦笉鍒版暟鎹増鏈椂涓嶅啋椋庨櫓澶嶇敤
-        return now == t.importVersion;
+        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;
         }
     }
 
@@ -290,10 +396,10 @@
         try {
             File dir = cacheDir();
             if (dir == null) return;
-            long version = t.importVersion;
-            if (version < 0) 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" + version + ".xlsx");
+            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) {
@@ -308,18 +414,17 @@
         }
     }
 
-    /** 浠庤惤鐩樼紦瀛橀噷鎵俱�屽悓涓�鎶ヨ〃鏈� + 鍚屼竴鏁版嵁鐗堟湰 + 鏈秴鏈熴�嶇殑缁撴灉锛涙病鏈夎繑鍥� null */
-    private Task loadFromDisk(String key, String type, String period, String mode, Integer toMonth) {
+    /** 浠庤惤鐩樼紦瀛橀噷鎵俱�屽悓涓�鎶ヨ〃鏈� + 鍚屼竴鏁版嵁鎸囩汗 + 鏈秴鏈熴�嶇殑缁撴灉锛涙病鏈夎繑鍥� 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;
-            long version = importVersion();
-            if (version < 0) return null; // 鍙栦笉鍒版暟鎹増鏈椂涓嶅啋椋庨櫓澶嶇敤
-            File f = new File(dir, cacheFilePrefix(type, period, mode, toMonth) + "_v" + version + ".xlsx");
+            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, version);
+            Task t = new Task(type, period, mode, toMonth, fingerprint);
             t.cacheFile = f;
             t.createdAt = finished; // 澶嶇敤鍘嗗彶缁撴灉锛氫互鐢熸垚鏃堕棿浣滀负璧风偣锛岄伩鍏� elapsedMs 鍑虹幇璐熸暟
             t.fileName = "鐢熸垚_閬撹矾杩愯緭閲忔眹鎬昏〃_" + period + ".xlsx";

--
Gitblit v1.9.1