Przeglądaj źródła

feat(T01): 新增 /api/admin/indicator/backfill 回填端点(dryRun 优先)

- HealthReportService.backfillIndicatorValues:先日期修复后时序回填,dryRun 只统计不写库
- 出参含 eligible / dateFixable / skippedNoSubject / skippedNoPayload / written / details
- admin 角色校验,默认 dryRun=true
Sisyphus 2 tygodni temu
rodzic
commit
02892080d9

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorAdminController.java

@@ -3,9 +3,11 @@ package com.etotem.cfc.controller.admin;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.IndicatorDefinition;
 import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.IndicatorService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import javax.annotation.Resource;
@@ -20,6 +22,9 @@ public class IndicatorAdminController {
     @Resource
     private IndicatorService indicatorService;
 
+    @Resource
+    private HealthReportService healthReportService;
+
     @PostMapping("/list")
     public Result<?> list(@RequestBody Map<String, Object> params) {
         String domain = (String) params.getOrDefault("domain", null);
@@ -55,4 +60,27 @@ public class IndicatorAdminController {
         List<IndicatorValue> values = indicatorService.listValues(sourceType, sourceId);
         return Result.success(values);
     }
+
+    /**
+     * 存量时序回填(DS-2026-10-25-01 §4.6)。
+     * dryRun=true(默认)只统计不写入,返回 eligible / dateFixable 两个计数;
+     * dryRun=false 才实际写入,且顺序上先修日期再补时序。
+     *
+     * 入参:subjectId? / reportId? / limit(默认200) / dryRun(默认true) / fixReportDates(默认false)
+     */
+    @PostMapping("/backfill")
+    public Result<?> backfill(@RequestBody Map<String, Object> params,
+                              @RequestAttribute(value = "role", required = false) String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("权限不足");
+        }
+        Long subjectId = ParamUtils.getLong(params.get("subjectId"));
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        Long limitLong = ParamUtils.getLong(params.get("limit"));
+        Integer limit = limitLong == null ? null : limitLong.intValue();
+        boolean dryRun = !Boolean.FALSE.equals(ParamUtils.getBoolean(params.get("dryRun")));
+        boolean fixReportDates = Boolean.TRUE.equals(ParamUtils.getBoolean(params.get("fixReportDates")));
+        return Result.success(
+                healthReportService.backfillIndicatorValues(subjectId, reportId, limit, dryRun, fixReportDates));
+    }
 }

+ 132 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -121,6 +121,9 @@ public class HealthReportService {
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
+    @Resource
+    private ReportDateExtractor reportDateExtractor;
+
     /**
      * 创建健康报告(含指标列表)
      */
@@ -1320,6 +1323,135 @@ public class HealthReportService {
         }
     }
 
