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

feat: 旧体系接口14天零调用打点统计+巡检脚本 (P2-T3 退役证据)

为统一维度体系 P2-T3 旧体系退役补前置证据能力:
- 新增 LegacyDimensionCallFilter: OncePerRequestFilter, flag 门控默认关零开销
  匹配 /api/energy/ /api/admin/energy-config/ /api/admin/energy-rule/ /api/admin/energy-behavior/
- 新增 LegacyDimensionCallLog 实体 + Mapper 每日聚合 upsert (UNIQUE api_path+period)
- 新增 LegacyDimensionCallLogService: recordCall 打点 + @Scheduled 每小时 __heartbeat__ 心跳
- DatabaseInitializer 迁移454 幂等建表 + schema.sql 同步
- application.yml 增加 dimension.registry.legacyCallLog.enabled (默认 false)
- docs/scripts/legacy_dimension_zero_call_check.py: 14天零调用巡检脚本 (PASS/FAIL/WARN)
- 单测 LegacyDimensionCallFilterTest(6) + LegacyDimensionCallLogServiceTest(7) = 13/13 通过
Agnes Agent 5 дней назад
Родитель
Сommit
df8af9a2be

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3460,6 +3460,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移453: 小米运动健康云服务接口接入(X1)—— 令牌表(token 仅后端持有、加密存库)
         createXiaomiHealthTokensTable();
+
+        // 迁移454: 旧体系接口调用打点表(P2-T3 退役证据,按 api_path+period 每日聚合)
+        createLegacyDimensionCallLogTable();
     }
 
     /**
@@ -3569,6 +3572,31 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移454: 创建旧体系接口调用打点表(P2-T3 退役证据)。
+     *
+     * <p>为「旧接口连续 14 天零调用」退役触发条件提供前置证据。按 (api_path, period)
+     * 每日聚合(UNIQUE 键),避免 14 天观察期行数按「接口 × 天」爆炸;{@code __heartbeat__}
+     * 心跳行证明采集器当天存活。幂等:CREATE TABLE IF NOT EXISTS。</p>
+     *
+     * <p>字段与 2026-11-03-unified-dimension-system-design.md §8.3 对齐:api_path/period/call_count。</p>
+     */
+    private void createLegacyDimensionCallLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS legacy_dimension_call_log ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY,"
+                    + "api_path VARCHAR(100) NOT NULL COMMENT '旧体系接口路径(/api/energy/** 等;__heartbeat__ 为采集心跳)',"
+                    + "period DATE NOT NULL COMMENT '统计日期(按日聚合)',"
+                    + "call_count INT NOT NULL DEFAULT 0 COMMENT '当日调用次数',"
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',"
+                    + "UNIQUE KEY uk_legacy_dim_call_api_period (api_path, period)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='旧体系接口调用打点(P2-T3退役证据)'");
+            log.info("迁移454: legacy_dimension_call_log 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移454: legacy_dimension_call_log 创建失败: {}", e.getMessage());
+        }
+    }
+
     /**
      * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
      *

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/LegacyDimensionCallLog.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.sql.Date;
+
+/**
+ * 旧体系接口调用打点(P2-T3 退役证据,按日聚合)。
+ *
+ * <p>为「旧接口连续 14 天零调用」退役触发条件提供前置证据:每个待退役的旧体系接口
+ * 在当天被调用一次则 (api_path, period) 对应行 call_count+1;{@code __heartbeat__}
+ * 心跳行证明采集器当天存活,防止「表空 = 假零调用」误判。UNIQUE(api_path, period)
+ * 由建表迁移(DatabaseInitializer 迁移454)保证,行数按 接口数 × 天数 有界,不会膨胀。</p>
+ *
+ * <p>结构见 2026-11-03-unified-dimension-system-design.md §8.3。</p>
+ */
+@Data
+@TableName("legacy_dimension_call_log")
+public class LegacyDimensionCallLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 旧体系接口路径(/api/energy/**、/api/admin/energy-config/** 等;__heartbeat__ 为采集心跳) */
+    private String apiPath;
+
+    /** 统计日期(按日聚合) */
+    private Date period;
+
+    /** 当日调用次数 */
+    private Integer callCount;
+
+    /** 最后更新时间(DATETIME,java.sql.Date 已用于 period,此处用全限定名避免命名冲突) */
+    private java.util.Date updatedAt;
+}

