From f54b0dff8157c7ef2547ac912d70cab63de50626 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 09 九月 2026 20:47:18 +0800
Subject: [PATCH] 按需读取化成旧报表摘要与循环曲线

---
 ruoyi-admin/src/test/java/com/battery/business/report/FormationRealReportTest.java        |    2 
 ruoyi-admin/src/test/java/com/battery/business/report/FormationReportReadMysqlTest.java   |  101 +++++++++++++++++++++++++
 battery-business/src/main/java/com/battery/business/report/FormationReportService.java    |   30 ++++++-
 battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java |   62 +++++++++++++++
 battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java   |   17 +---
 ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java         |   16 +++
 6 files changed, 206 insertions(+), 22 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java b/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java
index 52dbef0..c837a10 100644
--- a/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java
@@ -1,11 +1,71 @@
 package com.battery.business.report;
 
 import javax.sql.DataSource;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import com.alibaba.fastjson2.JSON;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Repository;
 
 /** 化成身份与版本使用独立表,复用对比报表的事务内存储规则。 */
 @Repository
 public class FormationReportRepository extends ReportVersionRepository
 {
-    public FormationReportRepository(DataSource dataSource) { super(dataSource, "formation", "化成"); }
+    private final JdbcTemplate jdbc;
+    public FormationReportRepository(DataSource dataSource) {
+        super(dataSource, "formation", "化成"); jdbc = new JdbcTemplate(dataSource);
+    }
+
+    public record SourceSummary(Summary report, int versionNo, List<Map<String, Object>> sources) { }
+    public record CycleCurve(Map<String, Object> selection, Map<String, Object> curve) { }
+
+    /** 列表只从保存版本投影来源名称,禁止传输或反序列化测量快照。 */
+    public List<SourceSummary> listSources() {
+        return jdbc.query("""
+                SELECT r.report_id,r.report_name,r.creator_name,r.created_at_utc,v.version_no,
+                       JSON_EXTRACT(v.snapshot_json,'$.items[*].source')
+                FROM t_formation_report r JOIN t_formation_report_version v ON v.report_id=r.report_id
+                 AND v.version_no=(SELECT MAX(v2.version_no) FROM t_formation_report_version v2 WHERE v2.report_id=r.report_id)
+                ORDER BY r.created_at_utc DESC,r.report_id DESC
+                """, (rs,index)->new SourceSummary(new Summary(rs.getLong(1),rs.getString(2),rs.getString(3),rs.getTimestamp(4).getTime()),
+                rs.getInt(5), parseSources(rs.getString(6))));
+    }
+
+    /** 首屏投影不含任何循环曲线,旧快照也按相同路径读取,不重写历史 JSON。 */
+    public Version page(long id, Integer versionNo) {
+        return getProjection(id, versionNo, objectProjection("v.snapshot_json",
+                List.of("name","description","cycleNos","settings","calculatedValues","units","displayUnits","curveMode")));
+    }
+
+    /** 版本号由首屏查询固定,后续字段查询不会混入并发产生的新版本。 */
+    public List<Map<String, Object>> pageItems(long id, int versionNo) {
+        String path = "CONCAT('$.items[',j.item_index-1,']')";
+        String fields = List.of("source","cycleNos","cycles","metrics","businessFields","chargeSteps").stream()
+                .map(key->"'"+key+"',JSON_EXTRACT(v.snapshot_json,CONCAT("+path+",'."+key+"'))").collect(Collectors.joining(","));
+        return jdbc.query("SELECT JSON_OBJECT("+fields+") FROM t_formation_report_version v "
+                + "JOIN JSON_TABLE(v.snapshot_json,'$.items[*]' COLUMNS(item_index FOR ORDINALITY)) j ON TRUE "
+                + "WHERE v.report_id=? AND v.version_no=? ORDER BY j.item_index",
+                (rs,index)->JSON.parseObject(rs.getString(1)),id,versionNo);
+    }
+
+    /** 只投影指定保存循环,保留采样的原始顺序、空值、单位和独立来源身份。 */
+    public List<CycleCurve> cycleCurves(long id, int versionNo, int cycleNo) {
+        return jdbc.query("""
+                SELECT JSON_EXTRACT(v.snapshot_json,CONCAT('$.items[',j.item_index-1,'].source.selection')),
+                       JSON_EXTRACT(v.snapshot_json,CONCAT('$.items[',j.item_index-1,'].curves."',?,'"'))
+                FROM t_formation_report_version v
+                JOIN JSON_TABLE(v.snapshot_json,'$.items[*]' COLUMNS(item_index FOR ORDINALITY)) j ON TRUE
+                WHERE v.report_id=? AND v.version_no=? ORDER BY j.item_index
+                """, (rs,index)->new CycleCurve(JSON.parseObject(rs.getString(1)),JSON.parseObject(rs.getString(2))),cycleNo,id,versionNo);
+    }
+
+    private static String objectProjection(String document, List<String> keys) {
+        return "JSON_OBJECT("+keys.stream().map(key->"'"+key+"',JSON_EXTRACT("+document+",'$."+key+"')").collect(Collectors.joining(","))+")";
+    }
+
+    private static List<Map<String,Object>> parseSources(String json) {
+        if (json == null) return List.of();
+        return JSON.parseArray(json).stream().map(value->(Map<String,Object>)JSON.toJSON(value)).toList();
+    }
 }
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportService.java b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
index 209da86..4944949 100644
--- a/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
@@ -56,12 +56,11 @@
     /** 列表来源名称取自已保存版本,测试删除后仍保留筛选与识别信息。 */
     public List<Summary> list()
     {
-        return reports.listWithSnapshots().stream().map(summary -> {
-            // 最新快照由一次列表 SQL 带出,避免每份报表再执行一次版本查询。
-            var sources = items(JSON.parseObject(summary.snapshotJson()));
+        return reports.listSources().stream().map(row -> {
+            var summary = row.report();
             return new Summary(summary.id(), summary.name(), summary.creator(), summary.createdAt(),
-                    String.join("、", sources.stream().map(item -> item.source().name()).distinct().toList()),
-                    String.join("、", sources.stream().map(item -> item.source().batteryGroup()).distinct().toList()), summary.versionNo());
+                    String.join("、", row.sources().stream().map(source -> Objects.toString(source.get("name"), "")).distinct().toList()),
+                    String.join("、", row.sources().stream().map(source -> Objects.toString(source.get("batteryGroup"), "")).distinct().toList()), row.versionNo());
         }).toList();
     }
 
@@ -131,6 +130,27 @@
     public Map<String, Object> get(long id, Integer versionNo)
     {
         var version = reports.get(id, versionNo);
+        return versionResponse(id, version);
+    }
+
+    /** 已保存详情先返回表格与版本身份,曲线通过同一版本的循环接口加载。 */
+    public Map<String, Object> page(long id, Integer versionNo) {
+        var version = reports.page(id, versionNo);
+        var result = versionResponse(id, version);
+        result.put("items", reports.pageItems(id, version.versionNo()));
+        result.put("curvesDeferred", true);
+        return result;
+    }
+
+    /** 只读固定版本的一个实际循环;不存在的报表、版本或循环必须明确拒绝。 */
+    public List<FormationReportRepository.CycleCurve> cycleCurves(long id, int versionNo, int cycleNo) {
+        var version = reports.page(id, versionNo);
+        var cycles = JSON.parseObject(version.snapshotJson()).getList("cycleNos", Integer.class);
+        if (cycleNo < 1 || cycles == null || !cycles.contains(cycleNo)) throw new ServiceException("报表版本中不存在该循环");
+        return reports.cycleCurves(id, versionNo, cycleNo);
+    }
+
+    private Map<String,Object> versionResponse(long id, ReportVersionRepository.Version version) {
         var result = JSON.parseObject(version.snapshotJson());
         result.put("id", id); result.put("versionNo", version.versionNo());
         result.put("creator", version.report().creator()); result.put("createdAt", version.report().createdAt());
diff --git a/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java b/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java
index ff9c100..66443b2 100644
--- a/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java
+++ b/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java
@@ -23,8 +23,6 @@
     private String sql(String statement) { return statement.replace("t_compare_report", "t_" + type + "_report"); }
 
     public record Summary(long id, String name, String creator, long createdAt) { }
-    /** 仅供需要从快照提取列表展示字段的化成服务使用;普通报表列表不返回快照。 */
-    public record SummarySnapshot(long id, String name, String creator, long createdAt, int versionNo, String snapshotJson) { }
     public record Version(Summary report, int versionNo, String snapshotJson, String operatorName, long versionCreatedAt, String changeSummary) { }
     public record History(int versionNo, String operatorName, long createdAt, String changeSummary) { }
 
@@ -35,23 +33,18 @@
                 (rs, index) -> new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()));
     }
 