+    /**
+     * 存量时序回填(/api/admin/indicator/backfill 的服务端,DS-2026-10-25-01 §4.6)。
+     *
+     * <p>顺序红线:先做日期修复、再做时序回填(污染日期若先固化进 indicator_values 再修就晚了)。
+     * dryRun=true 时只统计不写入,绝不产生任何副作用(不触发 mappingService.resolve,避免惰性建定义)。</p>
+     *
+     * @return scanned / eligible / written / skippedNoSubject / skippedNoPayload / failed /
+     *         dryRun / fixReportDates / dateFixable / dateFixed / details
+     */
+    public Map<String, Object> backfillIndicatorValues(Long subjectId, Long reportId, Integer limit,
+                                                       boolean dryRun, boolean fixReportDates) {
+        int batchLimit = (limit == null || limit < 1) ? 200 : Math.min(limit, 500);
+        LambdaQueryWrapper<HealthReport> qw = new LambdaQueryWrapper<HealthReport>()
+                .eq(HealthReport::getStatus, "active");
+        if (subjectId != null) {
+            qw.eq(HealthReport::getSubjectId, subjectId);
+        }
+        if (reportId != null) {
+            qw.eq(HealthReport::getId, reportId);
+        }
+        qw.orderByAsc(HealthReport::getId).last("LIMIT " + batchLimit);
+        List<HealthReport> reports = healthReportMapper.selectList(qw);
+
+        int scanned = reports.size();
+        int eligible = 0;
+        int written = 0;
+        int skippedNoSubject = 0;
+        int skippedNoPayload = 0;
+        int failed = 0;
+        int dateFixable = 0;
+        int dateFixed = 0;
+        List<Map<String, Object>> details = new ArrayList<>();
+
+        for (HealthReport r : reports) {
+            // ---- 第 1 步:日期修复判定(幂等:修好后条件不再成立)----
+            try {
+                java.sql.Date extracted = reportDateExtractor.extractReportDate(r.getPayloadJson());
+                if (extracted != null && r.getReportDate() != null
+                        && !toDay(extracted).equals(toDay(r.getReportDate()))
+                        && !r.getReportDate().before(toDay(r.getCreatedAt()))) {
+                    // report_date >= 入库日当天 = new Date() fallback 污染特征 → 修回真实采样日
+                    dateFixable++;
+                    if (!dryRun && fixReportDates) {
+                        String oldDate = r.getReportDate() == null ? null : toDay(r.getReportDate()).toString();
+                        r.setReportDate(extracted);
+                        healthReportMapper.updateById(r);
+                        dateFixed++;
+                        try {
+                            refreshMemberIndicatorCurrent(r.getSubjectId(), r.getId());
+                        } catch (Exception e) {
+                            log.warn("日期修复后刷新指标当前值失败 reportId={}: {}", r.getId(), e.getMessage());
+                        }
+                        log.info("回填日期修复 reportId={}, {} → {}", r.getId(), oldDate, extracted);
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("日期修复判定失败 reportId={}: {}", r.getId(), e.getMessage());
+            }
+
+            // ---- 第 2 步:时序回填判定 ----
+            if (r.getSubjectId() == null) {
+                skippedNoSubject++;
+                details.add(backfillDetail(r, "skip_no_subject"));
+                continue;
+            }
+            if (r.getPayloadJson() == null || r.getPayloadJson().trim().isEmpty()) {
+                skippedNoPayload++;
+                details.add(backfillDetail(r, "skip_no_payload"));
+                continue;
+            }
+            boolean covered = indicatorValueMapper.selectCount(new LambdaQueryWrapper<IndicatorValue>()
+                    .eq(IndicatorValue::getSourceType, r.getReportType())
+                    .eq(IndicatorValue::getSourceId, r.getId())) > 0;
+            if (covered) {
+                details.add(backfillDetail(r, "already_covered"));
+                continue;
+            }
+            eligible++;
+            String status = "would_write";
+            if (!dryRun) {
+                try {
+                    ParsedReportPayload.Payload payload =
+                            objectMapper.readValue(r.getPayloadJson(), ParsedReportPayload.Payload.class);
+                    normalizeIndicatorPipeline.processReport(r, payload);
+                    written++;
+                    status = "written";
+                } catch (Exception e) {
+                    failed++;
+                    status = "failed";
+                    log.warn("时序回填失败 reportId={}: {}", r.getId(), e.getMessage());
+                }
+            }
+            details.add(backfillDetail(r, status));
+        }
+
+        Map<String, Object> out = new HashMap<>();
+        out.put("scanned", scanned);
+        out.put("eligible", eligible);
+        out.put("written", written);
+        out.put("skippedNoSubject", skippedNoSubject);
+        out.put("skippedNoPayload", skippedNoPayload);
+        out.put("failed", failed);
+        out.put("dryRun", dryRun);
+        out.put("fixReportDates", fixReportDates);
+        out.put("dateFixable", dateFixable);
+        out.put("dateFixed", dateFixed);
+        out.put("details", details);
+        log.info("存量时序回填(dryRun={}, fixDates={}) scanned={}, eligible={}, dateFixable={}, 已写 {}",
+                dryRun, fixReportDates, scanned, eligible, dateFixable, written);
+        return out;
+    }
+
+    private Map<String, Object> backfillDetail(HealthReport r, String status) {
+        Map<String, Object> d = new HashMap<>();
+        d.put("reportId", r.getId());
+        d.put("status", status);
+        return d;
+    }
+
+    /** 截断到日(仅比较日期部分) */
+    private java.sql.Date toDay(java.util.Date d) {
+        if (d == null) {
+            return null;
+        }
+        return java.sql.Date.valueOf(
+                java.time.Instant.ofEpochMilli(d.getTime())
+                        .atZone(java.time.ZoneId.systemDefault()).toLocalDate());
+    }
+
     /**
      * 重新分配报告归属
      *