From 7100c8a4f7dd3fc7d53ffead4475d7a8abc51c12 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 09 九月 2026 02:32:44 +0800
Subject: [PATCH] 完善循环拼接与首页待办真实数据接口

---
 ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java        |   33 ++++
 battery-business/src/main/java/com/battery/business/report/CompareReportService.java       |   10 +
 ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java |    2 
 battery-business/src/main/java/com/battery/business/service/TodoService.java               |   69 +++++++-
 ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java                     |   25 ++
 ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java         |   46 +++++
 battery-business/src/main/java/com/battery/business/service/DashboardService.java          |   91 +++++++++++
 ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java                |   39 ++++
 ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java                |   19 ++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java       |    2 
 ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql  |   35 ++++
 ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java           |   57 +++++++
 battery-business/src/main/java/com/battery/business/report/SpliceReportService.java        |   47 +++--
 13 files changed, 438 insertions(+), 37 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/CompareReportService.java b/battery-business/src/main/java/com/battery/business/report/CompareReportService.java
index d14ac6e..f3cc618 100644
--- a/battery-business/src/main/java/com/battery/business/report/CompareReportService.java
+++ b/battery-business/src/main/java/com/battery/business/report/CompareReportService.java
@@ -49,7 +49,13 @@
         {
             TTest test = tests.selectTestById(summary.getTestId());
             if (!"COMPLETED".equals(test.getTestStatus())) continue;
-            result.addAll(candidates(test, data.records(test.getTestId())));
+            var available = candidates(test, data.records(test.getTestId()));
+            Set<String> mapped = new HashSet<>();
+            for (var item : available) if (item.selection().logicalNo() != null)
+                mapped.add(item.selection().testBatteryId() + "|" + item.selection().cycleNo());
+            // 与控制台一致:有保存的逻辑工步时,新选入口展示合并结果;旧快照仍保留原选择。
+            result.addAll(available.stream().filter(item -> item.selection().logicalNo() != null
+                    || !mapped.contains(item.selection().testBatteryId() + "|" + item.selection().cycleNo())).toList());
         }
         return result;
     }
@@ -57,7 +63,7 @@
     /** 预览所选当前有效记录;相同电池的不同循环或工步是独立比对项。 */
     public List<Item> preview(List<Selection> selections)
     {
-        if (selections == null || selections.isEmpty() || selections.contains(null)
+        if (selections == null || selections.isEmpty() || selections.stream().anyMatch(Objects::isNull)
                 || new HashSet<>(selections).size() != selections.size())
             throw new ServiceException("请选择不重复的比对项");
         Map<Long, TTest> loadedTests = new HashMap<>();
diff --git a/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java b/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
index 841f84d..1506ecc 100644
--- a/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
+++ b/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
@@ -9,6 +9,7 @@
 import com.battery.business.domain.TTest;
 import com.battery.business.domain.TTestBattery;
 import com.battery.business.service.ITTestService;
+import com.battery.business.service.ITBatteryGroupService;
 import com.battery.business.service.ITestDataQueryService;
 import com.ruoyi.common.exception.ServiceException;
 import org.springframework.stereotype.Service;
@@ -21,8 +22,9 @@
  private final ITestDataQueryService data;
  private final SpliceReportRepository reports;
  private final SpliceFieldTemplateService templates;
- public SpliceReportService(ITTestService tests, ITestDataQueryService data, SpliceReportRepository reports, SpliceFieldTemplateService templates) {
-  this.tests = tests; this.data = data; this.reports = reports; this.templates = templates;
+ private final ITBatteryGroupService groups;
+ public SpliceReportService(ITTestService tests, ITestDataQueryService data, SpliceReportRepository reports, SpliceFieldTemplateService templates, ITBatteryGroupService groups) {
+  this.tests = tests; this.data = data; this.reports = reports; this.templates = templates; this.groups = groups;
  }
  public record Candidate(SpliceReportRequest.Selection selection,String name,String type,long groupId,String group,String batteryNo,long startedAt){}
  public record Matrix(List<Map<String,Object>> segments,List<Integer> cycleNos){}
@@ -39,7 +41,7 @@
  }
  /** 校验每个测试电池来源并按提交顺序逐段接续真实循环。 */
  public Matrix preview(List<SpliceReportRequest.Selection> selections) {
-  if (selections == null || selections.size() < 2 || selections.contains(null)
+  if (selections == null || selections.size() < 2 || selections.stream().anyMatch(Objects::isNull)
       || new HashSet<>(selections).size() != selections.size())
    throw new ServiceException("请选择至少两个不重复的检测完成测试电池");
   Map<Long, TTest> loaded = new LinkedHashMap<>();
@@ -54,14 +56,14 @@
   }
   List<Map<String, Object>> segments = new ArrayList<>();
   Set<Integer> mappedCycles = new TreeSet<>();
-  Integer previousEnd = null;
+  int position = 0;
   for (var entry : selected.entrySet()) {
    long testId = entry.getKey().testId();
-   List<Map<String, Object>> cycles = data.cycles(testId), steps = data.steps(testId), records = data.records(testId);
+   List<Map<String, Object>> cycles = data.cycles(testId), steps = data.steps(testId);
    TTestBattery battery = entry.getValue();
     var rows = batteryRows(cycles, battery);
     var stepRows = batteryRows(steps, battery);
-    var nos = java.util.stream.Stream.of(rows, stepRows, batteryRows(records, battery)).flatMap(List::stream)
+    var nos = java.util.stream.Stream.of(rows, stepRows).flatMap(List::stream)
         .map(this::cycle).filter(no -> no > 0).distinct().sorted().toList();
     Map<String, Object> segment = new LinkedHashMap<>();
     segment.put("source", candidate(loaded.get(testId), battery));
@@ -79,12 +81,14 @@
      });
      frozen.put("fieldValues", values); segment.put("consoleDetail", frozen);
     }
-    segment.put("originalCycleNos", nos); segment.put("statistics", statistics(rows, stepRows, nos));
-   // 每个测试电池独立计算偏移;空段不占用循环,各指标共用同一定位。
-   int offset = previousEnd == null || nos.isEmpty() ? 0 : previousEnd + 1 - nos.get(0);
-   segment.put("offset", offset);
-   for (int no : nos) mappedCycles.add(no + offset);
-   if (!nos.isEmpty()) previousEnd = nos.get(nos.size() - 1) + offset;
+    BigDecimal factor = capacityFactor(battery);
+    segment.put("capacityFactor", factor);
+    segment.put("originalCycleNos", nos); segment.put("statistics", statistics(rows, stepRows, nos, factor));
+   // 累计位置只计实际循环点;原循环号单独冻结,不能用数值偏移保留缺号空位。
+   Map<String,Integer> positions = new LinkedHashMap<>();
+   segment.put("offset", position);
+   for (int no : nos) { positions.put(Integer.toString(no), ++position); mappedCycles.add(position); }
+   segment.put("cyclePositions", positions);
    segments.add(segment);
   }
   return new Matrix(segments, new ArrayList<>(mappedCycles));
