# -*- coding: utf-8 -*-
r"""HTTP 契约复核门(P11-A):把"重构前的接口行为"变成可自动复核的判据。
规则(比结构与可达性,不比数据值):
· 方法一致:契约登记 GET/POST,实测必须 200;
· 状态码一致:200 ⇒ 必须 200;
· JSON 顶层键:契约登记的键**必须都在**(允许新增键 —— 但少一个就是回归);
· HTML:`
` 与契约一致(页面呈现的锚点之一)。
用法:
python scripts/http_contract_audit.py # 复核(要求服务在跑)
python scripts/http_contract_audit.py --json # 机读输出
退出码:0 全一致 · 9 有不一致 · 2 契约文件缺失 · 3 服务不可达。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
import urllib.error
import urllib.request
try:
from app_common.app_common_guanlan.api import install_root as _install_root
except ImportError: # 包结构异常时按位置上跳
from pathlib import Path as _P
def _install_root(_f): return _P(_f).resolve().parents[4]
ROOT = _install_root(__file__)
CONTRACT = ROOT / 'app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json'
def _svc_port(key: str, default: int) -> int:
"""端口从 configs/serve.json 现取(与"端口只在这里改"的纪律一致);可用环境变量覆盖。"""
import os
env = os.environ.get('GUANLAN_%s_PORT' % key.upper())
if env:
return int(env)
try:
from app_common.app_common_guanlan.api import paths as _P
cfg = json.loads(_P.config('serve.json').read_text(encoding='utf-8-sig'))
return int(cfg.get(key, default))
except Exception: # noqa: BLE001
return default
_GW = _svc_port('gateway_java', _svc_port('gateway', 28084)) # 入口(Java 网关)
_JAVA = _svc_port('java', 28120) # Java 业务后端
BASES = {'gateway': 'http://127.0.0.1:%d' % _GW, 'frontend': 'http://127.0.0.1:%d' % _GW,
# ★P13(2026-10-01):旧 detail 服务(18033)已按用户令退役,契约端点改由 Java 后端承担
'detail_api': 'http://127.0.0.1:%d' % _JAVA,
'detail_page': 'http://127.0.0.1:%d' % _GW,
'cms': 'http://127.0.0.1:%d' % _GW, 'algorithm': 'http://127.0.0.1:%d' % _svc_port('algorithm', 18050)}
SLOW = ('/api/curves', '/api/fleet', '/api/rpt_compose', '/api/snapshot')
def probe(url: str, method: str, timeout: int) -> dict:
data = b'{}' if method == 'POST' else None
try:
req = urllib.request.Request(url, data=data, method=method,
headers={'Content-Type': 'application/json',
'User-Agent': 'guanlan-http-contract-audit'})
with urllib.request.urlopen(req, timeout=timeout) as r:
body = r.read(600_000)
return dict(status=r.status, ctype=r.headers.get('content-type', ''), body=body)
except urllib.error.HTTPError as e:
return dict(status=e.code, ctype='', body=b'')
except Exception as e: # noqa: BLE001
return dict(status=0, ctype='', body=b'', note=type(e).__name__)
def audit(as_json: bool = False) -> int:
if not CONTRACT.is_file():
print('[X] 缺契约正本', CONTRACT.relative_to(ROOT).as_posix())
return 2
c = json.loads(CONTRACT.read_text(encoding='utf-8'))
rows, bad, unreachable = [], [], 0
for e in c['endpoints']:
url = BASES.get(e['kind'], '') + e['path']
to = 120 if any(s in e['path'] for s in SLOW) else 60
got = probe(url, e['method'], to)
if got['status'] == 0:
unreachable += 1
rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=0,
ok=False, why='不可达(%s)' % got.get('note', '')))
continue
ok, why = True, ''
if got['status'] != e['status']:
ok, why = False, '状态码 %s ≠ %s' % (got['status'], e['status'])
elif e.get('json_keys_any'):
try:
o = json.loads(got['body'].decode('utf-8', 'replace'))
except Exception: # noqa: BLE001
o = None
if not isinstance(o, dict):
ok, why = False, '响应不是 JSON 对象'
else:
sets = [set(s) for s in e['json_keys_any']]
if not any(s <= set(o.keys()) for s in sets):
ok, why = False, ('响应键集不在契约允许的任一种里(实测: %s)'
% ','.join(sorted(o.keys())[:8]))
elif e.get('json_keys'):
try:
o = json.loads(got['body'].decode('utf-8', 'replace'))
except Exception: # noqa: BLE001
o = None
if not isinstance(o, dict):
ok, why = False, '响应不是 JSON 对象'
else:
miss = [k for k in e['json_keys'] if k not in o]
if miss:
ok, why = False, '缺顶层键: ' + ','.join(miss[:6])
elif e.get('html_title'):
m = re.search(r'(.*?)', got['body'].decode('utf-8', 'replace'), re.S)
title = (m.group(1).strip() if m else '')
if title != e['html_title']:
ok, why = False, '标题 %r ≠ 契约 %r' % (title[:40], e['html_title'][:40])
rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=got['status'],
ok=ok, why=why))
if not ok:
bad.append(rows[-1])
if as_json:
print(json.dumps(dict(total=len(rows), bad=len(bad), unreachable=unreachable, rows=rows),
ensure_ascii=False, indent=1))
else:
print('== HTTP 契约复核(%d 条端点)==' % len(rows))
for r in rows:
if not r['ok']:
print(' [X] %-42s %-4s 实测 %s %s' % (r['path'], r['method'], r['got'], r['why']))
print(' 一致 %d · 不一致 %d · 不可达 %d' % (len(rows) - len(bad), len(bad), unreachable))
print('结论:', '全部与重构前一致' if not bad else '存在不一致(见上)')
if bad and len(bad) == unreachable:
return 3
return 0 if not bad else 9
def main() -> int:
ap = argparse.ArgumentParser(description='HTTP 契约复核门(重构前行为对拍)')
ap.add_argument('--json', action='store_true')
a = ap.parse_args()
return audit(a.json)
if __name__ == '__main__':
sys.exit(main())