#!/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())