Files
xiaotutongxue/scripts/verify-all.py
T
lovife 9e78e1d239 feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)
起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
2026-10-08 14:46:11 +08:00

210 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""交付自检:一条命令把"这个仓库现在到底对不对"跑一遍。
## 为什么需要它
这个项目的验证手段有六类,散在六个地方:
| 类别 | 命令 | 需要什么 |
|---|---|---|
| 契约 ↔ 路由 | `check-api-parity.py` | 无 |
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
| 文档自身一致 | `verify-docs.py` | 无 |
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
**要记住"该跑哪几个、哪些需要前置"本身就是一种负担**,而漏跑一个的后果是
"以为过了"。所以这里合成一个入口,并且**如实说明哪些因为前置不足而跳过**
(不是静默跳过 —— 那与 F-85 是同一类问题)。
## 用法
```bash
python scripts/verify-all.py # 全部(跳过需要外部前置的,并说明)
python scripts/verify-all.py --with-e2e # 连 1548 条接口断言一起跑(需要后端在跑)
python scripts/verify-all.py --quick # 只跑不需要任何前置的五项
```
退出码:0 = 全过;1 = 有失败(**跳过不算失败**,但会明确列出来)。
"""
import argparse
import glob
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(ROOT)
GREEN, RED, YELLOW, DIM, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m"
if not sys.stdout.isatty():
GREEN = RED = YELLOW = DIM = RESET = ""
def run(cmd, timeout=3600):
"""跑一条命令,返回 (退出码, 输出)。"""
try:
p = subprocess.run(cmd, shell=True, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except subprocess.TimeoutExpired:
return -9, "超时"
def backend_up():
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/auth/captcha", timeout=5):
return True
except Exception:
return False
def main():
ap = argparse.ArgumentParser(description="交付自检")
ap.add_argument("--with-e2e", action="store_true", help="连 e2e 一起跑(需要后端在跑)")
ap.add_argument("--quick", action="store_true", help="只跑不需要前置的五项")
args = ap.parse_args()
results = [] # (名称, 结果, 摘要)
print("=" * 92)
print(" 交付自检 仓库:%s" % ROOT)
print("=" * 92)
# ── 一、不需要任何前置的机械校验 ──
print("\n【一】机械校验(不需要后端/数据库)")
checks = [
("契约 ↔ 路由", "python scripts/check-api-parity.py --quiet",
r"合计:契约 (\d+) / 已实现 (\d+) / 缺口 (\d+)"),
("实体 ↔ 建表", "python scripts/check-entity-columns.py --quiet",
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
("接线(定义↔使用)", "python scripts/check-wiring.py --quiet",
r"未接线的声明 (\d+) 条"),
]
for name, cmd, pat in checks:
code, out = run(cmd, timeout=300)
line = ""
m = re.search(pat, out)
if m:
line = m.group(0).strip()
else:
tail = [x for x in out.strip().split("\n") if x.strip()]
line = tail[-1][:80] if tail else "(无输出)"
ok = code == 0
results.append((name, ok, line))
print(" %s%-14s%s %s" % (GREEN if ok else RED, name, RESET, line))
# 脚本语法(改脚本时最容易踩:中文串里嵌半角引号)
bad = []
for fn in sorted(glob.glob("scripts/*.py")):
try:
compile(open(fn, encoding="utf-8").read(), fn, "exec")
except SyntaxError as e:
bad.append("%s:%s" % (os.path.basename(fn), e.lineno))
ok = not bad
results.append(("脚本语法", ok, "全部可编译" if ok else "有语法错:%s" % ", ".join(bad)))
print(" %s%-14s%s %s" % (GREEN if ok else RED, "脚本语法", RESET,
"全部可编译" if ok else "有语法错:%s" % ", ".join(bad)))
if args.quick:
return summary(results)
# ── 二、需要后端在跑 / 能起 jar 的 ──
print("\n【二】带前置的检查")
up = backend_up()
if not up:
msg = "**跳过**:后端 8080 没在跑(先 `cd backend && java -jar ... --spring.profiles.active=dev`)"
results.append(("接口断言(E2E)", None, msg))
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
elif not args.with_e2e:
msg = "**跳过**:要跑就加 `--with-e2e`(36 个脚本 / 1500+ 条断言,约 10 分钟)"
results.append(("接口断言(E2E)", None, msg))
print(" %s%-14s%s %s" % (YELLOW, "接口断言", RESET, msg))
else:
t0 = time.time()
files = sorted(glob.glob("scripts/e2e-*.py"))
total = fails = 0
failed_names = []
for f in files:
code, out = run("python %s" % f, timeout=900)
m = re.search(r"通过 (\d+) 失败 (\d+) 共 (\d+)", out)
if m:
total += int(m.group(1))
if int(m.group(2)) > 0:
fails += int(m.group(2))
failed_names.append(os.path.basename(f))
else:
m2 = re.search(r"结果: (\d+)/(\d+)", out)
if m2:
total += int(m2.group(2))
if m2.group(1) != m2.group(2):
fails += 1
failed_names.append(os.path.basename(f))
else:
fails += 1
failed_names.append(os.path.basename(f) + "(无汇总)")
ok = fails == 0
msg = "%d 个脚本 / %d 条断言 / 失败 %d%s(%.0fs)" % (
len(files), total, fails,
"" if ok else ":" + ", ".join(failed_names[:5]), time.time() - t0)
results.append(("接口断言(E2E)", ok, msg))
print(" %s%-14s%s %s" % (GREEN if ok else RED, "接口断言", RESET, msg))
# 生产自检:需要 jar
jar = glob.glob("backend/xt-server/target/xt-server-*.jar")
if not jar:
msg = "**跳过**:没有 jar(先 `cd backend && mvn -DskipTests package`)"
results.append(("生产配置拦截", None, msg))
print(" %s%-14s%s %s" % (YELLOW, "生产配置拦截", RESET, msg))
else:
# 起 jar 的检查会撞端口,所以只在没别的 java 在跑时做 —— 如实说明
code, out = run("python scripts/e2e-prodguard.py", timeout=900)
m = re.search(r"通过 (\d+) 失败 (\d+) 共 (\d+)", out)
ok = code == 0
msg = m.group(0) if m else ("通过" if ok else "失败")
results.append(("生产配置拦截", ok, msg))
print(" %s%-14s%s %s" % (GREEN if ok else RED, "生产配置拦截", RESET, msg))
if up:
code, out = run("python scripts/check-test-profile.py", timeout=600)
m = re.search(r"结果:(.*)", out)
ok = code == 0
msg = (m.group(1).strip() if m else ("通过" if ok else "失败"))[:70]
results.append(("test profile 能起", ok, msg))
print(" %s%-14s%s %s" % (GREEN if ok else RED, "test profile 能起", RESET, msg))
else:
results.append(("test profile 能起", None, "**跳过**:后端没在跑"))
print(" %s%-14s%s **跳过**:后端没在跑" % (YELLOW, "test profile 能起", RESET))
return summary(results)
def summary(results):
print("\n" + "=" * 92)
passed = [r for r in results if r[1] is True]
failed = [r for r in results if r[1] is False]
skipped = [r for r in results if r[1] is None]
print(" 通过 %d 失败 %d 跳过 %d" % (len(passed), len(failed), len(skipped)))
for name, ok, msg in failed:
print(" %s✗ %s%s:%s" % (RED, name, RESET, msg[:110]))
for name, ok, msg in skipped:
# ⚠️ 跳过要**明确列出来**,不能静默 —— 否则"以为过了"(与 F-85 同类)
print(" %s— %s 跳过:%s%s" % (YELLOW, name, msg[:100], RESET))
print("=" * 92)
sys.stdout.flush()
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())