legacy_dimension_zero_call_check.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """旧体系接口连续 N 天零调用巡检(统一维度体系 P2-T3 退役证据判定)。
  4. 对应设计文档 2026-11-03-unified-dimension-system-design.md §8.3 退役触发条件
  5. 「旧接口连续 14 天零调用 + 双写一致性 0 差异」。本脚本只判定「零调用」这一半证据。
  6. 用法(Git Bash / 任意 Python3 环境):
  7. python3 legacy_dimension_zero_call_check.py --days 14 \
  8. --host 127.0.0.1 --port 3306 --db cfc_test --user root --password xxx
  9. 或通过环境变量传入连接参数(无需命令行硬编码生产连接串):
  10. DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD
  11. 判定:
  12. PASS - 近 N 天每天都有 __heartbeat__ 心跳 且 业务路径调用总数=0
  13. → 连续 N 天零调用,可退役(输出零调用观察窗口起止日期)
  14. FAIL - 有心跳但业务调用>0 → 未满足退役条件,列出剩余调用路径与日期
  15. WARN - 无心跳(或心跳未覆盖整个观察窗口)→ 采集器未运行 / flag 未开启,
  16. 检查 DIM_REGISTRY_LEGACY_CALL_LOG_ENABLED 是否置 true
  17. 依赖:pymysql(pip install pymysql)。采集表 legacy_dimension_call_log 由后端
  18. DatabaseInitializer 迁移454 创建,__heartbeat__ 心跳行证明采集器当天存活,
  19. 防止「表空 = 假零调用」误判。
  20. """
  21. import argparse
  22. import datetime
  23. import os
  24. import sys
  25. HEARTBEAT = "__heartbeat__"
  26. def parse_args():
  27. env = os.environ
  28. p = argparse.ArgumentParser(
  29. description="旧体系接口连续 N 天零调用巡检(P2-T3 退役证据判定)")
  30. p.add_argument("--days", type=int, default=14,
  31. help="观察窗口天数(默认 14)")
  32. p.add_argument("--host", default=env.get("DB_HOST", "127.0.0.1"),
  33. help="数据库主机(默认 env DB_HOST / 127.0.0.1)")
  34. p.add_argument("--port", type=int, default=int(env.get("DB_PORT", "3306")),
  35. help="数据库端口(默认 env DB_PORT / 3306)")
  36. p.add_argument("--db", default=env.get("DB_NAME", "cfc"),
  37. help="数据库名(默认 env DB_NAME / cfc)")
  38. p.add_argument("--user", default=env.get("DB_USER", "root"),
  39. help="数据库用户(默认 env DB_USER / root)")
  40. p.add_argument("--password", default=env.get("DB_PASSWORD", ""),
  41. help="数据库密码(默认 env DB_PASSWORD / 空)")
  42. return p.parse_args()
  43. def connect(params):
  44. try:
  45. import pymysql
  46. except ImportError:
  47. sys.exit("缺少 pymysql 依赖,请先执行:pip install pymysql")
  48. conn = pymysql.connect(
  49. host=params.host, port=params.port, user=params.user,
  50. password=params.password, database=params.db, charset="utf8mb4",
  51. cursorclass=pymysql.cursors.DictCursor, autocommit=True)
  52. return conn
  53. def load_rows(conn, days):
  54. """返回近 N 天 (period, api_path, call_count) 列表(period 为 ISO 字符串)。"""
  55. with conn.cursor() as cur:
  56. cur.execute(
  57. "SELECT api_path, period, call_count FROM legacy_dimension_call_log "
  58. "WHERE period >= DATE_SUB(CURDATE(), INTERVAL %s DAY) "
  59. "ORDER BY period ASC, api_path ASC",
  60. (days,))
  61. rows = cur.fetchall()
  62. out = []
  63. for r in rows:
  64. out.append({
  65. "period": r["period"].isoformat() if hasattr(r["period"], "isoformat") else str(r["period"]),
  66. "api_path": r["api_path"],
  67. "call_count": int(r["call_count"] or 0),
  68. })
  69. return out
  70. def window_dates(days, today=None):
  71. """观察窗口日期(升序,含今天),返回 ISO 字符串列表,长度=days。"""
  72. if today is None:
  73. today = datetime.date.today()
  74. return [(today - datetime.timedelta(days=days - 1 - i)).isoformat()
  75. for i in range(days)]
  76. def judge(rows, days, today=None):
  77. """纯函数判定(无 DB),返回 (verdict, detail dict)。
  78. detail:
  79. verdict PASS / FAIL / WARN
  80. window_dates [iso...]
  81. heartbeat_dates {iso: True}
  82. business_by_date {iso: {path: count}}
  83. business_total int
  84. missing_heartbeat [iso...] 窗口内无心跳的日期
  85. zero_start / zero_end 连续零调用起止(PASS 时 = 窗口起止)
  86. remaining [(path, iso, count)] FAIL 时剩余调用
  87. """
  88. dates = window_dates(days, today)
  89. heartbeat_dates = {r["period"] for r in rows if r["api_path"] == HEARTBEAT}
  90. business_by_date = {}
  91. business_total = 0
  92. remaining = []
  93. for r in rows:
  94. if r["api_path"] == HEARTBEAT:
  95. continue
  96. business_by_date.setdefault(r["period"], {})
  97. business_by_date[r["period"]][r["api_path"]] = (
  98. business_by_date[r["period"]].get(r["api_path"], 0) + r["call_count"])
  99. business_total += r["call_count"]
  100. remaining.append((r["api_path"], r["period"], r["call_count"]))
  101. missing_heartbeat = [d for d in dates if d not in heartbeat_dates]
  102. detail = {
  103. "verdict": None,
  104. "window_dates": dates,
  105. "heartbeat_dates": heartbeat_dates,
  106. "business_by_date": business_by_date,
  107. "business_total": business_total,
  108. "missing_heartbeat": missing_heartbeat,
  109. "zero_start": dates[0] if dates else None,
  110. "zero_end": dates[-1] if dates else None,
  111. "remaining": remaining,
  112. }
  113. if missing_heartbeat:
  114. # 心跳未覆盖整个窗口 → 无法证明「采集器全程活着」,不轻下 PASS
  115. detail["verdict"] = "WARN"
  116. elif business_total == 0:
  117. detail["verdict"] = "PASS"
  118. else:
  119. detail["verdict"] = "FAIL"
  120. return detail["verdict"], detail
  121. def render_matrix(detail):
  122. """输出 日期×接口路径 调用矩阵(表格文本)。"""
  123. lines = []
  124. lines.append("日期 | 心跳 | 业务调用路径(当日次数)")
  125. lines.append("-" * 72)
  126. for d in detail["window_dates"][::-1]: # 最新在上
  127. hb = "有" if d in detail["heartbeat_dates"] else "无"
  128. biz = detail["business_by_date"].get(d, {})
  129. if biz:
  130. parts = ", ".join("%s×%d" % (p, c) for p, c in sorted(biz.items()))
  131. else:
  132. parts = "-"
  133. lines.append("%s | %s | %s" % (d, hb, parts))
  134. return "\n".join(lines)
  135. def render_verdict(verdict, detail):
  136. if verdict == "PASS":
  137. return (
  138. "判定:PASS —— 连续 %d 天零调用,可退役\n"
  139. "零调用观察窗口:%s 至 %s(窗口内每天有心跳,业务调用总数=0)"
  140. % (len(detail["window_dates"]), detail["zero_start"], detail["zero_end"]))
  141. if verdict == "FAIL":
  142. rem = "\n".join(" %s %s x%d" % (p, d, c)
  143. for p, d, c in detail["remaining"])
  144. return (
  145. "判定:FAIL —— 仍有业务调用,未满足退役条件\n"
  146. "剩余调用路径与日期:\n%s" % rem)
  147. return (
  148. "判定:WARN —— 采集证据不足,不能判定可退役\n"
  149. "缺失心跳日期(%d 天):%s\n"
  150. "检查项:\n"
  151. " 1) 采集开关 dimension.registry.legacyCallLog.enabled "
  152. "(环境变量 DIM_REGISTRY_LEGACY_CALL_LOG_ENABLED)是否置 true;\n"
  153. " 2) 采集服务 LegacyDimensionCallLogService 每小时心跳是否正常写入;\n"
  154. " 3) 若表完全为空:采集器未运行 / 采集开关未开启(默认 false 零开销)。"
  155. % (len(detail["missing_heartbeat"]), ", ".join(detail["missing_heartbeat"])))
  156. def main():
  157. args = parse_args()
  158. conn = connect(args)
  159. try:
  160. rows = load_rows(conn, args.days)
  161. finally:
  162. conn.close()
  163. verdict, detail = judge(rows, args.days)
  164. print("=== 旧体系接口调用矩阵(近 %d 天)===" % args.days)
  165. print(render_matrix(detail))
  166. print()
  167. print(render_verdict(verdict, detail))
  168. return 0
  169. if __name__ == "__main__":
  170. sys.exit(main())