Files
gaokao-volunteer-system/scripts/score_range_fullchain_100_e2e.py

505 lines
18 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import http.client
import json
import os
import subprocess
import sys
import time
import urllib.parse
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
def _rel_or_abs(p: Path) -> str:
"""Return path relative to ROOT if possible, else absolute string."""
try:
return str(p.relative_to(ROOT))
except ValueError:
return str(p)
MANIFEST = ROOT / "reports" / "score_range_fullchain_100_cases_2026_06_25.json"
# P1-7 修复:默认回归产物写入 /tmpgitignored不覆写受版本控制的 reports/
OUT = Path("/tmp/score_range_fullchain_100_e2e.json")
BATCH_JSON = Path("/tmp/score_range_fullchain_100_batches.json")
BATCH_CSV = Path("/tmp/score_range_fullchain_100_batches.csv")
LOG = Path("/tmp/score-range-fullchain-100-e2e.log")
BASE_HOST = "127.0.0.1"
BASE_PORT = 19110
SMOKE_CASE_IDS = {2, 7, 12, 14, 20, 29, 31, 37, 44, 54, 83, 98}
def load_manifest(path: Path = MANIFEST) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def build_batch_plan(cases: list[dict[str, Any]]) -> dict[str, Any]:
planned: list[dict[str, Any]] = []
for case in cases:
row = dict(case)
batches: list[str] = []
if row["id"] in SMOKE_CASE_IDS:
batches.append("smoke")
if row["expected_path"] == "fullchain":
batches.append("fullchain")
else:
batches.append("boundary")
row["batches"] = batches
planned.append(row)
return {
"summary": {
"case_count": len(planned),
"smoke_case_count": sum(1 for row in planned if "smoke" in row["batches"]),
"fullchain_case_count": sum(
1 for row in planned if row["expected_path"] == "fullchain"
),
"boundary_case_count": sum(
1 for row in planned if row["expected_path"] == "contract_boundary"
),
},
"cases": planned,
}
def write_batch_plan(plan: dict[str, Any], *, json_path: Path, csv_path: Path) -> None:
json_path.write_text(
json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
with csv_path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(
fh,
fieldnames=[
"id",
"province",
"score",
"score_band_low",
"score_band_high",
"subjects",
"anchor_major",
"public_supported",
"expected_path",
"batches",
],
)
writer.writeheader()
for row in plan["cases"]:
writer.writerow({
"id": row["id"],
"province": row["province"],
"score": row["score"],
"score_band_low": row["score_band_low"],
"score_band_high": row["score_band_high"],
"subjects": "/".join(row.get("subjects") or []),
"anchor_major": row["anchor_major"],
"public_supported": "yes" if row["public_supported"] else "no",
"expected_path": row["expected_path"],
"batches": "/".join(row["batches"]),
})
def select_cases(cases: list[dict[str, Any]], batch: str) -> list[dict[str, Any]]:
if batch == "all":
return list(cases)
return [row for row in cases if batch in row.get("batches", [])]
def default_output_path(batch: str) -> Path:
if batch == "all":
return OUT
return Path(f"/tmp/score_range_fullchain_100_e2e_{batch}.json")
def request(
method: str,
path: str,
body: bytes | dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
*,
port: int,
) -> tuple[int, dict[str, str], str]:
conn = http.client.HTTPConnection(BASE_HOST, port, timeout=20)
outgoing_headers = {"Connection": "close"}
if headers:
outgoing_headers.update(headers)
payload: bytes | None
if isinstance(body, dict):
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
outgoing_headers.setdefault("Content-Type", "application/json")
else:
payload = body
conn.request(method, path, body=payload, headers=outgoing_headers)
resp = conn.getresponse()
raw_headers = {k.lower(): v for k, v in resp.getheaders()}
text = resp.read().decode("utf-8", "replace")
conn.close()
return resp.status, raw_headers, text
def _start_admin(port: int) -> subprocess.Popen[str]:
# P1-8 修复:启动前检测端口是否已被占用
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind((BASE_HOST, port))
sock.close()
except OSError:
raise RuntimeError(
f"端口 {port} 已被占用,可能存在旧进程。"
f"请先 kill 占用进程或更换端口再运行。"
)
env = os.environ.copy()
env.update({
"GAOKAO_ENV": "dev",
"GAOKAO_DB_PATH": str(ROOT / f"data/orders/fullchain-admin-{port}.db"),
"GAOKAO_ORDERS_DB_PATH": str(ROOT / f"data/orders-fullchain-{port}.db"),
"GAOKAO_SHARE_DB_PATH": str(ROOT / f"data/share-fullchain-{port}.db"),
"GAOKAO_SHARE_REPORT_DIR": str(ROOT / f"data/share-reports-fullchain-{port}"),
"GAOKAO_OPS_ALERT_LOG": str(ROOT / f"data/alerts/fullchain-ops-{port}.jsonl"),
"GAOKAO_RETENTION_DAYS": "180",
"GAOKAO_JWT_SECRET": "x" * 32,
"GAOKAO_PORTAL_TOKEN_SECRET": "y" * 32,
"GAOKAO_ADMIN_USER": "admin",
"GAOKAO_ADMIN_PASS": "FullchainTest1!",
"GAOKAO_PAYMENT_WEBHOOK_SECRET": "P" + "r" * 31 + "!",
"GAOKAO_PAYMENT_PROVIDER": "mock",
"GAOKAO_ORDERS_FERNET_KEY": "F" * 44,
"GAOKAO_ADMIN_BIND": f"127.0.0.1:{port}",
})
for directory in [
ROOT / "data/orders",
ROOT / "data",
ROOT / f"data/share-reports-fullchain-{port}",
ROOT / "data/alerts",
]:
directory.mkdir(parents=True, exist_ok=True)
log = open(LOG, "w", encoding="utf-8")
proc = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"admin.app:create_app",
"--factory",
"--host",
BASE_HOST,
"--port",
str(port),
"--log-level",
"warning",
],
cwd=ROOT,
env=env,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
)
# P1-8 修复:双条件探针(进程存活 + health 200
for _ in range(60):
if proc.poll() is not None:
proc.terminate()
raise RuntimeError(f"admin 进程意外退出 (exit code={proc.returncode})")
try:
status, _, body = request("GET", "/health", port=port)
if status == 200 and body.strip().startswith("{"):
return proc
except Exception:
pass
time.sleep(0.5)
proc.terminate()
raise RuntimeError("admin failed to start")
def _stop_admin(proc: subprocess.Popen[str] | None) -> None:
if proc is None or proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def execute_case(case: dict[str, Any], *, port: int) -> dict[str, Any]:
idx = case["id"]
row: dict[str, Any] = dict(case)
row["sample_no"] = idx
qs = urllib.parse.urlencode({
"source": "home",
"province": case["province"],
"score": str(case["score"]),
"goal": f"{case['province']} {case['score_band_note']} {case['anchor_major']} 复核",
"consult": f"100样例全链路 idx={idx}",
})
st, hd, body = request("GET", f"/review/start?{qs}", port=port)
row["review_start_status"] = st
row["review_start_ok"] = (
st == 200
and ("方案复核入口" in body or "复核结果" in body)
and case["province"] in body
)
if case["expected_path"] == "contract_boundary":
# P1-2 修复:真正 POST /api/public/orders 验证是否被契约阻断
order_payload = {
"service_version": "standard",
"amount_cents": 9900,
"customer_phone": f"13888{idx:06d}",
"candidate_name": f"边界验证{idx}",
"candidate_province": case["province"],
}
st2, hd2, body2 = request(
"POST", "/api/public/orders", order_payload, port=port
)
row["boundary_create_order_status"] = st2
row["boundary_create_order_rejected"] = st2 in (400, 422)
row["boundary_create_order_body"] = body2[:200]
row["status"] = "skipped_public_order_contract_boundary"
row["contract_boundary_ok"] = (
row["review_start_ok"] and row["boundary_create_order_rejected"]
)
return row
order_payload = {
"service_version": "standard",
"amount_cents": 9900,
"customer_phone": f"13888{idx:06d}",
"candidate_name": f"全链路100样例{idx}",
"candidate_province": case["province"],
}
st, hd, body = request("POST", "/api/public/orders", order_payload, port=port)
row["create_order_status"] = st
if st != 201:
row["status"] = "create_order_failed"
row["create_order_body"] = body[:300]
return row
created = json.loads(body)
row["order_id"] = created.get("order_id")
row["checkout_url"] = created.get("checkout_url")
row["portal_status_url"] = created.get("portal_status_url")
payment_id = row["checkout_url"].split("/pay/mock/")[1].split("?")[0]
st, hd, body = request("POST", f"/pay/mock/{payment_id}/complete", port=port)
row["payment_complete_status"] = st
row["payment_complete_location"] = hd.get("location")
row["payment_complete_ok"] = st == 303 and bool(
hd.get("location", "").endswith("/payment-success")
)
if hd.get("location"):
st2, _, b2 = request("GET", hd["location"], port=port)
row["payment_success_status"] = st2
row["payment_success_ok"] = st2 == 200 and (
"支付成功" in b2 or "订单进度总览" in b2 or "已支付" in b2
)
else:
row["payment_success_status"] = None
row["payment_success_ok"] = False
info_path = row["portal_status_url"].replace("/status", "/info")
st, _, body = request("GET", urllib.parse.urlparse(info_path).path, port=port)
row["portal_info_get_status"] = st
row["portal_info_get_ok"] = st == 200 and "考生资料填写" in body
token = row["portal_status_url"].split("/portal/")[1].split("/status")[0]
info_payload = {
"mode": "submit",
"candidate_province": case["province"],
"candidate_subjects": case["subjects"],
"candidate_score": case["score"],
"candidate_rank": case["estimated_rank"],
"existing_plan_summary": f"已有一版方案100样例{idx},先看风险",
"family_background": "家长希望先看梯度是否合理",
"interest_assessment_type": "mbti",
"interest_assessment_result": "INTJ",
"interest_assessment_notes": "仅做辅助",
"consent_version": "portal-v1",
"consent_scope": "step1",
"privacy_accepted": True,
"service_terms_accepted": True,
"guardian_confirmed": True,
}
st, _, body = request("POST", f"/portal/{token}/info", info_payload, port=port)
row["portal_info_submit_status"] = st
row["portal_info_submit_ok"] = st in (200, 201)
if st not in (200, 201):
row["portal_info_submit_body"] = body[:300]
st, _, body = request(
"GET", urllib.parse.urlparse(row["portal_status_url"]).path, port=port
)
row["portal_status_status"] = st
row["portal_status_ok"] = st == 200 and "订单进度总览" in body
st, _, body = request(
"GET",
f"/review/start?source=status&token={urllib.parse.quote(token)}",
port=port,
)
row["token_review_start_status"] = st
row["token_review_start_ok"] = (
st == 200
and ("方案复核入口" in body or "复核结果" in body)
and case["province"] in body
)
form = urllib.parse.urlencode({"token": token, "action": "full_plan"})
st, hd, body = request(
"POST",
"/review/action",
form.encode(),
{"Content-Type": "application/x-www-form-urlencoded"},
port=port,
)
row["review_action_full_plan_status"] = st
row["review_action_full_plan_location"] = hd.get("location")
if hd.get("location"):
st2, _, b2 = request("GET", hd["location"], port=port)
row["full_plan_status"] = st2
row["full_plan_ok"] = (
st2 == 200 and "完整规划建议页" in b2 and "方案优先级" in b2
)
else:
row["full_plan_status"] = None
row["full_plan_ok"] = False
form = urllib.parse.urlencode({"token": token, "action": "cwb"})
st, hd, body = request(
"POST",
"/review/action",
form.encode(),
{"Content-Type": "application/x-www-form-urlencoded"},
port=port,
)
row["review_action_cwb_status"] = st
row["review_action_cwb_location"] = hd.get("location")
if hd.get("location"):
st2, _, b2 = request("GET", hd["location"], port=port)
row["cwb_status"] = st2
row["cwb_ok"] = (
st2 == 200
and "冲稳保建议页" in b2
and "冲刺建议" in b2
and "稳妥建议" in b2
and "保底建议" in b2
)
else:
row["cwb_status"] = None
row["cwb_ok"] = False
row["status"] = (
"ok"
if all([
row["review_start_ok"],
row["payment_complete_ok"],
row["payment_success_ok"],
row["portal_info_get_ok"],
row["portal_info_submit_ok"],
row["portal_status_ok"],
row["token_review_start_ok"],
row["full_plan_ok"],
row["cwb_ok"],
])
else "failed"
)
return row
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="100 个高考分数真实全链路验证")
parser.add_argument(
"--batch", choices=["all", "smoke", "fullchain", "boundary"], default="all"
)
parser.add_argument("--manifest", type=Path, default=MANIFEST)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--batch-json", type=Path, default=BATCH_JSON)
parser.add_argument("--batch-csv", type=Path, default=BATCH_CSV)
parser.add_argument("--port", type=int, default=BASE_PORT)
return parser.parse_args()
def main() -> int:
args = parse_args()
output_path = args.output or default_output_path(args.batch)
manifest = load_manifest(args.manifest)
plan = build_batch_plan(manifest["cases"])
write_batch_plan(plan, json_path=args.batch_json, csv_path=args.batch_csv)
selected = select_cases(plan["cases"], args.batch)
print(
f"[fullchain100] batch={args.batch} selected={len(selected)} port={args.port}"
)
proc = _start_admin(args.port)
try:
results = []
for case in selected:
row = execute_case(case, port=args.port)
results.append(row)
print(
f" [{'OK' if row['status'] in ('ok', 'skipped_public_order_contract_boundary') else 'FAIL'}] "
f"#{row['id']:03d} {row['province']} {row['score']} -> {row['status']}"
)
finally:
_stop_admin(proc)
review_start_pass = sum(1 for row in results if row.get("review_start_ok"))
eligible = [row for row in results if row["expected_path"] == "fullchain"]
passed = [row for row in eligible if row.get("status") == "ok"]
failed = [
row
for row in results
if row.get("status") not in ("ok", "skipped_public_order_contract_boundary")
]
boundary = [row for row in results if row["expected_path"] == "contract_boundary"]
boundary_pass = [
row
for row in boundary
if row.get("status") == "skipped_public_order_contract_boundary"
and row.get("contract_boundary_ok")
]
full_plan_pass = sum(1 for row in eligible if row.get("full_plan_ok"))
cwb_pass = sum(1 for row in eligible if row.get("cwb_ok"))
summary = {
"base_url": f"http://{BASE_HOST}:{args.port}",
"selected_batch": args.batch,
"selected_case_count": len(results),
"review_start_pass": review_start_pass,
"review_start_fail": len(results) - review_start_pass,
"eligible_for_fullchain": len(eligible),
"fullchain_pass": len(passed),
"fullchain_fail": len(eligible) - len(passed),
"contract_boundary_expected": len(boundary),
"contract_boundary_pass": len(boundary_pass),
"contract_boundary_fail": len(boundary) - len(boundary_pass),
"full_plan_pass": full_plan_pass,
"cwb_pass": cwb_pass,
"failed_samples": failed,
"samples": results,
"batch_plan_json": _rel_or_abs(args.batch_json),
"batch_plan_csv": _rel_or_abs(args.batch_csv),
"note": "fullchain=公开支持省份完整下单链路contract_boundary=非公开支持省份仅验证复核入口与合同边界。",
}
output_path.write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"wrote {output_path}")
print(f"review_start_pass={review_start_pass}/{len(results)}")
print(f"eligible_for_fullchain={len(eligible)}")
print(f"fullchain_pass={len(passed)}/{len(eligible)}")
print(f"contract_boundary_pass={len(boundary_pass)}/{len(boundary)}")
print(f"full_plan_pass={full_plan_pass}/{len(eligible)}")
print(f"cwb_pass={cwb_pass}/{len(eligible)}")
return 0 if not failed and len(boundary_pass) == len(boundary) else 1
if __name__ == "__main__":
raise SystemExit(main())