1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库 (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖 接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环 - 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis, 空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底 - V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里) 与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129 - 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外); 平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存) - RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 —— 修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红 - scripts/e2e-m10-role.py 77/77 需求收敛(交接摘要 §一·三十六,本节的数字为准): - 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、 加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列; 审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支 - V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有 别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集, 存量账号会"能登录、处处 40301",不抛异常不打日志 - 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、 小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条) - UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭 (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常) - 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、 存储自检探针内容;学生端 app 名与全局导航栏标题
213 lines
8.9 KiB
Python
213 lines
8.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""交付自检:一条命令把"这个仓库现在到底对不对"跑一遍。
|
||
|
||
## 为什么需要它
|
||
|
||
这个项目的验证手段有六类,散在六个地方:
|
||
|
||
| 类别 | 命令 | 需要什么 |
|
||
|---|---|---|
|
||
| 契约 ↔ 路由 | `check-api-parity.py` | 无 |
|
||
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
|
||
| 文档自身一致 | `verify-docs.py` | 无 |
|
||
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
|
||
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
|
||
| 接口行为(断言总数见下方"用法"里的实测值) | `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 # 连 37 个脚本的接口断言一起跑(需要后端在跑)
|
||
python scripts/verify-all.py --quick # 只跑不需要任何前置的五项
|
||
```
|
||
|
||
> 脚本数与断言总数会随需求变化(2026-10 需求收敛后是 **37 个脚本**,断言总数以本命令
|
||
> 实际输出为准 —— 别再往文档里抄一个会过期的固定数字)。
|
||
|
||
退出码: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`(37 个脚本、约 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())
|