Переглянути джерело

缓存事务添加乐观锁

DESKTOP-6LTVLN7\Liumouren 3 тижнів тому
батько
коміт
50e77e51b3

+ 6 - 0
docs/feasibility-gap-and-lifecycle.md

@@ -45,6 +45,12 @@
 - `_smoke_812.py`:字典/角色/继承人门禁/进度/校验规则  
 - `_smoke_gap_fix.py`:本轮缺口 API 全量冒烟  
 
+## 并发策略(落地摘要)
+
+- **人员编辑**:前端带回 `updateTimeMs` → `expectedUpdateTime`;后端比对 `update_time`,冲突返回业务码 **409**(「数据已被他人修改」)。
+- **短互斥**:领导批审批、特殊业务审批、单人资格审批使用 JVM 内 `ConcurrentBizLock`;拿不到锁同样 **409**。多实例部署时仍依赖乐观锁,不能单靠本地锁。
+- **前端**:统一拦截里对 4xx(含 409)用警告提示,无需页面单独分支。
+
 ## 说明
 
 此前矩阵存在「后端有 / 前端未接线仍算 DONE」。以本文件为准;高优先级 8 项已在前后端同时闭合并冒烟通过。

+ 35 - 0
docs/qa-bug-report-20260821.md

@@ -0,0 +1,35 @@
+# 测试与逻辑审查报告(2026-08-21)
+
+> 源码静态审查 + 本机 API 实测(`localhost:8088`)  
+> **2026-08-21 晚:本地后台已按下列项修复并重启验证**
+
+## 修复状态(本地)
+
+| 编号 | 问题 | 状态 | 验证 |
+|------|------|------|------|
+| C1 | 资格审批未校验待审 | **已修** | NORMAL 调 review → 业务码 409 |
+| C2 | 暂停月标被清零 | **已修** | 源码去掉清零,与注释一致 |
+| C3/H2/H5 | 乐观锁/短互斥 | **已修** | 错戳 409;缺戳 400;正确戳 200 |
+| H1 | 运行实例未部署 | **已修(本地)** | Swagger 含 `expectedUpdateTime` |
+| H3 | 告警双写 | **已修** | handleAlert 只关告警;页面一次 savePerson |
+| H4 | 批量资格无锁 | **已修** | 按人 `qualify-review:{id}` |
+| M2 | 409 无刷新 | **已修** | 管理员编辑 409 后重拉详情 |
+| M3 | 备注 sanitize 回写 | **已修** | `remarkRaw` 优先回传 |
+| M4 | 锁 Map 膨胀 | **已修** | unlock 后无等待者 remove |
+| M1 | 村级仅新增 | 未改(产品缺口) | — |
+| L1 | 登录审计冒烟 | 未改 | — |
+
+## 结论(修复后)
+
+本地已重启:资格门禁与乐观锁现场探针通过。以后打线上包时需重新打 WAR/重启对应实例。
+
+## 历史:修复前发现摘要
+
+| 统计(修复前) | 数量 |
+|------|------|
+| Critical | 3 |
+| High | 5 |
+| Medium | 4 |
+| Low | 1 |
+
+详见同目录此前全文;本文件以「修复状态」为准。

+ 67 - 0
src/main/java/com/yykj/sjnmtybt/common/ConcurrentBizLock.java

@@ -0,0 +1,67 @@
+package com.yykj.sjnmtybt.common;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 单业务写操作短时互斥:同一 bizKey 同时只允许一个写线程,避免双人同时审批/删除。
+ * 锁在 JVM 内有效;多实例部署时仍需配合乐观锁(版本号)。
+ */
+public final class ConcurrentBizLock {
+
+    private static final ConcurrentHashMap<String, ReentrantLock> LOCKS =
+            new ConcurrentHashMap<String, ReentrantLock>();
+
+    private ConcurrentBizLock() {
+    }
+
+    /**
+     * 尝试获取锁;拿不到立即抛 409。
+     * @return 必须在 finally 中 {@link #unlock(String, ReentrantLock)}
+     */
+    public static ReentrantLock tryLock(String bizKey, String busyMessage) {
+        if (bizKey == null || bizKey.isEmpty()) {
+            throw new BusinessException(400, "业务锁 key 不能为空");
+        }
+        ReentrantLock lock = LOCKS.computeIfAbsent(bizKey, new java.util.function.Function<String, ReentrantLock>() {
+            @Override
+            public ReentrantLock apply(String k) {
+                return new ReentrantLock();
+            }
+        });
+        boolean ok;
+        try {
+            ok = lock.tryLock(0, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BusinessException(409, busyMessage != null ? busyMessage : "操作冲突,请稍后重试");
+        }
+        if (!ok) {
+            throw new BusinessException(409,
+                    busyMessage != null ? busyMessage : "其他人正在处理该数据,请稍后刷新重试");
+        }
+        return lock;
+    }
+
+    /** @deprecated 请用 {@link #unlock(String, ReentrantLock)} 以便回收无用锁 */
+    public static void unlock(ReentrantLock lock) {
+        unlock(null, lock);
+    }
+
+    /**
+     * 释放锁;若无人等待则从 Map 移除,避免长期运行 key 膨胀。
+     */
+    public static void unlock(String bizKey, ReentrantLock lock) {
+        if (lock == null) {
+            return;
+        }
+        if (lock.isHeldByCurrentThread()) {
+            lock.unlock();
+        }
+        if (bizKey != null && !bizKey.isEmpty()
+                && !lock.isLocked() && !lock.hasQueuedThreads()) {
+            LOCKS.remove(bizKey, lock);
+        }
+    }
+}

+ 12 - 0
src/main/java/com/yykj/sjnmtybt/controller/AlertController.java

@@ -13,6 +13,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Map;
+
 @Tag(name = "11-告警处置", description = "流程图红色系统预警(如死亡停发)及回盘/匹配异常;DMS sjnmtybt_alart")
 @RestController
 @RequestMapping("/api/alerts")
@@ -44,6 +46,16 @@ public class AlertController {
         return ApiResponse.ok(vo);
     }
 
+    @Operation(summary = "合并重复告警(运维)",
+            description = "同人+归一化正文仅保留最早一条,其余从 DMS 物理删除(含已关闭历史副本)。"
+                    + "兼容旧参数名 dedupe-pending。")
+    @PostMapping({"/dedupe-pending", "/purge-duplicates"})
+    public ApiResponse<Map<String, Object>> dedupePending(
+            @Parameter(description = "街镇ID,可选") @RequestParam(required = false) String townId,
+            @Parameter(description = "村居ID,可选") @RequestParam(required = false) String villageId) {
+        return ApiResponse.ok("重复告警已清理", flow.purgeDuplicateAlerts(townId, villageId));
+    }
+
     @Operation(summary = "处置告警",
             description = "处理结果示例:恢复发放、纳入下月补发、更新银行卡、人工确认居保状态、关闭。")
     @PostMapping("/handle")

+ 13 - 1
src/main/java/com/yykj/sjnmtybt/controller/BatchController.java

@@ -137,13 +137,25 @@ public class BatchController {
     }
 
     @Operation(summary = "业务部门提交本镇月补贴清单至分管领导",
-            description = "请提交镇统计记录(batchLevel=TOWN)。本镇当月特殊业务须全部通过;不改全区主批状态。")
+            description = "请提交镇统计记录(batchLevel=TOWN)。本镇当月特殊业务须全部通过;"
+                    + "批次内居保未匹配/未通过人员须清零;不改全区主批状态。")
     @PostMapping("/{id}/submit-leader")
     public ApiResponse<BatchVO> submitLeader(
             @Parameter(description = "镇统计记录ID,如 TB202608-T_YEXIE", required = true) @PathVariable String id) {
         return ApiResponse.ok("已提交分管领导审批", flow.submitMonthlyToLeader(id));
     }
 