-    public List<SummarySnapshot> listWithSnapshots()
-    {
-        return jdbc.query(sql("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, v.snapshot_json "
-                + "from t_compare_report r join t_compare_report_version v on v.report_id=r.report_id "
-                + "and v.version_no=(select max(v2.version_no) from t_compare_report_version v2 where v2.report_id=r.report_id) "
-                + "order by r.created_at_utc desc, r.report_id desc"),
-                (rs, index) -> new SummarySnapshot(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime(), rs.getInt(5), rs.getString(6)));
-    }
-
     /** 重读最新完整版本;来源不存在不影响结果。 */
     public Version get(long id)
     { return get(id, null); }
 
     /** 读取指定历史版,版本号为空时读取最新版本。 */
     public Version get(long id, Integer versionNo)
+    { return getProjection(id, versionNo, "v.snapshot_json"); }
+
+    /** 子仓储可提供固定字段投影;表达式仅由代码定义,不接受用户输入。 */
+    protected Version getProjection(long id, Integer versionNo, String projection)
     {
-        var rows = jdbc.query(sql("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, v.snapshot_json, v.operator_name, v.created_at_utc, v.change_summary "
+        var rows = jdbc.query(sql("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, " + projection + ", v.operator_name, v.created_at_utc, v.change_summary "
                 + "from t_compare_report r join t_compare_report_version v on v.report_id=r.report_id where r.report_id=? "
                 + (versionNo == null ? "and v.version_no=(select max(v2.version_no) from t_compare_report_version v2 where v2.report_id=r.report_id)" : "and v.version_no=?")),
                 (rs, index) -> new Version(new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()),
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java b/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java
index 2564723..9d3f8ca 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java
@@ -39,11 +39,11 @@
 
     /** 重开最新保存版本,不读取来源测试。 */
     @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
-    public AjaxResult get(@PathVariable long id) { return success(service.get(id, null)); }
+    public AjaxResult get(@PathVariable long id) { return success(service.page(id, null)); }
 
     /** 编辑权限独立读取最新基准。 */
     @GetMapping("/{id}/editing") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
-    public AjaxResult editing(@PathVariable long id) { return success(service.get(id, null)); }
+    public AjaxResult editing(@PathVariable long id) { return success(service.page(id, null)); }
 
     /** 查看历史版本的操作人、时间和说明。 */
     @GetMapping("/{id}/versions") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
@@ -51,7 +51,17 @@
 
     /** 指定历史版本只读,不提供回滚。 */
     @GetMapping("/{id}/versions/{versionNo}") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
-    public AjaxResult version(@PathVariable long id, @PathVariable int versionNo) { return success(service.get(id, versionNo)); }
+    public AjaxResult version(@PathVariable long id, @PathVariable int versionNo) { return success(service.page(id, versionNo)); }
+
+    /** 查看曲线必须沿用首屏固定版本,不能在切换循环时重读最新来源。 */
+    @GetMapping("/{id}/versions/{versionNo}/cycles/{cycleNo}") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
+    public AjaxResult cycle(@PathVariable long id, @PathVariable int versionNo, @PathVariable int cycleNo)
+    { return success(service.cycleCurves(id, versionNo, cycleNo)); }
+
+    /** 编辑动作可独立读取已打开基准的曲线,不额外要求查询权限。 */
+    @GetMapping("/{id}/editing/versions/{versionNo}/cycles/{cycleNo}") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
+    public AjaxResult editingCycle(@PathVariable long id, @PathVariable int versionNo, @PathVariable int cycleNo)
+    { return success(service.cycleCurves(id, versionNo, cycleNo)); }
 
     /** 导出权限独立读取指定快照,打开打印不记录下载成功。 */
     @GetMapping("/{id}/versions/{versionNo}/print") @PreAuthorize("@ss.hasPermi('system:formation-report:export')")
diff --git a/ruoyi-admin/src/test/java/com/battery/business/report/FormationRealReportTest.java b/ruoyi-admin/src/test/java/com/battery/business/report/FormationRealReportTest.java
index 32b67be..ee6b528 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/report/FormationRealReportTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/report/FormationRealReportTest.java
@@ -72,7 +72,7 @@
         assertEquals(50, values.getBigDecimal("1|11|1|capacity1vf").intValueExact());
         assertEquals(5, saved.getJSONArray("items").getJSONObject(0).getJSONArray("businessFields").size());
         assertNull(saved.getJSONArray("items").getJSONObject(0).get("stepCurves"), "新快照不应重复保存逐工步采样");
-        assertEquals(1, service.list().get(0).versionNo());
+        assertEquals(1, saved.getIntValue("versionNo"));
         clearInvocations(tests, data);
         assertEquals(values, JSON.parseObject(JSON.toJSONString(service.get(saved.getLongValue("id"), null))).getJSONObject("calculatedValues"));
         verifyNoInteractions(tests, data);
diff --git a/ruoyi-admin/src/test/java/com/battery/business/report/FormationReportReadMysqlTest.java b/ruoyi-admin/src/test/java/com/battery/business/report/FormationReportReadMysqlTest.java
new file mode 100644
index 0000000..ad2dc3f
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/report/FormationReportReadMysqlTest.java
@@ -0,0 +1,101 @@
+package com.battery.business.report;
+
+import static org.junit.jupiter.api.Assertions.*;
+import java.util.*;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.battery.business.web.FormationReportController;
+import com.ruoyi.common.exception.ServiceException;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+/** JSON 投影在临时 MySQL 库验证,不能用内存数据库的替代函数证明实际查询正确。 */
+@EnabledIfEnvironmentVariable(named="FORMATION_MYSQL_URL", matches=".+")
+class FormationReportReadMysqlTest {
+    private JdbcTemplate jdbc;
+    private FormationReportRepository repository;
+    private FormationReportService service;
+    private long id;
+
+    @BeforeEach void setup() {
+        String url=System.getenv("FORMATION_MYSQL_URL"), user=System.getenv("FORMATION_MYSQL_USER"), password=System.getenv("FORMATION_MYSQL_PASSWORD");
+        // 建表前核对实际连接的 schema,避免测试误写共享开发库。
+        jdbc=new JdbcTemplate(new DriverManagerDataSource(url,user,password));
+        assertTrue(jdbc.queryForObject("SELECT DATABASE()",String.class).matches("formation_read_[a-f0-9]{32}"));
+        jdbc.execute("DROP TABLE IF EXISTS t_formation_report_version");
+        jdbc.execute("DROP TABLE IF EXISTS t_formation_report");
+        jdbc.execute("CREATE TABLE t_formation_report(report_id BIGINT AUTO_INCREMENT PRIMARY KEY,report_name VARCHAR(100),creator_id BIGINT,creator_name VARCHAR(100),created_at_utc DATETIME(3))");
+        jdbc.execute("CREATE TABLE t_formation_report_version(report_id BIGINT,version_no INT,snapshot_json JSON,operator_id BIGINT,operator_name VARCHAR(100),created_at_utc DATETIME(3),change_summary VARCHAR(500),PRIMARY KEY(report_id,version_no))");
+        repository=new FormationReportRepository(jdbc.getDataSource());
+        service=new FormationReportService(null,null,repository,null,null);
+        id=repository.insert("保存版本一",snapshot(1),1,"操作员",1000);
+    }
+
+    private String snapshot(int version) {
+        List<Map<String,Object>> items=new ArrayList<>();
+        for(int index=1;index<=2;index++) {
+            var item=new LinkedHashMap<String,Object>();
+            item.put("source",Map.of("selection",Map.of("testId",index,"testBatteryId",10+index),"name","测试"+index,"batteryGroup","组"+index,"batteryNo","同名电池"));
+            item.put("cycleNos",index==1?List.of(1,3):List.of(1));
+            item.put("chargeSteps",Map.of("1",List.of(Map.of("stepNo",1,"direction","CHARGE","endVoltage",4.2))));
+            item.put("businessFields",List.of(Map.of("fieldName","F","fieldValue","5")));
+            item.put("metrics",Map.of());item.put("cycles",List.of());
+            var curves=new LinkedHashMap<String,Object>();
+            curves.put("1",Map.of("zeroTime",1000,"records",List.of(Map.of("deviceTime",1000,"stepNo",1,"voltage",version,"current",0),Map.of("deviceTime",2500,"stepNo",1,"voltage",-1))));
+            if(index==1)curves.put("3",Map.of("zeroTime",5000,"records",List.of(Map.of("deviceTime",5000,"stepNo",1,"voltage",3))));
+            item.put("curves",curves);
+            item.put("stepCurves",Map.of("legacyDuplicate","x".repeat(1_000_000)));
+            items.add(item);
+        }
+        return JSON.toJSONString(Map.of("name","保存版本"+version,"items",items,"cycleNos",List.of(1,3),"description","说明",
+                "settings",Map.of("values",Map.of("1|11|1|manual","2"),"fields",List.of()),"calculatedValues",Map.of("1|11|1|capacity1v",250)));
+    }
+
+    @Test void projectsOldSnapshotsWithoutLoadingDuplicatedCurvesOrChangingHistory() {
+        String before=repository.get(id,1).snapshotJson();
+        assertTrue(before.length()>2_000_000);
+        var list=service.list();
+        assertEquals("测试1、测试2",list.get(0).testName());
+        assertEquals("组1、组2",list.get(0).batteryGroup());
+        assertEquals(1,list.get(0).versionNo());
+        assertTrue(JSON.toJSONBytes(list).length<2000);
+        var first=JSON.parseObject(JSON.toJSONString(service.page(id,1)));
+        assertTrue(first.getBooleanValue("curvesDeferred"));
+        assertTrue(first.toJSONString().length()<5000);
+        assertEquals(2,first.getJSONArray("items").size());
+        var expectedItems=JSON.parseObject(before).getJSONArray("items");
+        for(int i=0;i<2;i++) {
+            JSONObject item=first.getJSONArray("items").getJSONObject(i);
+            assertFalse(item.containsKey("curves"));assertFalse(item.containsKey("stepCurves"));
+            for(String key:List.of("source","cycleNos","chargeSteps","businessFields","metrics","cycles"))
+                assertEquals(expectedItems.getJSONObject(i).get(key),item.get(key));
+        }
+        repository.append(id,2,"保存版本二",snapshot(2),1,"操作员","追加新版本");
+        assertEquals(2,service.page(id,null).get("versionNo"));
+        assertEquals(2,service.list().get(0).versionNo());
+        var curves=service.cycleCurves(id,1,1);
+        assertEquals(2,curves.size());
+        assertEquals(expectedItems.getJSONObject(0).getJSONObject("curves").get("1"),curves.get(0).curve());
+        assertEquals(Map.of("testId",2,"testBatteryId",12),curves.get(1).selection());
+        assertNull(service.cycleCurves(id,1,3).get(1).curve());
+        assertThrows(ServiceException.class,()->service.page(999,null));
+        assertThrows(ServiceException.class,()->service.cycleCurves(id,99,1));
+        assertThrows(ServiceException.class,()->service.cycleCurves(id,1,2));
+        assertEquals(before,repository.get(id,1).snapshotJson());
+        assertFalse(JSON.toJSONString(repository.list()).contains("snapshot"));
+    }
+
+    @Test void pageAndPrintKeepSeparatePayloadsAndCurveActionsKeepSeparatePermissions() throws Exception {
+        var controller=new FormationReportController(service,repository);
+        var page=JSON.parseObject(JSON.toJSONString(controller.get(id))).getJSONObject("data");
+        assertTrue(page.toJSONString().length()<5000);
+        var print=JSON.parseObject(JSON.toJSONString(controller.print(id,1))).getJSONObject("data");
+        assertEquals(List.of(1,3),print.getList("cycleNos",Integer.class));
+        assertNotNull(print.getJSONArray("items").getJSONObject(0).getJSONObject("curves").get("3"));
+        assertEquals("@ss.hasPermi('system:formation-report:query')",FormationReportController.class.getMethod("cycle",long.class,int.class,int.class).getAnnotation(PreAuthorize.class).value());
+        assertEquals("@ss.hasPermi('system:formation-report:edit')",FormationReportController.class.getMethod("editingCycle",long.class,int.class,int.class).getAnnotation(PreAuthorize.class).value());
+    }
+}

--
Gitblit v1.7.1