feat(m0): 后端骨架 + 账号体系(M0 验收 36/36 通过)

技术栈(已在 4.1.1 上实测跑通,非纸面选型)
  Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
  + Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app

交付内容
  M0-01 基础设施     deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
  M0-02 后端骨架     xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
                     多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
  M0-05 租户        开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
  M0-06 组织        CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
  M0-07 PC 登录     RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
  M0-08 登出/当前用户  Token 黑名单(存 SHA-256,TTL 到自然过期)
  M0-01 数据库      Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)

安全姿态
  · 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
  · 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
  · 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
  · 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
  · 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志

脚本
  scripts/sync-ddl.py          文档 DDL → Flyway 迁移,带 --check 防漂移
  scripts/e2e-m0.py            M0 验收 36 项,可进 CI
  scripts/verify-docs.py       文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
  scripts/dev-login.py         RSA 加密登录助手
  scripts/dev-clean-e2e.py     清理 E2E 测试数据

文档修正(实测暴露,非人工审查)
  · DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
    原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
  · 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
  · 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
This commit is contained in:
2026-09-28 17:28:21 +08:00
parent 63aec55329
commit 9d7a8ca845
88 changed files with 7564 additions and 32 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档一致性校验。
被校的东西都是历史上真实出错过的:
1. 表格列数一致(错位会让 Markdown 渲染成一堆竖线)
2. 接口数:逐节「标题声称数」vs「实际表行数」——只看总数会漏掉
"这节多算 3 个、那节少算 3 个、总数恰好相等"这种情况
3. 错误码数量与文档声称一致
4. 关键数字跨文档一致(FR / VAL / 任务 / 人日 / 表数 / 接口)
5. 原型屏数与 figcaption 数一致
用法
----
python scripts/verify-docs.py # 校验,失败退出码 1
"""
from __future__ import annotations
import io
import os
import re
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
D = os.path.join(ROOT, "docs")
BT = chr(96)
ok_count = 0
bad: list[str] = []
def ok(msg: str) -> None:
global ok_count
ok_count += 1
print(f" [OK] {msg}")
def fail(msg: str) -> None:
bad.append(msg)
print(f" [FAIL] {msg}")
def read(*parts: str) -> str:
p = os.path.join(ROOT, *parts)
return io.open(p, encoding="utf-8").read() if os.path.exists(p) else ""
# ── 1. 表格列数 ────────────────────────────────────────
def check_tables(name: str, text: str) -> None:
lines = text.split("\n")
bad_rows = 0
i = 0
while i < len(lines):
if lines[i].strip().startswith("|") and i + 1 < len(lines) \
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
n = lines[i].count("|")
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
if lines[j].count("|") != n:
bad_rows += 1
j += 1
i = j
else:
i += 1
if bad_rows:
fail(f"{name}:{bad_rows} 行表格列数不一致")
else:
ok(f"{name}:表格列数一致")
# ── 2. 接口数逐节核对 ──────────────────────────────────
def check_api_count() -> int:
s = read("docs", "03-接口设计.md")
if not s:
fail("找不到 03-接口设计.md")
return 0
sec = s[s.index("## §4"):s.index("## §5")]
parts = re.split(r"^(### 4\.\d+ .+)$", sec, flags=re.M)
claim_sum = actual_sum = 0
mismatches = []
i = 1
while i < len(parts) - 1:
title, body = parts[i].strip(), parts[i + 1]
i += 2
m = re.search(r"[((](\d+)[))]\s*$", title)
claim = int(m.group(1)) if m else None
# ⚠️ 允许 ⭐ 出现在方法之前(| ⭐ GET | ... |)
actual = len(re.findall(r"^\|\s*(?:⭐\s*)?(GET|POST|PUT|DELETE|PATCH)\s*\|", body, re.M))
# 索引节(交叉引用别处已计数的接口),不重复计入
if "索引" in title or "新增" in title:
continue
if claim is None:
continue
claim_sum += claim
actual_sum += actual
if claim != actual:
mismatches.append(f"{title[:30]} 声称{claim} 实际{actual}")
if mismatches:
fail(f"03 接口数逐节不一致:{'; '.join(mismatches)}")
elif claim_sum == actual_sum:
ok(f"03 接口数逐节一致 = {actual_sum}")
else:
fail(f"03 接口数:声称 {claim_sum} vs 实际 {actual_sum}")
return actual_sum
# ── 3. 错误码 ──────────────────────────────────────────
def check_error_codes() -> int:
s = read("docs", "03-接口设计.md")
sec = s[s.index("## §15"):]
codes = set(re.findall(
r"^\|\s*(?:⭐\s*)?" + re.escape(BT) + r"(\d{4,5})" + re.escape(BT) + r"\s*\|", sec, re.M))
total = len(codes) + 1 # + 成功码 0
claimed = re.search(r"本节共 \*\*(\d+) 个\*\*", s)
if claimed and int(claimed.group(1)) != total:
fail(f"03 错误码:实际 {total} vs 文档声称 {claimed.group(1)}")
else:
ok(f"03 错误码 = {total}")
return total
# ── 4. 跨文档关键数字 ──────────────────────────────────
def check_cross_doc(api: int, err: int) -> None:
docs = {
"PRD": read("docs", "数据信息填报系统PRD.md"),
"03": read("docs", "03-接口设计.md"),
"05": read("docs", "05-开发任务拆解.md"),
"README": read("README.md"),
"交接摘要": read("docs", "交接摘要.md"),
}
checks = [
("接口", str(api)),
("错误码", str(err)),
("任务", "100"),
("人日", "387"),
]
for label, value in checks:
hit = [n for n, t in docs.items() if value in t]
if len(hit) >= 3:
ok(f"{label} {value} 出现于 {len(hit)} 份文档")
else:
fail(f"{label} {value} 仅出现于 {hit}")
# ── 5. 原型屏数 ────────────────────────────────────────
def check_screens() -> None:
total = 0
for f in ["student-app.html", "teacher-app.html", "admin-web.html"]:
s = read("docs", "prototype", f)
total += len(re.findall(r"<figcaption", s, re.I))
claimed = re.search(r"合计屏数\s*:\s*(\d+)", read("docs", "交接摘要.md"))
if total:
ok(f"原型屏数(figcaption 计数)= {total}")
if claimed and int(claimed.group(1)) != total:
fail(f"原型屏数:实际 {total} vs 交接摘要声称 {claimed.group(1)}")
def main() -> int:
print("=" * 66)
print(" 文档一致性校验")
print("=" * 66)
print("\n[1] 表格列数")
for f in ["README.md"]:
check_tables(f, read(f))
for f in os.listdir(D):
if f.endswith(".md"):
check_tables(f, read("docs", f))
print("\n[2] 接口数(逐节核对)")
api = check_api_count()
print("\n[3] 错误码")
err = check_error_codes()
print("\n[4] 跨文档关键数字")
check_cross_doc(api, err)
print("\n[5] 原型屏数")
check_screens()
print("\n" + "=" * 66)
if bad:
print(f" 结果: {ok_count} 项通过,{len(bad)} 项失败")
for b in bad:
print(f" - {b}")
else:
print(f" 结果: {ok_count} 项全部通过")
print("=" * 66)
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())