+    @Operation(summary = "统计批次内居保未闭环人数",
+            description = "未匹配/匹配失败/丧失资格等均计入;丧葬/停止/归档人员不计。用于前端禁用「提交领导」。")
+    @GetMapping("/{id}/unresolved-insurance-count")
+    public ApiResponse<java.util.Map<String, Object>> unresolvedInsuranceCount(
+            @Parameter(description = "镇统计或全区批次ID", required = true) @PathVariable String id) {
+        java.util.Map<String, Object> r = new java.util.LinkedHashMap<String, Object>();
+        r.put("batchId", id);
+        r.put("count", flow.countUnresolvedInsurance(id));
+        return ApiResponse.ok(r);
+    }
+
     @Operation(summary = "分管领导线上审批月补贴清单",
             description = "流程图:生成月补贴清单后,由社区事务中心分管领导线上审批;"
                     + "通过→生成月补贴对照文件;驳回→退回业务部门调整。")

+ 12 - 0
src/main/java/com/yykj/sjnmtybt/dto/request/PaymentDetailQueryRequest.java

@@ -16,4 +16,16 @@ public class PaymentDetailQueryRequest extends PageQuery {
     @Schema(description = "姓名(模糊包含)") private String name;
     @Schema(description = "身份证号(模糊包含,可输后几位)") private String idNumber;
     @Schema(description = "人员业务ID") private String personnelId;
+    /** totalAmount / funeralAmount / supplementAmount / monthlyAmount */
+    @Schema(description = "排序字段:totalAmount|funeralAmount|supplementAmount|monthlyAmount")
+    private String sortBy;
+    /** asc / desc,默认 desc */
+    @Schema(description = "排序方向:asc|desc")
+    private String sortOrder;
+    /** 仅含丧葬金额>0 */
+    @Schema(description = "仅看有丧葬金额")
+    private Boolean onlyFuneral;
+    /** 仅含补发金额>0 */
+    @Schema(description = "仅看有补发金额")
+    private Boolean onlySupplement;
 }

+ 6 - 0
src/main/java/com/yykj/sjnmtybt/dto/request/PersonnelSaveRequest.java

@@ -51,4 +51,10 @@ public class PersonnelSaveRequest {
     private String operatorRoleKey;
     @Schema(description = "人员业务状态;导入可填,空则新增默认草稿、更新已有人则保持原状态")
     private PersonnelStatusEnum bizStatus;
+    /**
+     * 乐观锁:编辑时带回打开详情时的 updateTime(毫秒)。
+     * 与库中不一致则 409,提示刷新后重试;新增或不传则不做版本校验。
+     */
+    @Schema(description = "编辑乐观锁:打开详情时的 updateTime(毫秒时间戳)")
+    private Long expectedUpdateTime;
 }

+ 676 - 20
src/main/java/com/yykj/sjnmtybt/service/BizDataService.java

@@ -74,6 +74,7 @@ public class BizDataService {
     // ---------- 区划 ----------
     private final Object regionListLock = new Object();
     private volatile List<RegionVO> regionListCache;
+    private volatile Map<String, Map<String, Object>> regionRowByKey;
     private volatile Map<String, String> regionDisplayIndex;
     private volatile long regionListCacheExpireAt;
     private static final long REGION_LIST_CACHE_TTL_MS = 300_000L;
@@ -120,6 +121,7 @@ public class BizDataService {
                 props.getDms().getColumns().getRegion().getModelId(),
                 200);
         List<RegionVO> all = new ArrayList<RegionVO>();
+        Map<String, Map<String, Object>> idx = new HashMap<String, Map<String, Object>>();
         java.util.Set<String> seen = new java.util.HashSet<String>();
         if (rows != null) {
             for (Map<String, Object> row : rows) {
@@ -129,9 +131,17 @@ public class BizDataService {
                     continue;
                 }
                 all.add(vo);
+                if (StringUtils.hasText(key)) {
+                    idx.put(key, row);
+                }
+                String dmsId = DmsRowUtils.str(row, "id");
+                if (StringUtils.hasText(dmsId)) {
+                    idx.put(dmsId, row);
+                }
             }
         }
         regionListCache = all;
+        regionRowByKey = idx;
         regionDisplayIndex = null;
         regionListCacheExpireAt = System.currentTimeMillis() + REGION_LIST_CACHE_TTL_MS;
     }