@@ -92,7 +96,13 @@
  private List<Map<String, Object>> batteryRows(List<Map<String, Object>> rows, TTestBattery battery) {
   return rows.stream().filter(row -> Objects.equals(row.get("batteryNo"), battery.getBatteryNo())).toList();
  }
- private Map<String,Object> statistics(List<Map<String,Object>> rows, List<Map<String,Object>> stepRows, List<Integer> nos) {
+ private BigDecimal capacityFactor(TTestBattery battery) {
+  var group = groups.selectBatteryGroupById(battery.getGroupId());
+  if (group == null || group.getF() == null) return null;
+  try { var value = new BigDecimal(group.getF().trim()); return value.signum() == 0 ? null : value; }
+  catch (NumberFormatException error) { return null; }
+ }
+ private Map<String,Object> statistics(List<Map<String,Object>> rows, List<Map<String,Object>> stepRows, List<Integer> nos, BigDecimal factor) {
   Map<String,Object> result = new LinkedHashMap<>();
   for (int no : nos) {
    Map<String,Object> values = new LinkedHashMap<>();
@@ -105,9 +115,14 @@
     if (charge != null && discharge != null && charge.doubleValue() > 0)
      values.put("energyEfficiency", new BigDecimal(discharge.toString()).multiply(BigDecimal.valueOf(100))
          .divide(new BigDecimal(charge.toString()), MathContext.DECIMAL128));
+    // 与项目图表一致:Ah 转 mAh 后除以来源电池组 F,缺失或零分母不补值。
+    Number capacity = num(values.get("dischargeCapacity"));
+    if (capacity != null && factor != null)
+     values.put("dischargeCapacityF", new BigDecimal(capacity.toString()).multiply(BigDecimal.valueOf(1000)).divide(factor, MathContext.DECIMAL128));
    }
    // 查询接口按实际发生顺序返回工步;先选末记录再取值,空值不得向前回退。
-   var actualSteps = stepRows.stream().filter(row -> cycle(row) == no).toList();
+   var actualSteps = stepRows.stream().filter(row -> cycle(row) == no)
+       .sorted(Comparator.comparingInt(row -> row.get("stepNo") instanceof Number value ? value.intValue() : 0)).toList();
    if (!actualSteps.isEmpty()) values.put("finalVoltage1", actualSteps.get(actualSteps.size() - 1).get("endVoltage"));
    result.put(Integer.toString(no), values);
   }
@@ -124,7 +139,7 @@
   validateStatistics(settings, matrix.segments());
   validateSourceDetails(settings, matrix.segments());
   Map<String,Object> snapshot = new LinkedHashMap<>();
-  snapshot.put("spliceMode", "battery-segments");
+  snapshot.put("spliceMode", "project-cycles");
   snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
   snapshot.put("selections", request.selections()); snapshot.put("segments", matrix.segments());
   snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", settings);
@@ -179,7 +194,7 @@
   overrides.keySet().retainAll(statisticKeys(matrix.segments()));
   settings.put("statisticValues", overrides);
   snapshot.put("settings", settings); snapshot.put("segments", matrix.segments()); snapshot.put("cycleNos", matrix.cycleNos());
-  snapshot.put("spliceMode", "battery-segments");
+  snapshot.put("spliceMode", "project-cycles");
   reports.append(id, base + 1, snapshot.getString("name"), snapshot.toJSONString(), uid, user, "按最新来源生成:" + summary.trim());
   return get(id, null);
  }
