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:
@@ -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())
|
||||
Reference in New Issue
Block a user