+ 96 - 0
cfc-backend/src/main/java/com/etotem/cfc/filter/LegacyDimensionCallFilter.java

@@ -0,0 +1,96 @@
+package com.etotem.cfc.filter;
+
+import com.etotem.cfc.service.LegacyDimensionCallLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.annotation.Resource;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 旧体系接口调用打点 Filter(P2-T3 退役证据)。
+ *
+ * <p>采集开关 {@code dimension.registry.legacyCallLog.enabled} 默认关闭:关闭时本 filter
+ * 零开销直接放行(不匹配、不计数、不访问 DB)。开启时仅对命中旧体系 URI 前缀的请求
+ * 调 {@link LegacyDimensionCallLogService#recordCall(String)} 计数 +1。</p>
+ *
+ * <p>统计范围(唯一事实源,已核实现网 Controller):</p>
+ * <ul>
+ *   <li>{@code /api/energy/**}:{@code EnergyController}(@RequestMapping("/api/energy"),9 端点)+ {@code SeedEnergyController}(/seed,同前缀)</li>
+ *   <li>{@code /api/admin/energy-config/**}:{@code AdminEnergyConfigController}</li>
+ *   <li>{@code /api/admin/energy-rule/**}:{@code AdminEnergyRuleController}</li>
+ *   <li>{@code /api/admin/energy-behavior/**}:{@code AdminEnergyBehaviorConfigController}</li>
+ * </ul>
+ *
+ * <p><b>说明</b>:设计文档 §8.3 曾列 {@code /api/dimension/overview}、
+ * {@code /api/dimension/radar-data},但现网代码不存在这两个路径(幽灵路径),
+ * <b>不纳入统计</b>,避免产生永不命中的统计行。</p>
+ */
+@Slf4j
+@Component
+@Order(Ordered.LOWEST_PRECEDENCE)
+public class LegacyDimensionCallFilter extends OncePerRequestFilter {
+
+    /** 待统计的旧体系 URI 前缀(唯一事实源) */
+    public static final List<String> LEGACY_PREFIXES = Collections.unmodifiableList(Arrays.asList(
+            "/api/energy/",
+            "/api/admin/energy-config/",
+            "/api/admin/energy-rule/",
+            "/api/admin/energy-behavior/"
+    ));
+
+    @Value("${dimension.registry.legacyCallLog.enabled:false}")
+    private boolean legacyCallLogEnabled;
+
+    @Resource
+    private LegacyDimensionCallLogService legacyDimensionCallLogService;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        if (legacyCallLogEnabled) {
+            String uri = request.getRequestURI();
+            if (matchesLegacyPath(uri)) {
+                try {
+                    legacyDimensionCallLogService.recordCall(uri);
+                } catch (Exception e) {
+                    // 打点失败不阻断业务请求(零侵入红线)
+                    log.warn("LegacyDimensionCallFilter 打点失败(忽略): {}", e.getMessage());
+                }
+            }
+        }
+        filterChain.doFilter(request, response);
+    }
+
+    /**
+     * 判断 URI 是否命中旧体系接口前缀。
+     *
+     * <p>null / 空 / 未命中任一前缀返回 false;幽灵路径(/api/dimension/overview、
+     * /api/dimension/radar-data)不匹配,不在统计范围。</p>
+     *
+     * @param uri 请求 URI(不含 query string)
+     * @return true=命中旧体系前缀
+     */
+    public static boolean matchesLegacyPath(String uri) {
+        if (uri == null || uri.isEmpty()) {
+            return false;
+        }
+        for (String prefix : LEGACY_PREFIXES) {
+            if (uri.startsWith(prefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/LegacyDimensionCallLogMapper.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+import java.sql.Date;
+
+/**
+ * 旧体系接口调用打点 Mapper(P2-T3 退役证据)。
+ *
+ * <p>按 (api_path, period) 每日聚合 upsert:不存在则插入 call_count=1,已存在则
+ * call_count+1。UNIQUE(api_path, period) 由建表迁移(DatabaseInitializer 迁移454)
+ * 保证,避免 14 天观察期行数按「接口 × 天」爆炸。</p>
+ */
+@Mapper
+public interface LegacyDimensionCallLogMapper {
+
+    /**
+     * 每日聚合打点:命中 (api_path, period) 则 call_count+1,否则插入 call_count=1。
+     *
+     * @param apiPath 旧体系接口路径(或 {@code __heartbeat__} 心跳)
+     * @param period  统计日期
+     * @return 受影响行数(1=插入或自增)
+     */
+    @Update("INSERT INTO legacy_dimension_call_log (api_path, period, call_count, updated_at) "
+            + "VALUES (#{apiPath}, #{period}, 1, NOW()) "
+            + "ON DUPLICATE KEY UPDATE call_count = call_count + 1, updated_at = NOW()")
+    int upsert(@Param("apiPath") String apiPath, @Param("period") Date period);
+}

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/LegacyDimensionCallLogService.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.mapper.LegacyDimensionCallLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.sql.Date;
+
+/**
+ * 旧体系接口调用打点服务(P2-T3 退役证据采集)。
+ *
+ * <p>采集开关 {@code dimension.registry.legacyCallLog.enabled} 默认关闭(零开销):
+ * 关闭时 {@link #heartbeat()} 不写、filter 不计数;测试/待退役环境置 true 后,
+ * {@link LegacyDimensionCallFilter} 对命中前缀的旧接口逐次 {@link #recordCall(String)},
+ * 每小时 {@link #heartbeat()} 证明采集器当天存活,防止「表空 = 假零调用」误判。</p>
+ *
+ * <p>设计文档 §8.3 的退役触发条件:旧接口连续 14 天零调用 + 双写一致性 0 差异,
+ * 由 docs/scripts/legacy_dimension_zero_call_check.py 巡检判定。</p>
+ */
+@Slf4j
+@Service
+public class LegacyDimensionCallLogService {
+
+    /** 采集心跳专用 api_path:证明采集器当天在运行 */
+    public static final String HEARTBEAT_PATH = "__heartbeat__";
+
+    /** api_path 列长度上限(VARCHAR(100)),超长截断,避免写盘失败 */
+    private static final int MAX_PATH_LENGTH = 100;
+
+    @Value("${dimension.registry.legacyCallLog.enabled:false}")
+    private boolean legacyCallLogEnabled;
+
+    @Resource
+    private LegacyDimensionCallLogMapper legacyDimensionCallLogMapper;
+
+    /**
+     * 打点:旧体系接口被调用时计数 +1(按 api_path + period 每日聚合)。
+     *
+     * <p>打点失败只记录告警日志,绝不影响业务请求(零侵入红线)。</p>
+     *
+     * @param path 请求 URI 路径(不含 query string)
+     */
+    public void recordCall(String path) {
+        if (path == null || path.isEmpty()) {
+            return;
+        }
+        try {
+            String safePath = path.length() > MAX_PATH_LENGTH
+                    ? path.substring(0, MAX_PATH_LENGTH) : path;
+            legacyDimensionCallLogMapper.upsert(safePath, today());
+        } catch (Exception e) {
+            log.warn("LegacyDimensionCallLogService.recordCall 打点失败(忽略): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 每小时心跳:写一条 {@code __heartbeat__} 记录,证明采集器当天活着。
+     *
+     * <p>仅在采集开关开启时写入(默认关闭零开销)。@EnableScheduling 已在
+     * {@code CfcApplication} 开启。</p>
+     */
+    @Scheduled(cron = "0 0 * * * ?")
+    public void heartbeat() {
+        if (!legacyCallLogEnabled) {
+            return;
+        }
+        try {
+            legacyDimensionCallLogMapper.upsert(HEARTBEAT_PATH, today());
+        } catch (Exception e) {
+            log.warn("LegacyDimensionCallLogService.heartbeat 失败(忽略): {}", e.getMessage());
+        }
+    }
+
+    private Date today() {
+        return new Date(System.currentTimeMillis());
+    }
+}

+ 4 - 0
cfc-backend/src/main/resources/application.yml

@@ -140,6 +140,10 @@ dimension:
   registry:
     enabled: false
     dualWrite: false
+    # P2-T3 旧体系接口调用打点:默认关闭零开销;测试/待退役环境置 true 开启采集
+    # (LegacyDimensionCallFilter 计数 + 每小时 __heartbeat__ 心跳)
+    legacyCallLog:
+      enabled: ${DIM_REGISTRY_LEGACY_CALL_LOG_ENABLED:false}
 
 langgraph:
   # LangGraph 报告解析服务(健康报告 PDF → 结构化 JSON)

+ 10 - 0
cfc-backend/src/main/resources/schema.sql

@@ -7091,3 +7091,13 @@ CREATE TABLE IF NOT EXISTS xiaomi_health_tokens (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_member (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小米运动健康OAuth令牌';
+
+-- 旧体系接口调用打点(P2-T3 退役证据,按 api_path+period 每日聚合;__heartbeat__ 为采集心跳)
+CREATE TABLE IF NOT EXISTS legacy_dimension_call_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    api_path VARCHAR(100) NOT NULL COMMENT '旧体系接口路径(/api/energy/** 等;__heartbeat__ 为采集心跳)',
+    period DATE NOT NULL COMMENT '统计日期(按日聚合)',
+    call_count INT NOT NULL DEFAULT 0 COMMENT '当日调用次数',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+    UNIQUE KEY uk_legacy_dim_call_api_period (api_path, period)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='旧体系接口调用打点(P2-T3退役证据)';

+ 72 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/LegacyDimensionCallFilterTest.java

@@ -0,0 +1,72 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.filter.LegacyDimensionCallFilter;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 旧体系接口 URI 前缀匹配单测(路径匹配是 filter 的核心逻辑,与开关状态无关)。
+ */
+class LegacyDimensionCallFilterTest {
+
+    @Test
+    void energy_prefix_全部子路径命中() {
+        // EnergyController (sandbox/overview/logs/grant/deduct-for-wish/configure/score-history/family-score-history/wealth-detail)
+        // + SeedEnergyController (/seed),同 /api/energy/** 前缀
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/sandbox"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/overview"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/logs"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/grant"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/deduct-for-wish"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/configure"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/score-history"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/family-score-history"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/wealth-detail"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/seed"));
+        // 超长子路径也命中(startsWith)
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/energy/foo/bar/baz"));
+    }
+
+    @Test
+    void admin_energy_config_rule_behavior_prefix_命中() {
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/admin/energy-config/list"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/admin/energy-rule/save"));
+        assertTrue(LegacyDimensionCallFilter.matchesLegacyPath("/api/admin/energy-behavior/delete"));
+    }
+
+    @Test
+    void ghost_paths_section83_design_doc_mentioned_but_not_in_code_not_counted() {
+        // §8.3 曾列 /api/dimension/overview、/api/dimension/radar-data,
+        // 但现网代码不存在,明确不纳入统计(避免产生永不命中的统计行)。
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/api/dimension/overview"));
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/api/dimension/radar-data"));
+    }
+
+    @Test
+    void unrelated_paths_not_matched() {
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/api/membership/..."));
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/api/health/main"));
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/api/v1/..."));
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath("/non-api/..."));
+    }
+
+    @Test
+    void null_or_empty_uri_not_matched() {
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath(null));
+        assertFalse(LegacyDimensionCallFilter.matchesLegacyPath(""));
+    }
+
+    @Test
+    void prefixes_count_and_contents_single_source_of_truth() {
+        // 守护契约:确保前缀列表稳定,不因重命名静默丢失某接口
+        java.util.List<String> prefixes = LegacyDimensionCallFilter.LEGACY_PREFIXES;
+        assertEquals(4, prefixes.size(), "待统计前缀应与现网 Controller 数一致(energy + 3 个 admin 子集)");
+        assertTrue(prefixes.contains("/api/energy/"));
+        assertTrue(prefixes.contains("/api/admin/energy-config/"));
+        assertTrue(prefixes.contains("/api/admin/energy-rule/"));
+        assertTrue(prefixes.contains("/api/admin/energy-behavior/"));
+    }
+}

+ 116 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/LegacyDimensionCallLogServiceTest.java

@@ -0,0 +1,116 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.mapper.LegacyDimensionCallLogMapper;
+import com.etotem.cfc.service.LegacyDimensionCallLogService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.sql.Date;
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * LegacyDimensionCallLogService 纯单元测试(无 Spring 上下文、无 DB)。
+ *
+ * <p>覆盖:</p>
+ * <ul>
+ *   <li>{@code recordCall(path)}:调用 mapper.upsert(path, today);null/空 path 不写</li>
+ *   <li>路径超长截断至 100 字符(VARCHAR(100))</li>
+ *   <li>{@code heartbeat()}:仅在采集开关开启时写入 {@code __heartbeat__},关闭时零开销</li>
+ *   <li>打点失败吞异常,不阻断</li>
+ * </ul>
+ */
+class LegacyDimensionCallLogServiceTest {
+
+    @Mock
+    private LegacyDimensionCallLogMapper legacyDimensionCallLogMapper;
+
+    private LegacyDimensionCallLogService service;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new LegacyDimensionCallLogService();
+        setField(service, "legacyDimensionCallLogMapper", legacyDimensionCallLogMapper);
+        setField(service, "legacyCallLogEnabled", true);
+    }
+
+    @Test
+    void recordCall_正常路径_upsert被调用且period等于今天() {
+        String uri = "/api/energy/sandbox";
+        service.recordCall(uri);
+        // 说明:Service 内部用 new Date(System.currentTimeMillis()),与 Date.valueOf(LocalDate.now())
+        // 虽显示为同一天,但 java.sql.Date.equals 按内部毫秒比较,因此改用语义匹配(仅比较日期部分)。
+        verify(legacyDimensionCallLogMapper).upsert(eq(uri), argThat(d -> d != null && d.toLocalDate().equals(LocalDate.now())));
+    }
+
+    @Test
+    void recordCall_null或空path_不调用mapper() {
+        service.recordCall(null);
+        verifyNoInteractions(legacyDimensionCallLogMapper);
+        service.recordCall("");
+        verifyNoInteractions(legacyDimensionCallLogMapper);
+    }
+
+    @Test
+    void recordCall_path超长_自动截断至100字符() {
+        // Java 8 兼容:不用 String.repeat
+        StringBuilder sb = new StringBuilder("/api/admin/energy-config/save-dimension-ratio");
+        while (sb.length() <= 100) {
+            sb.append('x');
+        }
+        String longPath = sb.toString();
+        service.recordCall(longPath);
+        ArgumentCaptor<String> pathCaptor = ArgumentCaptor.forClass(String.class);
+        verify(legacyDimensionCallLogMapper).upsert(pathCaptor.capture(), any());
+        assertEquals(100, pathCaptor.getValue().length(), "超长路径应被截断至 100");
+    }
+
+    @Test
+    void recordCall_mapper抛异常_吞掉不影响调用方() {
+        doThrow(new RuntimeException("DB is down")).when(legacyDimensionCallLogMapper).upsert(any(), any());
+        // 不应抛出
+        assertDoesNotThrow(() -> service.recordCall("/api/energy/grant"));
+        // 但仍会尝试一次 upsert(只是内部被吞)
+        verify(legacyDimensionCallLogMapper, times(1)).upsert(eq("/api/energy/grant"), any());
+    }
+
+    @Test
+    void heartbeat_开启时写入__heartbeat__并today() {
+        service.heartbeat();
+        ArgumentCaptor<String> pathCaptor = ArgumentCaptor.forClass(String.class);
+        verify(legacyDimensionCallLogMapper).upsert(pathCaptor.capture(), any(Date.class));
+        assertEquals(LegacyDimensionCallLogService.HEARTBEAT_PATH, pathCaptor.getValue());
+    }
+
+    @Test
+    void heartbeat_关闭时零开销不写() throws Exception {
+        setField(service, "legacyCallLogEnabled", false);
+        service.heartbeat();
+        verifyNoInteractions(legacyDimensionCallLogMapper);
+    }
+
+    @Test
+    void HEARTBEAT_PATH_const_nonEmpty() {
+        assertNotNull(LegacyDimensionCallLogService.HEARTBEAT_PATH);
+        assertFalse(LegacyDimensionCallLogService.HEARTBEAT_PATH.isEmpty());
+    }
+
+    /** 反射注入字段,避免 Mockito mock 的字段名依赖问题。 */
+    private static void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private static Date today() {
+        return Date.valueOf(LocalDate.now());
+    }
+}

+ 198 - 0
docs/scripts/legacy_dimension_zero_call_check.py

@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""旧体系接口连续 N 天零调用巡检(统一维度体系 P2-T3 退役证据判定)。
+
+对应设计文档 2026-11-03-unified-dimension-system-design.md §8.3 退役触发条件
+「旧接口连续 14 天零调用 + 双写一致性 0 差异」。本脚本只判定「零调用」这一半证据。
+
+用法(Git Bash / 任意 Python3 环境):
+    python3 legacy_dimension_zero_call_check.py --days 14 \
+        --host 127.0.0.1 --port 3306 --db cfc_test --user root --password xxx
+    或通过环境变量传入连接参数(无需命令行硬编码生产连接串):
+        DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD
+
+判定:
+    PASS  - 近 N 天每天都有 __heartbeat__ 心跳 且 业务路径调用总数=0
+           → 连续 N 天零调用,可退役(输出零调用观察窗口起止日期)
+    FAIL  - 有心跳但业务调用>0 → 未满足退役条件,列出剩余调用路径与日期
+    WARN  - 无心跳(或心跳未覆盖整个观察窗口)→ 采集器未运行 / flag 未开启,
+            检查 DIM_REGISTRY_LEGACY_CALL_LOG_ENABLED 是否置 true
+
+依赖:pymysql(pip install pymysql)。采集表 legacy_dimension_call_log 由后端
+DatabaseInitializer 迁移454 创建,__heartbeat__ 心跳行证明采集器当天存活,
+防止「表空 = 假零调用」误判。
+"""
+
+import argparse
+import datetime
+import os
+import sys
+
+HEARTBEAT = "__heartbeat__"
+
+
+def parse_args():
+    env = os.environ
+    p = argparse.ArgumentParser(
+        description="旧体系接口连续 N 天零调用巡检(P2-T3 退役证据判定)")
+    p.add_argument("--days", type=int, default=14,
+                   help="观察窗口天数(默认 14)")
+    p.add_argument("--host", default=env.get("DB_HOST", "127.0.0.1"),
+                   help="数据库主机(默认 env DB_HOST / 127.0.0.1)")
+    p.add_argument("--port", type=int, default=int(env.get("DB_PORT", "3306")),
+                   help="数据库端口(默认 env DB_PORT / 3306)")
+    p.add_argument("--db", default=env.get("DB_NAME", "cfc"),
+                   help="数据库名(默认 env DB_NAME / cfc)")
+    p.add_argument("--user", default=env.get("DB_USER", "root"),
+                   help="数据库用户(默认 env DB_USER / root)")
+    p.add_argument("--password", default=env.get("DB_PASSWORD", ""),
+                   help="数据库密码(默认 env DB_PASSWORD / 空)")
+    return p.parse_args()
+
+
+def connect(params):
+    try:
+        import pymysql
+    except ImportError:
+        sys.exit("缺少 pymysql 依赖,请先执行:pip install pymysql")
+    conn = pymysql.connect(
+        host=params.host, port=params.port, user=params.user,
+        password=params.password, database=params.db, charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor, autocommit=True)
+    return conn
+
+
+def load_rows(conn, days):
+    """返回近 N 天 (period, api_path, call_count) 列表(period 为 ISO 字符串)。"""
+    with conn.cursor() as cur:
+        cur.execute(
+            "SELECT api_path, period, call_count FROM legacy_dimension_call_log "
+            "WHERE period >= DATE_SUB(CURDATE(), INTERVAL %s DAY) "
+            "ORDER BY period ASC, api_path ASC",
+            (days,))
+        rows = cur.fetchall()
+    out = []
+    for r in rows:
+        out.append({
+            "period": r["period"].isoformat() if hasattr(r["period"], "isoformat") else str(r["period"]),
+            "api_path": r["api_path"],
+            "call_count": int(r["call_count"] or 0),
+        })
+    return out
+
+
+def window_dates(days, today=None):
+    """观察窗口日期(升序,含今天),返回 ISO 字符串列表,长度=days。"""
+    if today is None:
+        today = datetime.date.today()
+    return [(today - datetime.timedelta(days=days - 1 - i)).isoformat()
+            for i in range(days)]
+
+
+def judge(rows, days, today=None):
+    """纯函数判定(无 DB),返回 (verdict, detail dict)。
+
+    detail:
+      verdict       PASS / FAIL / WARN
+      window_dates  [iso...]
+      heartbeat_dates  {iso: True}
+      business_by_date {iso: {path: count}}
+      business_total  int
+      missing_heartbeat [iso...]  窗口内无心跳的日期
+      zero_start / zero_end  连续零调用起止(PASS 时 = 窗口起止)
+      remaining  [(path, iso, count)]  FAIL 时剩余调用
+    """
+    dates = window_dates(days, today)
+    heartbeat_dates = {r["period"] for r in rows if r["api_path"] == HEARTBEAT}
+    business_by_date = {}
+    business_total = 0
+    remaining = []
+    for r in rows:
+        if r["api_path"] == HEARTBEAT:
+            continue
+        business_by_date.setdefault(r["period"], {})
+        business_by_date[r["period"]][r["api_path"]] = (
+            business_by_date[r["period"]].get(r["api_path"], 0) + r["call_count"])
+        business_total += r["call_count"]
+        remaining.append((r["api_path"], r["period"], r["call_count"]))
+
+    missing_heartbeat = [d for d in dates if d not in heartbeat_dates]
+    detail = {
+        "verdict": None,
+        "window_dates": dates,
+        "heartbeat_dates": heartbeat_dates,
+        "business_by_date": business_by_date,
+        "business_total": business_total,
+        "missing_heartbeat": missing_heartbeat,
+        "zero_start": dates[0] if dates else None,
+        "zero_end": dates[-1] if dates else None,
+        "remaining": remaining,
+    }
+
+    if missing_heartbeat:
+        # 心跳未覆盖整个窗口 → 无法证明「采集器全程活着」,不轻下 PASS
+        detail["verdict"] = "WARN"
+    elif business_total == 0:
+        detail["verdict"] = "PASS"
+    else:
+        detail["verdict"] = "FAIL"
+    return detail["verdict"], detail
+
+
+def render_matrix(detail):
+    """输出 日期×接口路径 调用矩阵(表格文本)。"""
+    lines = []
+    lines.append("日期         | 心跳 | 业务调用路径(当日次数)")
+    lines.append("-" * 72)
+    for d in detail["window_dates"][::-1]:  # 最新在上
+        hb = "有" if d in detail["heartbeat_dates"] else "无"
+        biz = detail["business_by_date"].get(d, {})
+        if biz:
+            parts = ", ".join("%s×%d" % (p, c) for p, c in sorted(biz.items()))
+        else:
+            parts = "-"
+        lines.append("%s |  %s  | %s" % (d, hb, parts))
+    return "\n".join(lines)
+
+
+def render_verdict(verdict, detail):
+    if verdict == "PASS":
+        return (
+            "判定:PASS —— 连续 %d 天零调用,可退役\n"
+            "零调用观察窗口:%s 至 %s(窗口内每天有心跳,业务调用总数=0)"
+            % (len(detail["window_dates"]), detail["zero_start"], detail["zero_end"]))
+    if verdict == "FAIL":
+        rem = "\n".join("  %s  %s  x%d" % (p, d, c)
+                        for p, d, c in detail["remaining"])
+        return (
+            "判定:FAIL —— 仍有业务调用,未满足退役条件\n"
+            "剩余调用路径与日期:\n%s" % rem)
+    return (
+        "判定:WARN —— 采集证据不足,不能判定可退役\n"
+        "缺失心跳日期(%d 天):%s\n"
+        "检查项:\n"
+        "  1) 采集开关 dimension.registry.legacyCallLog.enabled "
+        "(环境变量 DIM_REGISTRY_LEGACY_CALL_LOG_ENABLED)是否置 true;\n"
+        "  2) 采集服务 LegacyDimensionCallLogService 每小时心跳是否正常写入;\n"
+        "  3) 若表完全为空:采集器未运行 / 采集开关未开启(默认 false 零开销)。"
+        % (len(detail["missing_heartbeat"]), ", ".join(detail["missing_heartbeat"])))
+
+
+def main():
+    args = parse_args()
+    conn = connect(args)
+    try:
+        rows = load_rows(conn, args.days)
+    finally:
+        conn.close()
+
+    verdict, detail = judge(rows, args.days)
+    print("=== 旧体系接口调用矩阵(近 %d 天)===" % args.days)
+    print(render_matrix(detail))
+    print()
+    print(render_verdict(verdict, detail))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())