diff --git a/battery-business/src/main/java/com/battery/business/service/DashboardService.java b/battery-business/src/main/java/com/battery/business/service/DashboardService.java
new file mode 100644
index 0000000..d8e0f29
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/service/DashboardService.java
@@ -0,0 +1,91 @@
+package com.battery.business.service;
+
+import java.math.BigDecimal;
+import java.time.*;
+import java.util.*;
+import javax.sql.DataSource;
+import com.battery.business.domain.*;
+import com.battery.business.mapper.TTestMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+/** 首页只读聚合已有事实,不发起设备控制或为展示新增采样。 */
+@Service
+public class DashboardService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Hong_Kong");
+    private final ITTestService tests;
+    private final ITDetectionCabinetService cabinets;
+    private final TTestMapper testMapper;
+    private final JdbcTemplate jdbc;
+    public DashboardService(ITTestService tests, ITDetectionCabinetService cabinets, TTestMapper testMapper, DataSource source) {
+        this.tests = tests; this.cabinets = cabinets; this.testMapper = testMapper; jdbc = new JdbcTemplate(source);
+    }
+    public record Summary(long testCount, long batteryGroupCount, long todayCount, long monthCount) {}
+    public record Trend(List<String> labels, List<Long> values) {}
+    public record TestRow(long id, String name, String type, String status, String receptionStatus, String step, long warning) {}
+    public record CabinetRow(long id, String name, BigDecimal capacity, int channelCount, long usedChannels, long testCount, long batteryCount, String onlineStatus) {}
+    public record Warning(long id, long testId, String testName, String item, long occurredAt) {}
+    public record Snapshot(Summary summary, Map<String, Trend> trends, List<TestRow> tests, List<CabinetRow> cabinets, List<Warning> warnings) {}
+
+    /** 使用全部未删除测试计算总量及香港自然日/月新增数,统计不受页面分页限制。 */
+    public Snapshot read(Instant now) {
+        var allTests = tests.selectTestList(new TTest());
+        var today = now.atZone(BUSINESS_ZONE).toLocalDate();
+        Map<LocalDate, Long> dates = new HashMap<>();
+        allTests.stream().filter(test -> test.getCreateTime() != null).forEach(test ->
+                dates.merge(test.getCreateTime().toInstant().atZone(BUSINESS_ZONE).toLocalDate(), 1L, Long::sum));
+        var summary = new Summary(allTests.size(), jdbc.queryForObject("select count(*) from t_battery_group where del_flag=0", Long.class),
+                dates.getOrDefault(today, 0L), dates.entrySet().stream().filter(entry -> YearMonth.from(entry.getKey()).equals(YearMonth.from(today))).mapToLong(Map.Entry::getValue).sum());
+        var trends = new LinkedHashMap<String, Trend>();
+        trends.put("week", days(today.minusDays(6), today, dates));
+        trends.put("month", days(today.withDayOfMonth(1), today, dates));
+        List<String> months = new ArrayList<>(); List<Long> counts = new ArrayList<>();
+        for (int month = 1; month <= today.getMonthValue(); month++) {
+            final var key = YearMonth.of(today.getYear(), month); months.add(month + "月");
+            counts.add(dates.entrySet().stream().filter(entry -> YearMonth.from(entry.getKey()).equals(key)).mapToLong(Map.Entry::getValue).sum());
+        }
+        trends.put("year", new Trend(months, counts));
+        var open = testMapper.selectWarningExceptions(null, "OPEN");
+        Map<Long, Long> warningCounts = new HashMap<>();
+        List<Warning> warnings = open.stream().map(item -> {
+            warningCounts.merge(item.getTestId(), 1L, Long::sum);
+            return new Warning(item.getExceptionId(), item.getTestId(), item.getTestName(),
+                    "DEVICE_OFFLINE".equals(item.getExceptionType()) ? "设备离线" : metricName(item.getMetricCode()),
+                    item.getLastOccurredAtUtc() == null ? 0 : item.getLastOccurredAtUtc().getTime());
+        }).sorted(Comparator.comparingLong(Warning::occurredAt).reversed()).toList();
+        // 首页明确展示“最近记录”,不把持久化历史冒充设备当前工步;多电池分别保留记录身份。
+        Map<Long, List<String>> latest = new HashMap<>();
+        jdbc.query("select r.test_id,b.battery_no,r.step_no,r.cycle_no from t_test_realtime_record r join t_test_battery b on b.test_battery_id=r.test_battery_id and b.del_flag=0 join (select test_battery_id,max(record_id) record_id from t_test_realtime_record group by test_battery_id) last_record on last_record.record_id=r.record_id",
+                (org.springframework.jdbc.core.RowCallbackHandler) rs -> {
+                    if (rs.getObject(3) != null && rs.getObject(4) != null)
+                        latest.computeIfAbsent(rs.getLong(1), id -> new ArrayList<>()).add(rs.getString(2) + ":工步" + rs.getInt(3) + " / 循环" + rs.getInt(4));
+                });
+        var rows = allTests.stream().map(test -> new TestRow(test.getTestId(), test.getTestName(), test.getTestTypeName(), test.getTestStatus(),
+                test.getReceptionStatus(), String.join(";", latest.getOrDefault(test.getTestId(), List.of())), warningCounts.getOrDefault(test.getTestId(), 0L))).toList();
+        Map<Long, long[]> usage = new HashMap<>();
+        // 端口、电池和关联测试按未完成测试去重统计;在线事实仍由设备服务独立提供。
+        jdbc.query("select c.cabinet_id,count(distinct binding.channel_id),count(distinct t.test_id),count(distinct b.test_battery_id) from t_test_channel_binding binding join t_detection_channel c on c.channel_id=binding.channel_id and c.del_flag=0 join t_test t on t.test_id=binding.test_id and t.del_flag=0 and t.test_status<>'COMPLETED' join t_test_battery b on b.test_battery_id=binding.test_battery_id and b.del_flag=0 group by c.cabinet_id",
+                (org.springframework.jdbc.core.RowCallbackHandler) rs -> usage.put(rs.getLong(1), new long[] { rs.getLong(2), rs.getLong(3), rs.getLong(4) }));
+        var cabinetRows = cabinets.selectDetectionCabinetList(new TDetectionCabinet()).stream().map(cabinet -> {
+            var values = usage.getOrDefault(cabinet.getCabinetId(), new long[3]);
+            return new CabinetRow(cabinet.getCabinetId(), cabinet.getCabinetName(), cabinet.getCapacity(),
+                    cabinet.getChannelCount() == null ? 0 : cabinet.getChannelCount(), values[0], values[1], values[2],
+                    "0".equals(cabinet.getOnlineStatus()) ? "在线" : "离线");
+        }).toList();
+        return new Snapshot(summary, trends, rows, cabinetRows, warnings);
+    }
+
+    private Trend days(LocalDate begin, LocalDate end, Map<LocalDate, Long> dates) {
+        List<String> labels = new ArrayList<>(); List<Long> values = new ArrayList<>();
+        for (LocalDate day = begin; !day.isAfter(end); day = day.plusDays(1)) {
+            labels.add(day.getMonthValue() + "/" + day.getDayOfMonth()); values.add(dates.getOrDefault(day, 0L));
+        }
+        return new Trend(labels, values);
+    }
+
+    private String metricName(String code) {
+        if (code == null) return "阈值异常";
+        return Map.of("VOLTAGE", "电压", "CURRENT", "电流", "TEMPERATURE", "温度",
+                "POSITIVE_POTENTIAL", "正电位", "NEGATIVE_POTENTIAL", "负电位").getOrDefault(code, code);
+    }
+}
diff --git a/battery-business/src/main/java/com/battery/business/service/TodoService.java b/battery-business/src/main/java/com/battery/business/service/TodoService.java
index f215b5d..b7d36e6 100644
--- a/battery-business/src/main/java/com/battery/business/service/TodoService.java
+++ b/battery-business/src/main/java/com/battery/business/service/TodoService.java
@@ -1,25 +1,74 @@
 package com.battery.business.service;
 
 import java.sql.Timestamp;
