Просмотр исходного кода

feat(health-report): P1-1 报告日期可修改·后端 - 严格解析+未来日期拦截,日期变更联动快照刷新与 date_source 兜底

- updateReportFromPayload:SimpleDateFormat 改 setLenient(false) + ParsePosition 全串消费校验,
  解析失败抛 IllegalArgumentException(GlobalExceptionHandler 转 400 + 明确提示),不再静默吞错
- 未来日期拦截:parsedDate.after(now) 拒绝,当天允许
- 记录 reportDateChanged 标记,改日期后补调 refreshMemberIndicatorCurrent(subjectId, reportId)
  刷新 member_indicator_current 快照(改日期改变 report_date 排序,需重定位上一期)
- date_source 标记:columnExists(indicator_values, date_source) 兜底判断,列存在则 UPDATE report 时序行为 real,
  列不存在(T01 迁移未落地)整段跳过并 log.info 说明原因,不崩不报错
- 幂等性未破:oldReport.reportDate 先改再走 normalizeIndicatorPipeline.processReport,
  upsert SQL 含 report_date = VALUES(report_date),时序日期自动同步
- 新增私有 columnExists 辅助(参照 DatabaseInitializer 实现)

设计文档:docs/superpowers/specs/2026-10-26-report-date-editable-design.md §3.2
mvn -o compile EXIT_CODE=0
E2E Test Bot 2 недель назад
Родитель
Сommit
3c8d132ec8
1 измененных файлов с 57 добавлено и 9 удалено
  1. 57 9
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

+ 57 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1841,19 +1841,26 @@ public class HealthReportService {
             oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
             oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
         }
         }
         // 用户可手动修正报告日期(AI 解析结果不准确时)
         // 用户可手动修正报告日期(AI 解析结果不准确时)
+        // 严格解析:lenient=false(拒绝 2025-13-45 这种"静默成功")+ 整串必须被消费
+        // 解析失败/未来日期一律抛业务异常(GlobalExceptionHandler → 400 + 明确提示),绝不静默吞掉
+        boolean reportDateChanged = false;
         Object reportDateObj = scoreSource.get("reportDate");
         Object reportDateObj = scoreSource.get("reportDate");
         if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
         if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
             String reportDateStr = String.valueOf(reportDateObj).trim();
             String reportDateStr = String.valueOf(reportDateObj).trim();
-            try {
-                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
-                java.util.Date parsedDate = sdf.parse(reportDateStr);
-                if (parsedDate != null) {
-                    oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
-                    log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
-                }
-            } catch (Exception e) {
-                log.warn("reportDate 格式解析失败 reportId={}, value={}: {}", reportId, reportDateStr, e.getMessage());
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+            sdf.setLenient(false);
+            java.text.ParsePosition parsePos = new java.text.ParsePosition(0);
+            java.util.Date parsedDate = sdf.parse(reportDateStr, parsePos);
+            if (parsedDate == null || parsePos.getIndex() != reportDateStr.length()) {
+                throw new IllegalArgumentException("报告日期格式应为 yyyy-MM-dd,当前值:" + reportDateStr);
             }
             }
+            // 当天允许,未来日期拒绝
+            if (parsedDate.after(new Date())) {
+                throw new IllegalArgumentException("报告日期不能晚于今天,当前值:" + reportDateStr);
+            }
+            oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
+            reportDateChanged = true;
+            log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
         }
         }
         oldReport.setUpdatedAt(new Date());
         oldReport.setUpdatedAt(new Date());
         healthReportMapper.updateById(oldReport);
         healthReportMapper.updateById(oldReport);
@@ -2016,6 +2023,32 @@ public class HealthReportService {
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
             log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
         }
         }
+
+        // 8. 报告日期变更后的联动(须在归一化管道之后:管道重写 indicator_values 时序行)
+        if (reportDateChanged) {
+            // a) 刷新成员指标快照:改日期改变 report_date 排序,"上一期"定位需重算
+            //    内部有 subjectId/reportId null 保护;effectiveSubjectId 为本次编辑后的实际归属
+            Long effectiveSubjectId = (subjectId != null && subjectId > 0) ? subjectId : oldSubjectId;
+            try {
+                refreshMemberIndicatorCurrent(effectiveSubjectId, reportId);
+                log.info("报告日期变更后已刷新成员{}的指标快照", effectiveSubjectId);
+            } catch (Exception e) {
+                log.warn("报告日期变更后刷新成员{}指标快照失败 reportId={}: {}",
+                        effectiveSubjectId, reportId, e.getMessage());
+            }
+            // b) 手动修正的日期打 date_source='real' 标记(T01 迁移列;列不存在时跳过不崩)
+            try {
+                if (columnExists("indicator_values", "date_source")) {
+                    int updated = jdbcTemplate.update(
+                            "UPDATE indicator_values SET date_source = 'real' WHERE report_id = ?", reportId);
+                    log.info("已标记报告{}的时序行 date_source='real',共{}行", reportId, updated);
+                } else {
+                    log.info("indicator_values 无 date_source 列(T01 迁移未落地),跳过 date_source 标记 reportId={}", reportId);
+                }
+            } catch (Exception e) {
+                log.warn("date_source 标记失败 reportId={}: {}", reportId, e.getMessage());
+            }
+        }
     }
     }
 
 
     private Integer toInteger(Object value) {
     private Integer toInteger(Object value) {
@@ -2027,6 +2060,21 @@ public class HealthReportService {
         }
         }
     }
     }
 
 
+    /**
+     * 列是否存在(与 DatabaseInitializer.columnExists 同实现思路)
+     * 用于可选列(如 T01 的 indicator_values.date_source)做兜底判断:列不存在时跳过写入,不报错
+     */
+    private boolean columnExists(String table, String column) {
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
+                    Integer.class, table, column);
+            return count != null && count > 0;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     /**
     /**
      * 保存 payload 快照并记录编辑日志(action: create/edit)
      * 保存 payload 快照并记录编辑日志(action: create/edit)
      */
      */