@@ -267,7 +277,7 @@ public class BizDataService {
     private volatile List<PersonnelVO> personnelListCache;
     private volatile Map<String, Map<String, Object>> personnelRowByKey;
     private volatile long personnelListCacheExpireAt;
-    private static final long PERSONNEL_LIST_CACHE_TTL_MS = 300_000L;
+    private static final long PERSONNEL_LIST_CACHE_TTL_MS = 900_000L;
     private final AtomicBoolean personnelRefreshing = new AtomicBoolean(false);
 
     /**
@@ -321,6 +331,9 @@ public class BizDataService {
             }
             Map<String, Object> merged = new LinkedHashMap<String, Object>(old);
             merged.putAll(fields);
+            if (!fields.containsKey("update_time")) {
+                merged.put("update_time", Long.valueOf(System.currentTimeMillis()));
+            }
             upsertPersonnelCacheLocked(merged);
         }
     }
@@ -383,12 +396,6 @@ public class BizDataService {
         Map<String, Map<String, Object>> idx = personnelRowByKey == null
                 ? new HashMap<String, Map<String, Object>>()
                 : new HashMap<String, Map<String, Object>>(personnelRowByKey);
-        if (StringUtils.hasText(dmsId)) {
-            idx.put(dmsId, stored);
-        }
-        if (StringUtils.hasText(biz)) {
-            idx.put(biz, stored);
-        }
         List<PersonnelVO> list = new ArrayList<PersonnelVO>(personnelListCache);
         int found = -1;
         for (int i = 0; i < list.size(); i++) {
@@ -401,10 +408,33 @@ public class BizDataService {
             }
         }
         if (found >= 0) {
+            PersonnelVO oldVo = list.get(found);
+            Map<String, Object> prevStored = null;
+            if (oldVo != null && StringUtils.hasText(oldVo.getId())) {
+                prevStored = idx.get(oldVo.getId());
+            }
+            if (prevStored == null && StringUtils.hasText(dmsId)) {
+                prevStored = idx.get(dmsId);
+            }
+            if (prevStored != null) {
+                java.util.Iterator<Map.Entry<String, Map<String, Object>>> it =
+                        idx.entrySet().iterator();
+                while (it.hasNext()) {
+                    if (it.next().getValue() == prevStored) {
+                        it.remove();
+                    }
+                }
+            }
             list.set(found, vo);
         } else {
             list.add(vo);
         }
+        if (StringUtils.hasText(dmsId)) {
+            idx.put(dmsId, stored);
+        }
+        if (StringUtils.hasText(biz)) {
+            idx.put(biz, stored);
+        }
         personnelListCache = list;
         personnelRowByKey = idx;
     }
@@ -435,6 +465,11 @@ public class BizDataService {
                 } catch (Exception e) {
                     System.out.println("[payment-cache] warm failed: " + e.getMessage());
                 }
+                try {
+                    loadAlertListCached();
+                } catch (Exception e) {
+                    System.out.println("[alert-cache] warm failed: " + e.getMessage());
+                }
             }
         }, "warm-dms-caches");
         t.setDaemon(true);
@@ -530,6 +565,7 @@ public class BizDataService {
         List<PersonnelVO> filtered = all.stream()
                 .filter(p -> matchPersonnel(p, req, regionAlias))
                 .collect(Collectors.toList());
+        sortPersonnelForList(filtered);
         PageResult<PersonnelVO> page = pageOf(filtered, pageNum, pageSize);
         // 只回填当前页月标,避免每次列表再扫金额标准全表
         boolean lite = Boolean.TRUE.equals(req.getLite()) || pageSize >= 100;
@@ -1238,6 +1274,7 @@ public class BizDataService {
 
     private final Object batchByIdCacheLock = new Object();
     private volatile Map<String, BatchVO> batchByIdCache;
+    private volatile Map<String, Map<String, Object>> batchRowByKey;
     private volatile long batchByIdCacheExpireAt;
     private static final long BATCH_BY_ID_CACHE_TTL_MS = 60_000L;
     private final AtomicBoolean batchRefreshing = new AtomicBoolean(false);
@@ -1280,6 +1317,7 @@ public class BizDataService {
 
     private void reloadBatchCacheLocked() {
         Map<String, BatchVO> map = new HashMap<String, BatchVO>();
+        Map<String, Map<String, Object>> rawIdx = new HashMap<String, Map<String, Object>>();
         if (dmsOn()) {
             for (Map<String, Object> row : dmsClient.selectContentList(
                     props.getDms().getColumns().getBatch().getColumnId(),
@@ -1296,9 +1334,17 @@ public class BizDataService {
                 if (StringUtils.hasText(bizNo) && !bizNo.equals(vo.getId())) {
                     putPreferBatch(map, bizNo, vo);
                 }
+                String dmsId = DmsRowUtils.str(row, "id");
+                if (StringUtils.hasText(dmsId)) {
+                    rawIdx.put(dmsId, row);
+                }
+                if (StringUtils.hasText(bizNo)) {
+                    rawIdx.put(bizNo, row);
+                }
             }
         }
         batchByIdCache = map;
+        batchRowByKey = rawIdx;
         batchByIdCacheExpireAt = System.currentTimeMillis() + BATCH_BY_ID_CACHE_TTL_MS;
     }
 
@@ -1503,11 +1549,10 @@ public class BizDataService {
         if (!StringUtils.hasText(id) || !dmsOn()) {
             return null;
         }
-        for (Map<String, Object> row : dmsClient.selectContentList(
-                props.getDms().getColumns().getPaymentDetail().getColumnId(),
-                props.getDms().getColumns().getPaymentDetail().getModelId(), 500)) {
-            if (id.equals(DmsRowUtils.str(row, "id"))) {
-                return toPayment(row);
+        String key = id.trim();
+        for (PaymentDetailVO d : loadPaymentListCached()) {
+            if (d != null && key.equals(d.getId())) {
+                return d;
             }
         }
         return null;
@@ -1660,8 +1705,21 @@ public class BizDataService {
                     && !req.getPersonnelId().equals(d.getPersonnelId())) {
                 continue;
             }
+            if (Boolean.TRUE.equals(req.getOnlyFuneral())) {
+                if (d.getFuneralAmount() == null
+                        || d.getFuneralAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) {
+                    continue;
+                }
+            }
+            if (Boolean.TRUE.equals(req.getOnlySupplement())) {
+                if (d.getSupplementAmount() == null
+                        || d.getSupplementAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) {
+                    continue;
+                }
+            }
             filtered.add(d);
         }
+        sortPaymentDetailsForList(filtered, req.getSortBy(), req.getSortOrder());
         // 先分页再 enrichment,避免对全量结果逐条 getBatch 扫 DMS
         PageResult<PaymentDetailVO> page = pageOf(filtered, pageNum, pageSize);
         boolean lightEnrich = StringUtils.hasText(req.getPersonnelId());
@@ -1894,6 +1952,7 @@ public class BizDataService {
     // ---------- 金额标准 ----------
     private final Object standardListLock = new Object();
     private volatile List<AmountStandardVO> standardListCache;
+    private volatile Map<String, Map<String, Object>> standardRowByKey;
     private volatile long standardListCacheExpireAt;
     private static final long STANDARD_LIST_CACHE_TTL_MS = 60_000L;
     private final AtomicBoolean standardRefreshing = new AtomicBoolean(false);
@@ -1938,12 +1997,18 @@ public class BizDataService {
                 props.getDms().getColumns().getAmountStandard().getColumnId(),
                 props.getDms().getColumns().getAmountStandard().getModelId(), 200);
         List<AmountStandardVO> all = new ArrayList<AmountStandardVO>();
+        Map<String, Map<String, Object>> idx = new HashMap<String, Map<String, Object>>();
         if (rows != null) {
             for (Map<String, Object> row : rows) {
                 all.add(toStandard(row));
+                String id = DmsRowUtils.str(row, "id");
+                if (StringUtils.hasText(id)) {
+                    idx.put(id, row);
+                }
             }
         }
         standardListCache = all;
+        standardRowByKey = idx;
         standardListCacheExpireAt = System.currentTimeMillis() + STANDARD_LIST_CACHE_TTL_MS;
     }
 
@@ -2019,7 +2084,7 @@ public class BizDataService {
     private final Object alertListLock = new Object();
     private volatile List<AlertVO> alertListCache;
     private volatile long alertListCacheExpireAt;
-    private static final long ALERT_LIST_CACHE_TTL_MS = 30_000L;
+    private static final long ALERT_LIST_CACHE_TTL_MS = 300_000L;
     private final AtomicBoolean alertRefreshing = new AtomicBoolean(false);
 
     public void invalidateAlertCache() {
@@ -2034,6 +2099,13 @@ public class BizDataService {
         }
     }
 
+    /** 运维清理后立即同步重载,避免仍返回删前缓存。 */
+    public void reloadAlertCacheSync() {
+        synchronized (alertListLock) {
+            reloadAlertCacheLocked();
+        }
+    }
+
     private List<AlertVO> loadAlertListCached() {
         List<AlertVO> hit = alertListCache;
         if (hit != null && alertListCacheExpireAt > System.currentTimeMillis()) {
@@ -2058,12 +2130,112 @@ public class BizDataService {
     }
 
     private void reloadAlertCacheLocked() {
-        List<AlertVO> all = dmsClient.selectContentList(
+        long t0 = System.currentTimeMillis();
+        List<Map<String, Object>> rows = dmsClient.selectContentListParallel(
                 props.getDms().getColumns().getAlert().getColumnId(),
-                props.getDms().getColumns().getAlert().getModelId(), 200)
-                .stream().map(this::toAlert).collect(Collectors.toList());
+                props.getDms().getColumns().getAlert().getModelId(), 100, 4);
+        List<AlertVO> all = new ArrayList<AlertVO>(rows == null ? 0 : rows.size());
+        if (rows != null) {
+            for (Map<String, Object> row : rows) {
+                if (row == null) {
+                    continue;
+                }
+                AlertVO vo = toAlert(row);
+                if (vo != null) {
+                    all.add(vo);
+                }
+            }
+        }
         alertListCache = all;
         alertListCacheExpireAt = System.currentTimeMillis() + ALERT_LIST_CACHE_TTL_MS;
+        System.out.println("[alert-cache] loaded " + all.size() + " in "
+                + (System.currentTimeMillis() - t0) + "ms");
+    }
+
+    /** 从 DMS 直拉全量告警(不走短缓存),供运维去重/清理。 */
+    public List<AlertVO> listAllAlertsFromDms(String townId, String villageId) {
+        if (!dmsOn()) {
+            return new ArrayList<AlertVO>();
+        }
+        List<Map<String, Object>> rows = dmsClient.selectContentListParallel(
+                props.getDms().getColumns().getAlert().getColumnId(),
+                props.getDms().getColumns().getAlert().getModelId(), 100, 4);
+        List<AlertVO> out = new ArrayList<AlertVO>();
+        if (rows == null) {
+            return out;
+        }
+        for (Map<String, Object> row : rows) {
+            if (row == null) {
+                continue;
+            }
+            AlertVO vo = toAlert(row);
+            if (vo == null) {
+                continue;
+            }
+            if (StringUtils.hasText(townId) && !townId.equals(vo.getTownId())) {
+                continue;
+            }
+            if (StringUtils.hasText(villageId) && !villageId.equals(vo.getVillageId())) {
+                continue;
+            }
+            out.add(vo);
+        }
+        return out;
+    }
+
+    /** 工作台/看板:仅取待处置告警,按产生时间倒序截断,避免一次序列化上千条。 */
+    public List<AlertVO> listPendingAlertsRecent(String townId, String villageId, int limit) {
+        int cap = limit <= 0 ? 30 : Math.min(limit, 200);
+        AlertQueryRequest q = new AlertQueryRequest();
+        q.setHandleStatus(AlertHandleStatusEnum.PENDING);
+        q.setTownId(townId);
+        q.setVillageId(villageId);
+        q.setPageNum(1);
+        q.setPageSize(cap * 3);
+        List<AlertVO> hits = pageAlerts(q).getList();
+        if (hits == null || hits.isEmpty()) {
+            return new ArrayList<AlertVO>();
+        }
+        if (hits.size() <= cap) {
+            return hits;
+        }
+        return new ArrayList<AlertVO>(hits.subList(0, cap));
+    }
+
+    /** 列表默认排序:等级高优先,同等级按产生时间新→旧。 */
+    private static void sortAlertsByPriority(List<AlertVO> list) {
+        if (list == null || list.size() < 2) {
+            return;
+        }
+        list.sort(new Comparator<AlertVO>() {
+            @Override
+            public int compare(AlertVO a, AlertVO b) {
+                int ra = alertLevelRank(a);
+                int rb = alertLevelRank(b);
+                if (ra != rb) {
+                    return Integer.compare(ra, rb);
+                }
+                long ta = a == null || a.getCreateTime() == null ? 0L : a.getCreateTime();
+                long tb = b == null || b.getCreateTime() == null ? 0L : b.getCreateTime();
+                return Long.compare(tb, ta);
+            }
+        });
+    }
+
+    private static int alertLevelRank(AlertVO a) {
+        if (a == null || a.getLevel() == null) {
+            return 99;
+        }
+        switch (a.getLevel()) {
+            case HIGH:
+                return 0;
+            case MEDIUM:
+                return 1;
+            case LOW:
+                return 2;
+            default:
+                return 99;
+        }
     }
 
     public PageResult<AlertVO> pageAlerts(AlertQueryRequest req) {
@@ -2098,6 +2270,7 @@ public class BizDataService {
             }
             return true;
         }).collect(Collectors.toList());
+        sortAlertsByPriority(filtered);
         return pageOf(filtered, pageNum, pageSize);
     }
 
@@ -2117,12 +2290,51 @@ public class BizDataService {
         return row == null ? null : toAlert(row);
     }
 
+    /** 从告警缓存移除已删除/已关闭条目(运维清理或处置后调用)。 */
+    public void removeAlertsFromCache(java.util.Collection<String> ids) {
+        if (ids == null || ids.isEmpty()) {
+            return;
+        }
+        java.util.HashSet<String> set = new java.util.HashSet<String>();
+        for (String id : ids) {
+            if (StringUtils.hasText(id)) {
+                set.add(id.trim());
+            }
+        }
+        if (set.isEmpty()) {
+            return;
+        }
+        synchronized (alertListLock) {
+            removeAlertsFromCacheLocked(set);
+        }
+    }
+
+    private void removeAlertsFromCacheLocked(java.util.Set<String> ids) {
+        List<AlertVO> old = alertListCache;
+        if (old == null || old.isEmpty()) {
+            return;
+        }
+        List<AlertVO> list = new ArrayList<AlertVO>(old.size());
+        for (AlertVO a : old) {
+            if (a == null || !ids.contains(a.getId())) {
+                list.add(a);
+            }
+        }
+        if (list.size() != old.size()) {
+            alertListCache = list;
+        }
+    }
+
     /** 处置后立刻替换缓存中的该条,避免 Stale-While-Revalidate 仍返回「待处理」。 */
     public void replaceAlertInCache(AlertVO vo) {
         if (vo == null || !StringUtils.hasText(vo.getId())) {
             return;
         }
         synchronized (alertListLock) {
+            if (vo.getHandleStatus() == AlertHandleStatusEnum.CLOSED) {
+                removeAlertsFromCacheLocked(java.util.Collections.singleton(vo.getId()));
+                return;
+            }
             List<AlertVO> old = alertListCache;
             List<AlertVO> list = old == null
                     ? new ArrayList<AlertVO>()
@@ -2310,6 +2522,7 @@ public class BizDataService {
     /** 流程表全量短缓存:timeline / overview 反复扫 DMS 很慢 */
     private final Object processListLock = new Object();
     private volatile List<ProcessLogVO> processCache;
+    private volatile Map<String, Map<String, Object>> processRowByKey;
     private volatile long processCacheExpireAt;
     private static final long PROCESS_CACHE_TTL_MS = 45_000L;
     private final AtomicBoolean processRefreshing = new AtomicBoolean(false);
@@ -2351,10 +2564,23 @@ public class BizDataService {
     }
 
     private void reloadProcessCacheLocked() {
-        List<ProcessLogVO> all = dmsClient.selectContentList(
-                        props.getDms().getColumns().getProcessLog().getColumnId(),
-                        props.getDms().getColumns().getProcessLog().getModelId(), 100)
-                .stream().map(this::toProcess).collect(Collectors.toList());
+        List<Map<String, Object>> rows = dmsClient.selectContentList(
+                props.getDms().getColumns().getProcessLog().getColumnId(),
+                props.getDms().getColumns().getProcessLog().getModelId(), 100);
+        List<ProcessLogVO> all = new ArrayList<ProcessLogVO>();
+        Map<String, Map<String, Object>> idx = new HashMap<String, Map<String, Object>>();
+        if (rows != null) {
+            for (Map<String, Object> row : rows) {
+                if (row == null) {
+                    continue;
+                }
+                all.add(toProcess(row));
+                String id = DmsRowUtils.str(row, "id");
+                if (StringUtils.hasText(id)) {
+                    idx.put(id, row);
+                }
+            }
+        }
         Collections.sort(all, new Comparator<ProcessLogVO>() {
             @Override
             public int compare(ProcessLogVO a, ProcessLogVO b) {
@@ -2364,6 +2590,7 @@ public class BizDataService {
             }
         });
         processCache = all;
+        processRowByKey = idx;
         processCacheExpireAt = System.currentTimeMillis() + PROCESS_CACHE_TTL_MS;
     }
 
@@ -2564,4 +2791,433 @@ public class BizDataService {
         int to = Math.min(from + pageSize, all.size());
         return PageResult.of(pageNum, pageSize, all.size(), all.subList(from, to));
     }
+
+    /** 列表默认:待提交/新增优先,同组内按创建时间或 ID 倒序(新录入排前) */
+    private void sortPersonnelForList(List<PersonnelVO> list) {
+        if (list == null || list.size() < 2) {
+            return;
+        }
+        list.sort((a, b) -> {
+            int pa = personnelListPriority(a);
+            int pb = personnelListPriority(b);
+            if (pa != pb) {
+                return Integer.compare(pa, pb);
+            }
+            Long ta = a.getCreateTime();
+            Long tb = b.getCreateTime();
+            if (ta != null && tb != null && !ta.equals(tb)) {
+                return Long.compare(tb, ta);
+            }
+            String ia = a.getId() == null ? "" : a.getId();
+            String ib = b.getId() == null ? "" : b.getId();
+            return ib.compareTo(ia);
+        });
+    }
+
+    private int personnelListPriority(PersonnelVO p) {
+        if (p == null || p.getBizStatus() == null) {
+            return 50;
+        }
+        switch (p.getBizStatus()) {
+            case DRAFT:
+                return 0;
+            case ABNORMAL:
+                return 1;
+            case PENDING_TOWN_APPROVE:
+                return 2;
+            case NEW:
+                return 3;
+            default:
+                return 10;
+        }
+    }
+
+    /** 审批/批次明细:新增/减少/异常状态优先展示 */
+    private void sortPaymentDetailsForList(List<PaymentDetailVO> list) {
+        sortPaymentDetailsForList(list, null, null);
+    }
+
+    private void sortPaymentDetailsForList(List<PaymentDetailVO> list, String sortBy, String sortOrder) {
+        if (list == null || list.size() < 2) {
+            return;
+        }
+        final boolean amountSort = StringUtils.hasText(sortBy)
+                && ("totalAmount".equals(sortBy)
+                || "funeralAmount".equals(sortBy)
+                || "supplementAmount".equals(sortBy)
+                || "monthlyAmount".equals(sortBy));
+        final boolean asc = "asc".equalsIgnoreCase(sortOrder);
+        list.sort((a, b) -> {
+            if (amountSort) {
+                java.math.BigDecimal va = paymentSortAmount(a, sortBy);
+                java.math.BigDecimal vb = paymentSortAmount(b, sortBy);
+                int cmp = va.compareTo(vb);
+                return asc ? cmp : -cmp;
+            }
+            int pa = paymentDetailListPriority(a);
+            int pb = paymentDetailListPriority(b);
+            if (pa != pb) {
+                return Integer.compare(pa, pb);
+            }
+            String na = a.getName() == null ? "" : a.getName();
+            String nb = b.getName() == null ? "" : b.getName();
+            return na.compareTo(nb);
+        });
+    }
+
+    private java.math.BigDecimal paymentSortAmount(PaymentDetailVO d, String sortBy) {
+        if (d == null) {
+            return java.math.BigDecimal.ZERO;
+        }
+        java.math.BigDecimal v = null;
+        if ("funeralAmount".equals(sortBy)) {
+            v = d.getFuneralAmount();
+        } else if ("supplementAmount".equals(sortBy)) {
+            v = d.getSupplementAmount();
+        } else if ("monthlyAmount".equals(sortBy)) {
+            v = d.getMonthlyAmount();
+        } else {
+            v = d.getTotalAmount();
+        }
+        return v == null ? java.math.BigDecimal.ZERO : v;
+    }
+
+    private int paymentDetailListPriority(PaymentDetailVO d) {
+        if (d == null) {
+            return 50;
+        }
+        String flag = d.getChangeFlag();
+        if ("NEW".equals(flag)) {
+            return 0;
+        }
+        if ("REMOVE".equals(flag)) {
+            return 1;
+        }
+        String remarks = d.getRemarks() == null ? "" : d.getRemarks();
+        if (remarks.contains("暂停") || remarks.contains("停止") || remarks.contains("异常")
+                || remarks.contains("死亡")) {
+            return 2;
+        }
+        return 10;
+    }
+
+    // ---------- 写路径/校验:禁止再全表扫 DMS,统一走内存缓存 ----------
+
+    /** 按区划 code 或 DMS id 取原始行(删改区划用)。 */
+    public Map<String, Object> findRegionRaw(String codeOrId) {
+        if (!StringUtils.hasText(codeOrId)) {
+            return null;
+        }
+        loadRegionListCached();
+        Map<String, Map<String, Object>> idx = regionRowByKey;
+        return idx == null ? null : idx.get(codeOrId.trim());
+    }
+
+    /** 是否存在下级区划。 */
+    public boolean hasRegionChild(String parentCode) {
+        if (!StringUtils.hasText(parentCode)) {
+            return false;
+        }
+        String pid = parentCode.trim();
+        for (RegionVO r : loadRegionListCached()) {
+            if (r != null && pid.equals(r.getParentId())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 流程表原始行(按 DMS id)。 */
+    public Map<String, Object> findProcessRaw(String id) {
+        if (!StringUtils.hasText(id)) {
+            return null;
+        }
+        loadAllProcessCached();
+        Map<String, Map<String, Object>> idx = processRowByKey;
+        return idx == null ? null : idx.get(id.trim());
+    }
+
+    /** 特殊业务单原始行(流程表 action=SPECIAL_BIZ)。 */
+    public List<Map<String, Object>> listSpecialBizProcessRows(
+            String bizType, String approveStatus, String townId, String personnelId) {
+        loadAllProcessCached();
+        Map<String, Map<String, Object>> idx = processRowByKey;
+        if (idx == null || idx.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String id = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(id) || !seen.add(id)) {
+                continue;
+            }
+            if (!"SPECIAL_BIZ".equals(DmsRowUtils.str(row, "c_action"))) {
+                continue;
+            }
+            if (StringUtils.hasText(bizType) && !bizType.equals(DmsRowUtils.str(row, "c_biz_type"))) {
+                continue;
+            }
+            if (StringUtils.hasText(approveStatus)
+                    && !approveStatus.equals(DmsRowUtils.str(row, "c_status"))) {
+                continue;
+            }
+            if (StringUtils.hasText(townId) && !townId.equals(DmsRowUtils.str(row, "c_town_id"))) {
+                continue;
+            }
+            if (StringUtils.hasText(personnelId)
+                    && !personnelId.equals(DmsRowUtils.str(row, "c_personnel_id"))) {
+                continue;
+            }
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 发放明细:按人员 ID 集合筛(删人摘明细等写路径用)。 */
+    public List<PaymentDetailVO> listPaymentsByPersonnelIds(java.util.Collection<String> personnelIds) {
+        if (personnelIds == null || personnelIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        java.util.Set<String> ids = new java.util.HashSet<String>();
+        for (String id : personnelIds) {
+            if (StringUtils.hasText(id)) {
+                ids.add(id.trim());
+            }
+        }
+        if (ids.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<PaymentDetailVO> out = new ArrayList<PaymentDetailVO>();
+        for (PaymentDetailVO d : loadPaymentListCached()) {
+            if (d == null || isRemovedPayment(d)) {
+                continue;
+            }
+            if (d.getPersonnelId() != null && ids.contains(d.getPersonnelId())) {
+                out.add(d);
+            }
+        }
+        return out;
+    }
+
+    /** 发放明细:按批次+人员筛(丧葬清零等写路径用)。 */
+    public List<PaymentDetailVO> listPaymentsByBatchAndPersonnel(String batchId, String personnelId) {
+        if (!StringUtils.hasText(batchId) || !StringUtils.hasText(personnelId)) {
+            return Collections.emptyList();
+        }
+        List<PaymentDetailVO> out = new ArrayList<PaymentDetailVO>();
+        for (PaymentDetailVO d : loadPaymentListCached()) {
+            if (d == null || isRemovedPayment(d)) {
+                continue;
+            }
+            if (batchId.equals(d.getBatchId()) && personnelId.equals(d.getPersonnelId())) {
+                out.add(d);
+            }
+        }
+        return out;
+    }
+
+    /** 回盘统计:按批次 ID 查缓存。 */
+    public BatchStatVO findStatByBatchId(String batchId) {
+        if (!StringUtils.hasText(batchId)) {
+            return null;
+        }
+        String bid = batchId.trim();
+        for (BatchStatVO s : loadStatListCached()) {
+            if (s == null) {
+                continue;
+            }
+            if (bid.equals(s.getBatchId())) {
+                return s;
+            }
+            String remarks = s.getRemarks();
+            if (remarks != null && remarks.contains("RETURN_CONFIRM|" + bid)) {
+                return s;
+            }
+        }
+        return null;
+    }
+
+    /** 金额标准原始行。 */
+    public Map<String, Object> findStandardRaw(String id) {
+        if (!StringUtils.hasText(id)) {
+            return null;
+        }
+        loadStandardListCached();
+        Map<String, Map<String, Object>> idx = standardRowByKey;
+        return idx == null ? null : idx.get(id.trim());
+    }
+
+    /** 同镇金额标准原始行(保存/失效标准时用,不走 DMS 全表)。 */
+    public List<Map<String, Object>> listStandardRawByTown(String townId) {
+        loadStandardListCached();
+        Map<String, Map<String, Object>> idx = standardRowByKey;
+        if (idx == null || idx.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String id = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(id) || !seen.add(id)) {
+                continue;
+            }
+            if (StringUtils.hasText(townId) && !townId.equals(DmsRowUtils.str(row, "c_town_id"))) {
+                continue;
+            }
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 流程表原始行:按 action / bizType 筛(欠费、系统配置、导入历史等)。 */
+    public List<Map<String, Object>> listProcessRawRows(String action, String bizType) {
+        loadAllProcessCached();
+        Map<String, Map<String, Object>> idx = processRowByKey;
+        if (idx == null || idx.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String id = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(id) || !seen.add(id)) {
+                continue;
+            }
+            if (StringUtils.hasText(action) && !action.equals(DmsRowUtils.str(row, "c_action"))) {
+                continue;
+            }
+            if (StringUtils.hasText(bizType)) {
+                String bt = DmsRowUtils.str(row, "c_biz_type");
+                String act = DmsRowUtils.str(row, "c_action");
+                if (!bizType.equals(bt) && !bizType.equals(act)) {
+                    continue;
+                }
+            }
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 人员库全部原始行(走内存缓存,供批量运维/导入索引用)。 */
+    public List<Map<String, Object>> listAllPersonnelRawRows() {
+        loadPersonnelListCached();
+        Map<String, Map<String, Object>> idx = personnelRowByKey;
+        if (idx == null || idx.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String dmsId = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(dmsId) || !seen.add(dmsId)) {
+                continue;
+            }
+            out.add(row);
+        }
+        return out;
+    }
+
+    /** 导入/校验:身份证号 → 人员原始行(内存索引,不扫 DMS)。 */
+    public Map<String, Map<String, Object>> mapPersonnelByIdNumber() {
+        loadPersonnelListCached();
+        Map<String, Map<String, Object>> out = new HashMap<String, Map<String, Object>>();
+        Map<String, Map<String, Object>> idx = personnelRowByKey;
+        if (idx == null) {
+            return out;
+        }
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String dmsId = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(dmsId) || !seen.add(dmsId)) {
+                continue;
+            }
+            String idn = DmsRowUtils.str(row, "c_id_number");
+            if (StringUtils.hasText(idn)) {
+                out.put(idn.trim().toUpperCase(), row);
+            }
+        }
+        return out;
+    }
+
+    /** 当前人员库最大 P 序号(新增/导入取号用)。 */
+    public int maxPersonnelBizSeq() {
+        int max = 100;
+        for (PersonnelVO p : loadPersonnelListCached()) {
+            if (p == null || !StringUtils.hasText(p.getId())) {
+                continue;
+            }
+            String id = p.getId().trim();
+            if (id.matches("P\\d+")) {
+                try {
+                    max = Math.max(max, Integer.parseInt(id.substring(1)));
+                } catch (Exception ignore) {
+                }
+            }
+        }
+        return max;
+    }
+
+    /** 同业务号全部批次原始行(purge/查找用,走批次缓存)。 */
+    public List<Map<String, Object>> findAllBatchRawRows(String bizNo) {
+        if (!StringUtils.hasText(bizNo)) {
+            return Collections.emptyList();
+        }
+        loadBatchByIdCached();
+        Map<String, Map<String, Object>> idx = batchRowByKey;
+        if (idx == null || idx.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String key = bizNo.trim();
+        List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (Map<String, Object> row : idx.values()) {
+            if (row == null) {
+                continue;
+            }
+            String id = DmsRowUtils.str(row, "id");
+            if (!StringUtils.hasText(id) || !seen.add(id)) {
+                continue;
+            }
+            String no = DmsRowUtils.str(row, "c_batch_number");
+            if (key.equals(id) || key.equals(no)) {
+                out.add(row);
+            }
+        }
+        return out;
+    }
+
+    /** 同业务号全部批次(purge 用,走批次缓存)。 */
+    public List<BatchVO> findBatchesByBizNo(String bizNo) {
+        if (!StringUtils.hasText(bizNo)) {
+            return Collections.emptyList();
+        }
+        String key = bizNo.trim();
+        List<BatchVO> out = new ArrayList<BatchVO>();
+        java.util.Set<String> seen = new java.util.HashSet<String>();
+        for (BatchVO b : loadBatchByIdCached().values()) {
+            if (b == null || !StringUtils.hasText(b.getId()) || !seen.add(b.getId())) {
+                continue;
+            }
+            if (key.equals(b.getId()) || key.equals(b.getBatchNo())) {
+                out.add(b);
+            }
+        }
+        return out;
+    }
 }

Різницю між файлами не показано, бо вона завелика
+ 350 - 243
src/main/java/com/yykj/sjnmtybt/service/BizFlowService.java


+ 11 - 23
src/main/java/com/yykj/sjnmtybt/service/PersonnelImportExportService.java

@@ -381,28 +381,10 @@ public class PersonnelImportExportService {
 
     /** 一次拉取现有人员,按身份证号索引,避免每行全表查询。 */
     private Map<String, Map<String, Object>> loadExistingByIdNumber(int[] maxBizHolder) {
-        Map<String, Map<String, Object>> m = new HashMap<String, Map<String, Object>>();
-        if (!dms.isEnabled()) return m;
-        SjnmtybtProperties.Ref ref = props.getDms().getColumns().getPersonnel();
-        List<Map<String, Object>> rows = dms.selectContentListParallel(
-                ref.getColumnId(), ref.getModelId(), 100, 4);
-        if (rows == null) return m;
-        int max = maxBizHolder == null || maxBizHolder.length == 0 ? 100 : maxBizHolder[0];
-        for (Map<String, Object> row : rows) {
-            if (row == null) continue;
-            String biz = DmsRowUtils.personnelBizId(row);
-            if (biz != null && biz.matches("P\\d+")) {
-                try {
-                    max = Math.max(max, Integer.parseInt(biz.substring(1)));
-                } catch (Exception ignore) {
-                }
-            }
-            String idn = DmsRowUtils.str(row, "c_id_number");
-            if (!StringUtils.hasText(idn)) continue;
-            String key = idn.trim().toUpperCase();
-            if (!m.containsKey(key)) m.put(key, row);
+        Map<String, Map<String, Object>> m = data.mapPersonnelByIdNumber();
+        if (maxBizHolder != null && maxBizHolder.length > 0) {
+            maxBizHolder[0] = data.maxPersonnelBizSeq();
         }
-        if (maxBizHolder != null && maxBizHolder.length > 0) maxBizHolder[0] = max;
         return m;
     }
 
@@ -596,9 +578,8 @@ public class PersonnelImportExportService {
         if (!dms.isEnabled()) {
             return Collections.emptyList();
         }
-        SjnmtybtProperties.Ref ref = props.getDms().getColumns().getProcessLog();
         List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
-        for (Map<String, Object> row : dms.selectContentList(ref.getColumnId(), ref.getModelId(), 100)) {
+        for (Map<String, Object> row : data.listProcessRawRows(null, null)) {
             String bizType = DmsRowUtils.str(row, "c_biz_type");
             String action = DmsRowUtils.str(row, "c_action");
             String content = DmsRowUtils.str(row, "content");
@@ -923,6 +904,13 @@ public class PersonnelImportExportService {
         req.setRemarks(emptyToNull(remarks));
         req.setSource("Excel导入");
         req.setOperatorRoleKey("admin");
+        // 导入更新:带上当前 update_time 以满足乐观锁(与打开详情编辑一致)
+        if (existRow != null) {
+            Long ut = DmsRowUtils.longVal(existRow, "update_time");
+            if (ut != null) {
+                req.setExpectedUpdateTime(ut);
+            }
+        }
         // 导入只落底数,不写「新增/已通过」等流程态,避免绕过村「提交资格」
         if (existId == null) {
             req.setBizStatus(PersonnelStatusEnum.DRAFT);

Деякі файли не було показано, через те що забагато файлів було змінено