+import java.sql.Statement;
 import java.util.List;
 import javax.sql.DataSource;
+import com.ruoyi.common.exception.ServiceException;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
-/** 待办真实存储;所有查询和写操作都按当前负责人身份约束。 */
+/** 独立待办持久化,负责人限制始终使用登录用户 ID,不依赖姓名或管理员身份。 */
 @Service
 public class TodoService {
     private final JdbcTemplate jdbc;
-    public TodoService(DataSource source) { this.jdbc = new JdbcTemplate(source); }
-    public record Todo(long id, String content, long ownerId, String owner, String status, String creator, long createdAt, long operatedAt, String handleRemark) {}
+    public TodoService(DataSource source) { jdbc = new JdbcTemplate(source); }
+    public record Todo(long id, String content, long ownerId, String owner, String status,
+            String creator, long createdAt, long operatedAt, String handleRemark) {}
+    public record UserOption(long id, String name) {}
+    private static final String COLUMNS = "select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,handle_remark from t_todo";
+    private static final RowMapper<Todo> ROW = (rs, index) -> new Todo(rs.getLong(1), rs.getString(2), rs.getLong(3), rs.getString(4),
+            rs.getString(5), rs.getString(6), rs.getTimestamp(7).getTime(), rs.getTimestamp(8).getTime(), rs.getString(9));
+
+    /** 返回当前负责人自己的全部待办;包括已处理项,不向创建人或管理员额外开放。 */
     public List<Todo> list(long ownerId) {
-        return jdbc.query("select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,coalesce(handle_remark,'') from t_todo where owner_id=? order by operated_at_utc desc,todo_id desc", (rs, i) -> new Todo(rs.getLong(1),rs.getString(2),rs.getLong(3),rs.getString(4),rs.getString(5),rs.getString(6),rs.getTimestamp(7).getTime(),rs.getTimestamp(8).getTime(),rs.getString(9)), ownerId);
+        return jdbc.query(COLUMNS + " where owner_id=? order by operated_at_utc desc,todo_id desc", ROW, ownerId);
     }
-    public Todo create(String content, long ownerId, String owner, long creatorId, String creator) {
-        Timestamp now = new Timestamp(System.currentTimeMillis());
-        jdbc.update("insert into t_todo(content,owner_id,owner_name,creator_id,creator_name,status,created_at_utc,operated_at_utc,handle_remark) values(?,?,?,?,?,'待处理',?,?,null)", content.trim(), ownerId, owner, creatorId, creator, now, now);
-        return jdbc.queryForObject("select todo_id,content,owner_id,owner_name,status,creator_name,created_at_utc,operated_at_utc,coalesce(handle_remark,'') from t_todo where todo_id=last_insert_id()", (rs,i)->new Todo(rs.getLong(1),rs.getString(2),rs.getLong(3),rs.getString(4),rs.getString(5),rs.getString(6),rs.getTimestamp(7).getTime(),rs.getTimestamp(8).getTime(),rs.getString(9)));
+
+    /** 供新增表单选择启用用户,不要求用户管理模块权限。 */
+    public List<UserOption> owners() {
+        return jdbc.query("select user_id,coalesce(nullif(nick_name,''),user_name) from sys_user where status='0' and del_flag='0' order by user_id",
+                (rs, index) -> new UserOption(rs.getLong(1), rs.getString(2)));
     }
-    public boolean handle(long id,long ownerId,String remark) { return jdbc.update("update t_todo set status='已处理',handle_remark=?,operated_at_utc=? where todo_id=? and owner_id=? and status='待处理'",remark.trim(),new Timestamp(System.currentTimeMillis()),id,ownerId)==1; }
-    public boolean remove(long id,long ownerId) { return jdbc.update("delete from t_todo where todo_id=? and owner_id=?",id,ownerId)==1; }
+
+    /** 新增后仅返回确认主键,分配给别人时不把该待办回传给非负责人。 */
+    @Transactional
+    public long create(String content, long ownerId, long creatorId, String creator) {
+        String value = required(content, 200, "待办内容");
+        var owner = owners().stream().filter(user -> user.id() == ownerId).findFirst()
+                .orElseThrow(() -> new ServiceException("请选择启用的负责人"));
+        var now = new Timestamp(System.currentTimeMillis());
+        var key = new GeneratedKeyHolder();
+        // 主键从本次 INSERT 的 JDBC 结果取得,不跨连接使用会话相关的 last_insert_id。
+        jdbc.update(connection -> {
+            var statement = connection.prepareStatement("insert into t_todo(content,owner_id,owner_name,creator_id,creator_name,status,created_at_utc,operated_at_utc,handle_remark) values(?,?,?,?,?,'待处理',?,?,'')", Statement.RETURN_GENERATED_KEYS);
+            statement.setString(1, value); statement.setLong(2, owner.id()); statement.setString(3, owner.name());
+            statement.setLong(4, creatorId); statement.setString(5, creator); statement.setTimestamp(6, now); statement.setTimestamp(7, now);
+            return statement;
+        }, key);
+        return key.getKey().longValue();
+    }
+
+    /** 负责人只能将自己的待处理事项完成一次;写入条件同时校验身份和当前状态。 */
+    public void handle(long id, long ownerId, String remark) {
+        String value = required(remark, 500, "处理说明");
+        if (jdbc.update("update t_todo set status='已处理',handle_remark=?,operated_at_utc=? where todo_id=? and owner_id=? and status='待处理'",
+                value, new Timestamp(System.currentTimeMillis()), id, ownerId) != 1)
+            throw new ServiceException("待办不存在、已处理或无权处理");
+    }
+
+    /** 按现有删除交互物理删除自己的待办,不涉及测试或异常事实。 */
+    public void remove(long id, long ownerId) {
+        if (jdbc.update("delete from t_todo where todo_id=? and owner_id=?", id, ownerId) != 1)
+            throw new ServiceException("待办不存在或无权删除");
+    }
+
+    private String required(String text, int max, String name) {
+        if (text == null || text.trim().isEmpty() || text.trim().length() > max)
+            throw new ServiceException(name + "不能为空且长度不能超过" + max);
+        return text.trim();
+    }
 }
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java b/ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java
new file mode 100644
index 0000000..6d8f70d
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/DashboardController.java
@@ -0,0 +1,19 @@
+package com.battery.business.web;
+
+import java.time.Instant;
+import com.battery.business.service.DashboardService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/** 首页查询使用独立查看权限,不借用用户管理和测试编辑权限。 */
+@RestController
+@RequestMapping("/system/dashboard")
+public class DashboardController extends BaseController {
+    private final DashboardService service;
+    public DashboardController(DashboardService service) { this.service = service; }
+    /** 汇总当前测试、检测柜和开放异常;个人待办通过自己的负责人接口读取。 */
+    @GetMapping @PreAuthorize("@ss.hasAnyPermi('dashboard:workbench:list,dashboard:workbench:query')")
+    public AjaxResult get() { return success(service.read(Instant.now())); }
+}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java b/ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java
index 481fb79..339f6b0 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/TodoController.java
@@ -1,26 +1,37 @@
 package com.battery.business.web;
 
-import java.util.List;
 import com.battery.business.service.TodoService;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
-/** 待办列表接口;负责人身份由服务端从当前登录会话取得。 */
+/** 待办动作权限与负责人身份分别校验,创建身份只取当前登录会话。 */
 @RestController
 @RequestMapping("/system/todo")
 public class TodoController extends BaseController {
     private final TodoService service;
     public TodoController(TodoService service) { this.service = service; }
-    @GetMapping("/list") @PreAuthorize("@ss.hasPermi('todo:list:query')")
+    /** 仅查询自己的待办,不开放按其他负责人 ID 查询。 */
+    @GetMapping("/list") @PreAuthorize("@ss.hasAnyPermi('todo:list:list,todo:list:query')")
     public AjaxResult list() { return success().put("rows", service.list(getUserId())); }
+    /** 返回新增待办可选的启用用户。 */
+    @GetMapping("/owners") @PreAuthorize("@ss.hasPermi('todo:list:add')")
+    public AjaxResult owners() { return success(service.owners()); }
+    /** 校验负责人后创建,返回主键确认,不泄露别人待办的详情。 */
     @PostMapping @PreAuthorize("@ss.hasPermi('todo:list:add')")
-    public AjaxResult create(@RequestBody CreateRequest request) { if (request == null || request.content() == null || request.content().trim().isEmpty() || request.ownerId() == null || request.ownerName() == null) return error("待办内容和负责人不能为空"); return success(service.create(request.content(), request.ownerId(), request.ownerName(), getUserId(), getUsername())); }
+    public AjaxResult create(@RequestBody CreateRequest request) {
+        if (request == null || request.ownerId() == null) return error("负责人不能为空");
+        return success(service.create(request.content(), request.ownerId(), getUserId(), getUsername()));
+    }
+    /** 仅负责人可完成自己的待办,已处理不能再次修改。 */
     @PutMapping("/{id}/handle") @PreAuthorize("@ss.hasPermi('todo:list:handle')")
-    public AjaxResult handle(@PathVariable long id,@RequestBody HandleRequest request) { if (request == null || request.remark() == null || request.remark().trim().isEmpty()) return error("处理说明不能为空"); return service.handle(id,getUserId(),request.remark()) ? success() : error("待办不存在、已处理或无权处理"); }
+    public AjaxResult handle(@PathVariable long id, @RequestBody HandleRequest request) {
+        service.handle(id, getUserId(), request == null ? null : request.remark()); return success();
+    }
+    /** 删除只作用于当前负责人自己的记录。 */
     @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('todo:list:remove')")
-    public AjaxResult remove(@PathVariable long id) { return service.remove(id,getUserId()) ? success() : error("待办不存在或无权删除"); }
-    public record CreateRequest(String content, Long ownerId, String ownerName) {}
+    public AjaxResult remove(@PathVariable long id) { service.remove(id, getUserId()); return success(); }
+    public record CreateRequest(String content, Long ownerId) {}
     public record HandleRequest(String remark) {}
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java
index f056d65..0db0745 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysOperlogController.java
@@ -31,7 +31,7 @@
     @Autowired
     private ISysOperLogService operLogService;
 
-    @PreAuthorize("@ss.hasPermi('monitor:operlog:list')")
+    @PreAuthorize("@ss.hasAnyPermi('monitor:operlog:list,monitor:operlog:query')")
     @GetMapping("/list")
     public TableDataInfo list(SysOperLog operLog)
     {
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql b/ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql
new file mode 100644
index 0000000..bba0073
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V21__dashboard_and_todo_permissions.sql
@@ -0,0 +1,35 @@
+-- 最终库补齐首页及待办权限;只登记权限,不为任何角色自动增加授权。
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '首页',0,0,'dashboard','M','0','0','','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE path='dashboard' AND menu_type='M');
+SET @dashboard_module = (SELECT menu_id FROM sys_menu WHERE path='dashboard' AND menu_type='M' LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '工作台',@dashboard_module,1,'workbench','C','0','0','dashboard:workbench:list','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='dashboard:workbench:list');
+SET @dashboard_page = (SELECT menu_id FROM sys_menu WHERE perms='dashboard:workbench:list' LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '查看',@dashboard_page,1,'#','F','0','0','dashboard:workbench:query','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='dashboard:workbench:query');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '待办管理',0,5,'todo','M','0','0','','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE path='todo' AND menu_type='M');
+SET @todo_module = (SELECT menu_id FROM sys_menu WHERE path='todo' AND menu_type='M' LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '待办列表',@todo_module,1,'todo-list','C','0','0','todo:list:list','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:list');
+SET @todo_menu = (SELECT menu_id FROM sys_menu WHERE perms='todo:list:list' LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '查看',@todo_menu,1,'#','F','0','0','todo:list:query','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:query');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '新增待办',@todo_menu,1,'#','F','0','0','todo:list:add','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:add');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '处理待办',@todo_menu,2,'#','F','0','0','todo:list:handle','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:handle');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+SELECT '删除待办',@todo_menu,3,'#','F','0','0','todo:list:remove','system',UTC_TIMESTAMP(3)
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='todo:list:remove');
+
+-- 权限表仅展示三级结构,将既有操作日志放到系统管理页面层,不新增日志修改能力。
+UPDATE sys_menu SET parent_id=1 WHERE menu_id=500 AND parent_id=108 AND perms='monitor:operlog:list';
diff --git a/ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java b/ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java
new file mode 100644
index 0000000..c6b5faf
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/report/CompareMergedSourcesTest.java
@@ -0,0 +1,33 @@
+package com.battery.business.report;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.util.*;
+import com.battery.business.domain.*;
+import com.battery.business.service.*;
+import org.junit.jupiter.api.Test;
+
+class CompareMergedSourcesTest {
+    @Test
+    void selectingSourcesUsesSavedMergeAndPreviewIncludesItsActualSteps() {
+        var tests = mock(ITTestService.class); var data = mock(ITestDataQueryService.class);
+        var battery = new TTestBattery(); battery.setTestBatteryId(5L); battery.setGroupId(9L); battery.setBatteryNo("1号"); battery.setFields(List.of());
+        var test = new TTest(); test.setTestId(1L); test.setTestStatus("COMPLETED"); test.setBatteries(List.of(battery)); test.setLogicStepMappings(List.of());
+        when(tests.selectTestList(any())).thenReturn(List.of(test)); when(tests.selectTestById(1L)).thenReturn(test);
+        var records = List.of(Map.<String,Object>of("batteryNo", "1号", "cycleNo", 3, "stepNo", 10, "deviceTime", 1000L),
+                Map.<String,Object>of("batteryNo", "1号", "cycleNo", 3, "stepNo", 11, "deviceTime", 2000L));
+        when(data.records(1L)).thenReturn(records);
+        var service = new CompareReportService(tests, data, null);
+        var actual = service.sources(); assertEquals(2, actual.size());
+        var mapping = new TTestLogicStepMapping(); mapping.setLogicalNo(1); mapping.setLogicalName("合并充电"); mapping.setStartStepNo(1); mapping.setEndStepNo(2);
+        test.setLogicStepMappings(List.of(mapping));
+        var merged = service.sources(); assertEquals(1, merged.size());
+        assertEquals(1, merged.get(0).selection().logicalNo());
+        assertNull(merged.get(0).selection().stepNo());
+        assertTrue(merged.get(0).process().contains("合并充电"));
+        var preview = service.preview(List.of(merged.get(0).selection()));
+        assertEquals(records, preview.get(0).records());
+        assertEquals(1000, preview.get(0).zeroTime());
+        assertEquals(1, service.preview(List.of(actual.get(0).selection())).get(0).records().size(), "旧版来源仍可明确刷新原实际工步");
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java b/ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java
index 28fc6fe..5db5d8a 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/report/ReportTestDetailPersistenceTest.java
@@ -50,7 +50,7 @@
         }
         var compare = new CompareReportService(tests, data, new CompareReportRepository(database));
         var formation = new FormationReportService(tests, data, new FormationReportRepository(database), definitions, value -> StepDirectionResolver.Direction.UNKNOWN);
-        var splice = new SpliceReportService(tests, data, new SpliceReportRepository(database), new SpliceFieldTemplateService(database, definitions));
+        var splice = new SpliceReportService(tests, data, new SpliceReportRepository(database), new SpliceFieldTemplateService(database, definitions), mock(ITBatteryGroupService.class));
         var settings = JSON.parseObject(switch (type) {
             case "compare" -> "{\"metrics\":[],\"seriesColors\":[],\"batteryTemplateFields\":[],\"specialFields\":[],\"fieldStructure\":[],\"detailFieldValues\":{}}";
             case "formation" -> "{\"fields\":[{\"key\":\"chargeCapacity\",\"scope\":\"cycle\",\"kind\":\"metric\",\"displayName\":\"充电容量\",\"unit\":\"mAh\"}],\"values\":{},\"chargeStepSelections\":{},\"metrics\":[],\"seriesColors\":{}}";
diff --git a/ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java b/ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java
new file mode 100644
index 0000000..e1de86d
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/report/SpliceProjectCyclesTest.java
@@ -0,0 +1,46 @@
+package com.battery.business.report;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.math.BigDecimal;
+import java.util.*;
+import com.alibaba.fastjson2.JSON;
+import com.battery.business.domain.*;
+import com.battery.business.service.*;
+import org.junit.jupiter.api.Test;
+
+class SpliceProjectCyclesTest {
+    @Test
+    void actualCyclesBecomeConsecutivePositionsWithFrozenProjectStatistics() {
+        var tests = mock(ITTestService.class);
+        var data = mock(ITestDataQueryService.class);
+        var groups = mock(ITBatteryGroupService.class);
+        var group = new TBatteryGroup(); group.setF("2");
+        when(groups.selectBatteryGroupById(9L)).thenReturn(group);
+        for (long id : List.of(1L, 2L)) {
+            var battery = new TTestBattery(); battery.setTestBatteryId(id); battery.setGroupId(9L);
+            battery.setBatteryNo("电池1"); battery.setFields(List.of());
+            var test = new TTest(); test.setTestId(id); test.setTestStatus("COMPLETED"); test.setBatteries(List.of(battery));
+            when(tests.selectTestById(id)).thenReturn(test);
+            var nos = id == 1 ? List.of(1, 3) : List.of(10, 11);
+            when(data.cycles(id)).thenReturn(nos.stream().map(no -> Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "dischargeCapacity", new BigDecimal("0.004"))).toList());
+            when(data.steps(id)).thenReturn(nos.stream().flatMap(no -> List.of(
+                Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "stepNo", 8),
+                Map.<String,Object>of("batteryNo", "电池1", "cycleNo", no, "stepNo", 1, "endVoltage", 4)).stream()).toList());
+        }
+        var service = new SpliceReportService(tests, data, null, null, groups);
+        var result = service.preview(List.of(new SpliceReportRequest.Selection(1L, 1L), new SpliceReportRequest.Selection(2L, 2L)));
+        assertEquals(List.of(1, 2, 3, 4), result.cycleNos());
+        assertEquals(Map.of("1", 1, "3", 2), result.segments().get(0).get("cyclePositions"));
+        assertEquals(Map.of("10", 3, "11", 4), result.segments().get(1).get("cyclePositions"));
+        assertEquals(List.of(10, 11), result.segments().get(1).get("originalCycleNos"));
+        var stat = JSON.parseObject(JSON.toJSONString(result.segments().get(0).get("statistics"))).getJSONObject("1");
+        assertEquals(0, new BigDecimal("2").compareTo(stat.getBigDecimal("dischargeCapacityF")));
+        assertNull(stat.get("finalVoltage1"), "末工步缺失不能回退到前一工步电压");
+        assertNull(stat.get("finalVoltage2"), "未定义公式不能补值");
+        verify(data, never()).records(anyLong());
+        group.setF("0");
+        var unavailable = service.preview(List.of(new SpliceReportRequest.Selection(1L, 1L), new SpliceReportRequest.Selection(2L, 2L)));
+        assertNull(JSON.parseObject(JSON.toJSONString(unavailable.segments().get(0).get("statistics"))).getJSONObject("1").get("dischargeCapacityF"));
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java
new file mode 100644
index 0000000..20c9332
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/DashboardServiceTest.java
@@ -0,0 +1,57 @@
+package com.battery.business.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.time.Instant;
+import java.util.*;
+import com.battery.business.domain.*;
+import com.battery.business.mapper.TTestMapper;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+class DashboardServiceTest {
+    @Test
+    void aggregatesAllTestsAtHongKongMidnightAndKeepsDeviceFactsSeparate() {
+        var database = new JdbcDataSource(); database.setURL("jdbc:h2:mem:dashboard_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
+        var jdbc = new JdbcTemplate(database);
+        jdbc.execute("create table t_battery_group(group_id bigint, del_flag int)");
+        jdbc.execute("insert into t_battery_group values(1,0),(2,1)");
+        jdbc.execute("create table t_test_realtime_record(record_id bigint,test_id bigint,test_battery_id bigint,step_no int,cycle_no int)");
+        jdbc.execute("create table t_test_battery(test_battery_id bigint,battery_no varchar(20),del_flag int)");
+        jdbc.execute("create table t_test_channel_binding(channel_id bigint,test_id bigint,test_battery_id bigint)");
+        jdbc.execute("create table t_detection_channel(channel_id bigint,cabinet_id bigint,del_flag int)");
+        jdbc.execute("create table t_test(test_id bigint,test_status varchar(20),del_flag int)");
+        jdbc.execute("insert into t_test values(1,'RUNNING',0),(2,'COMPLETED',0)");
+        jdbc.execute("insert into t_test_battery values(1,'电池1',0),(2,'电池2',0)");
+        jdbc.execute("insert into t_detection_channel values(1,7,0)");
+        jdbc.execute("insert into t_test_channel_binding values(1,1,1),(1,2,2)");
+        var tests = mock(ITTestService.class);
+        var cabinets = mock(ITDetectionCabinetService.class);
+        var mapper = mock(TTestMapper.class);
+        var rows = new ArrayList<TTest>();
+        for (int index = 0; index < 1001; index++) {
+            var test = new TTest(); test.setTestId((long) index + 1); test.setTestStatus("RUNNING"); test.setReceptionStatus("PAUSED");
+            test.setCreateTime(Date.from(Instant.parse(index == 0 ? "2026-08-31T15:59:59Z" : "2026-08-31T16:00:00Z"))); rows.add(test);
+        }
+        when(tests.selectTestList(any())).thenReturn(rows);
+        var cabinet = new TDetectionCabinet(); cabinet.setCabinetId(7L); cabinet.setChannelCount(8); cabinet.setOnlineStatus("0");
+        when(cabinets.selectDetectionCabinetList(any())).thenReturn(List.of(cabinet));
+        var warning = new TTestWarningException(); warning.setExceptionId(3L); warning.setTestId(1L); warning.setExceptionType("DEVICE_OFFLINE");
+        when(mapper.selectWarningExceptions(null, "OPEN")).thenReturn(List.of(warning));
+        var result = new DashboardService(tests, cabinets, mapper, database).read(Instant.parse("2026-09-01T00:00:00Z"));
+        assertEquals(1001, result.summary().testCount());
+        assertEquals(1, result.summary().batteryGroupCount());
+        assertEquals(1000, result.summary().todayCount());
+        assertEquals(1000, result.summary().monthCount());
+        assertEquals(List.of(1000L), result.trends().get("month").values());
+        assertEquals(1, result.trends().get("year").values().get(7));
+        assertEquals(1000, result.trends().get("year").values().get(8));
+        assertEquals("在线", result.cabinets().get(0).onlineStatus());
+        assertEquals(1, result.cabinets().get(0).usedChannels());
+        assertEquals(1, result.cabinets().get(0).testCount());
+        assertEquals("RUNNING", result.tests().get(0).status());
+        assertEquals("PAUSED", result.tests().get(0).receptionStatus());
+        assertEquals("设备离线", result.warnings().get(0).item());
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java
new file mode 100644
index 0000000..594f717
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/TodoServiceTest.java
@@ -0,0 +1,39 @@
+package com.battery.business.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+import java.nio.file.*;
+import java.util.UUID;
+import com.ruoyi.common.exception.ServiceException;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+class TodoServiceTest {
+    @Test
+    void onlyOwnerCanReadHandleAndRemoveEvenWhenNamesMatch() throws Exception {
+        var database = new JdbcDataSource();
+        database.setURL("jdbc:h2:mem:todo_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
+        var jdbc = new JdbcTemplate(database);
+        jdbc.execute("CREATE TABLE sys_user(user_id BIGINT, user_name VARCHAR(100), nick_name VARCHAR(100), status CHAR(1), del_flag CHAR(1))");
+        jdbc.update("INSERT INTO sys_user VALUES(1,'creator','同名','0','0'),(2,'owner','同名','0','0'),(3,'disabled','停用','1','0')");
+        var ddl = Files.readString(Path.of("src/main/resources/db/migration-final/V20__todo_records.sql"));
+        jdbc.execute(ddl.substring(0, ddl.indexOf("ENGINE=InnoDB")));
+        var service = new TodoService(database);
+        long id = service.create(" 工作事项 ", 2, 1, "创建人");
+        assertTrue(service.list(1).isEmpty());
+        assertEquals("工作事项", service.list(2).get(0).content());
+        assertEquals(id, service.list(2).get(0).id());
+        assertEquals(2, service.owners().size());
+        assertThrows(ServiceException.class, () -> service.create("事项", 3, 1, "创建人"));
+        assertThrows(ServiceException.class, () -> service.handle(id, 1, "管理员或创建人不可越权"));
+        assertThrows(ServiceException.class, () -> service.remove(id, 1));
+        assertThrows(ServiceException.class, () -> service.handle(id, 2, " "));
+        service.handle(id, 2, "已完成");
+        var reopened = new TodoService(database).list(2).get(0);
+        assertEquals("已处理", reopened.status());
+        assertEquals("已完成", reopened.handleRemark());
+        assertThrows(ServiceException.class, () -> service.handle(id, 2, "再次处理"));
+        service.remove(id, 2);
+        assertTrue(service.list(2).isEmpty());
+    }
+}

--
Gitblit v1.7.1