http_contract_audit.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. # -*- coding: utf-8 -*-
  2. r"""HTTP 契约复核门(P11-A):把"重构前的接口行为"变成可自动复核的判据。
  3. 规则(比结构与可达性,不比数据值):
  4. · 方法一致:契约登记 GET/POST,实测必须 200;
  5. · 状态码一致:200 ⇒ 必须 200;
  6. · JSON 顶层键:契约登记的键**必须都在**(允许新增键 —— 但少一个就是回归);
  7. · HTML:`<title>` 与契约一致(页面呈现的锚点之一)。
  8. 用法:
  9. python scripts/http_contract_audit.py # 复核(要求服务在跑)
  10. python scripts/http_contract_audit.py --json # 机读输出
  11. 退出码:0 全一致 · 9 有不一致 · 2 契约文件缺失 · 3 服务不可达。
  12. """
  13. from __future__ import annotations
  14. import argparse
  15. import json
  16. import pathlib
  17. import re
  18. import sys
  19. import urllib.error
  20. import urllib.request
  21. try:
  22. from app_common.app_common_guanlan.api import install_root as _install_root
  23. except ImportError: # 包结构异常时按位置上跳
  24. from pathlib import Path as _P
  25. def _install_root(_f): return _P(_f).resolve().parents[4]
  26. ROOT = _install_root(__file__)
  27. CONTRACT = ROOT / 'app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json'
  28. def _svc_port(key: str, default: int) -> int:
  29. """端口从 configs/serve.json 现取(与"端口只在这里改"的纪律一致);可用环境变量覆盖。"""
  30. import os
  31. env = os.environ.get('GUANLAN_%s_PORT' % key.upper())
  32. if env:
  33. return int(env)
  34. try:
  35. from app_common.app_common_guanlan.api import paths as _P
  36. cfg = json.loads(_P.config('serve.json').read_text(encoding='utf-8-sig'))
  37. return int(cfg.get(key, default))
  38. except Exception: # noqa: BLE001
  39. return default
  40. _GW = _svc_port('gateway_java', _svc_port('gateway', 28084)) # 入口(Java 网关)
  41. _JAVA = _svc_port('java', 28120) # Java 业务后端
  42. BASES = {'gateway': 'http://127.0.0.1:%d' % _GW, 'frontend': 'http://127.0.0.1:%d' % _GW,
  43. # ★P13(2026-10-01):旧 detail 服务(18033)已按用户令退役,契约端点改由 Java 后端承担
  44. 'detail_api': 'http://127.0.0.1:%d' % _JAVA,
  45. 'detail_page': 'http://127.0.0.1:%d' % _GW,
  46. 'cms': 'http://127.0.0.1:%d' % _GW, 'algorithm': 'http://127.0.0.1:%d' % _svc_port('algorithm', 18050)}
  47. SLOW = ('/api/curves', '/api/fleet', '/api/rpt_compose', '/api/snapshot')
  48. def probe(url: str, method: str, timeout: int) -> dict:
  49. data = b'{}' if method == 'POST' else None
  50. try:
  51. req = urllib.request.Request(url, data=data, method=method,
  52. headers={'Content-Type': 'application/json',
  53. 'User-Agent': 'guanlan-http-contract-audit'})
  54. with urllib.request.urlopen(req, timeout=timeout) as r:
  55. body = r.read(600_000)
  56. return dict(status=r.status, ctype=r.headers.get('content-type', ''), body=body)
  57. except urllib.error.HTTPError as e:
  58. return dict(status=e.code, ctype='', body=b'')
  59. except Exception as e: # noqa: BLE001
  60. return dict(status=0, ctype='', body=b'', note=type(e).__name__)
  61. def audit(as_json: bool = False) -> int:
  62. if not CONTRACT.is_file():
  63. print('[X] 缺契约正本', CONTRACT.relative_to(ROOT).as_posix())
  64. return 2
  65. c = json.loads(CONTRACT.read_text(encoding='utf-8'))
  66. rows, bad, unreachable, retired = [], [], 0, []
  67. for e in c['endpoints']:
  68. if e.get('retired'):
  69. retired.append(e['path']) # 已登记退役(旧页):不计入红项
  70. continue
  71. url = BASES.get(e['kind'], '') + e['path']
  72. to = 120 if any(s in e['path'] for s in SLOW) else 60
  73. got = probe(url, e['method'], to)
  74. if got['status'] == 0:
  75. unreachable += 1
  76. rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=0,
  77. ok=False, why='不可达(%s)' % got.get('note', '')))
  78. continue
  79. ok, why = True, ''
  80. if got['status'] != e['status']:
  81. ok, why = False, '状态码 %s ≠ %s' % (got['status'], e['status'])
  82. elif e.get('json_keys_any'):
  83. try:
  84. o = json.loads(got['body'].decode('utf-8', 'replace'))
  85. except Exception: # noqa: BLE001
  86. o = None
  87. if not isinstance(o, dict):
  88. ok, why = False, '响应不是 JSON 对象'
  89. else:
  90. sets = [set(s) for s in e['json_keys_any']]
  91. if not any(s <= set(o.keys()) for s in sets):
  92. ok, why = False, ('响应键集不在契约允许的任一种里(实测: %s)'
  93. % ','.join(sorted(o.keys())[:8]))
  94. elif e.get('json_keys'):
  95. try:
  96. o = json.loads(got['body'].decode('utf-8', 'replace'))
  97. except Exception: # noqa: BLE001
  98. o = None
  99. if not isinstance(o, dict):
  100. ok, why = False, '响应不是 JSON 对象'
  101. else:
  102. miss = [k for k in e['json_keys'] if k not in o]
  103. if miss:
  104. ok, why = False, '缺顶层键: ' + ','.join(miss[:6])
  105. elif e.get('html_title'):
  106. m = re.search(r'<title>(.*?)</title>', got['body'].decode('utf-8', 'replace'), re.S)
  107. title = (m.group(1).strip() if m else '')
  108. if title != e['html_title']:
  109. ok, why = False, '标题 %r ≠ 契约 %r' % (title[:40], e['html_title'][:40])
  110. rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=got['status'],
  111. ok=ok, why=why))
  112. if not ok:
  113. bad.append(rows[-1])
  114. if as_json:
  115. print(json.dumps(dict(total=len(rows), bad=len(bad), unreachable=unreachable, rows=rows),
  116. ensure_ascii=False, indent=1))
  117. else:
  118. print('== HTTP 契约复核(%d 条端点)==' % len(rows))
  119. for r in rows:
  120. if not r['ok']:
  121. print(' [X] %-42s %-4s 实测 %s %s' % (r['path'], r['method'], r['got'], r['why']))
  122. print(' 一致 %d · 不一致 %d · 不可达 %d' % (len(rows) - len(bad), len(bad), unreachable))
  123. print('结论:', '全部与重构前一致' if not bad else '存在不一致(见上)')
  124. if bad and len(bad) == unreachable:
  125. return 3
  126. return 0 if not bad else 9
  127. def main() -> int:
  128. ap = argparse.ArgumentParser(description='HTTP 契约复核门(重构前行为对拍)')
  129. ap.add_argument('--json', action='store_true')
  130. a = ap.parse_args()
  131. return audit(a.json)
  132. if __name__ == '__main__':
  133. sys.exit(main())