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,328 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
M0 验收测试。
|
||||
|
||||
把《05-开发任务拆解》里 M0 的验收标准变成可重复执行的程序:
|
||||
|
||||
[x] 能开通租户 → 建组织树 → 各角色登录看到不同菜单
|
||||
[x] 跨租户访问被拦截
|
||||
[x] schema-postgresql.sql 在测试环境零错误执行 ← 由 Flyway 迁移保证
|
||||
|
||||
用法
|
||||
----
|
||||
python scripts/e2e-m0.py
|
||||
python scripts/e2e-m0.py --base http://localhost:8080
|
||||
|
||||
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
|
||||
|
||||
依赖:cryptography
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
# ── HTTP ──────────────────────────────────────────────
|
||||
|
||||
def http(method: str, url: str, body=None, token: str | None = None,
|
||||
timeout: int = 30) -> tuple[int, dict]:
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8")
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, {"code": -1, "msg": raw[:200]}
|
||||
|
||||
|
||||
def rsa_encrypt(plain: str) -> str:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
with open(PUB_KEY, "rb") as f:
|
||||
pub = serialization.load_pem_public_key(f.read())
|
||||
return base64.b64encode(pub.encrypt(plain.encode("utf-8"), padding.PKCS1v15())).decode()
|
||||
|
||||
|
||||
def login(base: str, username: str, password: str, tenant_code: str | None = None) -> dict:
|
||||
payload = {"username": username, "password": rsa_encrypt(password)}
|
||||
if tenant_code:
|
||||
payload["tenantCode"] = tenant_code
|
||||
try:
|
||||
_, body = http("POST", f"{base}/api/v1/auth/login", payload)
|
||||
except Exception as e: # 网络层失败也要报成一条用例,而不是崩掉
|
||||
return {"code": -1, "msg": f"请求失败: {e}"}
|
||||
return body or {"code": -1, "msg": "空响应"}
|
||||
|
||||
|
||||
# ── 断言 ──────────────────────────────────────────────
|
||||
|
||||
def data_of(body: dict) -> dict:
|
||||
"""安全取 data:响应里 data 可能是 null。"""
|
||||
if not isinstance(body, dict):
|
||||
return {}
|
||||
d = body.get("data")
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def list_of(body: dict) -> list:
|
||||
d = body.get("data")
|
||||
if isinstance(d, list):
|
||||
return d
|
||||
if isinstance(d, dict) and isinstance(d.get("list"), list):
|
||||
return d["list"]
|
||||
return []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||
if cond:
|
||||
PASSED.append(name)
|
||||
print(f" [PASS] {name}")
|
||||
else:
|
||||
FAILED.append((name, detail))
|
||||
print(f" [FAIL] {name} {detail}")
|
||||
return cond
|
||||
|
||||
|
||||
def expect_code(name: str, body: dict, code: int) -> bool:
|
||||
actual = body.get("code") if isinstance(body, dict) else None
|
||||
return check(name, actual == code,
|
||||
f"期望 code={code},实际 code={actual} msg={body.get('msg') if isinstance(body, dict) else body}")
|
||||
|
||||
|
||||
def finish() -> int:
|
||||
print("\n" + "=" * 66)
|
||||
total = len(PASSED) + len(FAILED)
|
||||
if FAILED:
|
||||
print(f" 结果: {len(PASSED)}/{total} 通过 —— 有失败项")
|
||||
print("\n 失败项:")
|
||||
for name, detail in FAILED:
|
||||
print(f" - {name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
else:
|
||||
print(f" 结果: {len(PASSED)}/{total} 全部通过")
|
||||
print("=" * 66)
|
||||
return 0 if not FAILED else 1
|
||||
|
||||
|
||||
# ── 用例 ──────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||
args = ap.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
stamp = str(int(time.time()))[-6:]
|
||||
new_code = f"e2e{stamp}"
|
||||
|
||||
if not os.path.exists(PUB_KEY):
|
||||
sys.exit(f"找不到 RSA 公钥:{PUB_KEY}")
|
||||
|
||||
print("=" * 66)
|
||||
print(" M0 验收测试")
|
||||
print(f" 目标: {base}")
|
||||
print("=" * 66)
|
||||
|
||||
# ── A. 认证 ───────────────────────────────────────
|
||||
print("\n[A] 认证与鉴权")
|
||||
|
||||
body = login(base, "platform", "Platform@123")
|
||||
if not expect_code("A1 平台运营登录成功", body, 0):
|
||||
return finish()
|
||||
platform_token = data_of(body).get("token")
|
||||
|
||||
# ⚠️ admin 这个账号名在多个租户下都存在(E2E 每次会给新租户也建一个 admin),
|
||||
# 所以必须带 tenantCode —— 这正是服务端「拒绝猜测、要求指明单位」的设计。
|
||||
body = login(base, "admin", "Admin@123", tenant_code="demo")
|
||||
expect_code("A2 学校管理员登录成功(带租户码)", body, 0)
|
||||
demo_token = data_of(body).get("token")
|
||||
demo_menus = set(data_of(body).get("menus") or [])
|
||||
|
||||
# 覆盖「账号跨租户重名」这条保护。
|
||||
# 首次运行时 admin 只存在于 demo 一个租户(此时正常登录是对的);
|
||||
# 反复运行后会出现多个同名 admin(此时必须要求指明单位)。
|
||||
# 两种都算通过,唯一不允许的是「随便挑一个」。
|
||||
body = login(base, "admin", "Admin@123")
|
||||
code_n = body.get("code")
|
||||
msg_n = str(body.get("msg", ""))
|
||||
check("A2b 同名账号跨租户时:要么唯一可直接登录,要么要求指明单位——绝不猜",
|
||||
code_n == 0 or (code_n == 40012 and "多个单位" in msg_n),
|
||||
f"code={code_n} msg={msg_n}")
|
||||
|
||||
expect_code("A3 密码错误返回 40012", login(base, "admin", "WrongPassword!1", "demo"), 40012)
|
||||
|
||||
# 用每次唯一的账号名,避免上一轮的失败计数触发验证码要求
|
||||
ghost = f"ghost_{stamp}"
|
||||
expect_code("A4 账号不存在也返回 40012(不泄露账号是否存在)",
|
||||
login(base, ghost, "Whatever@123"), 40012)
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/auth/me")
|
||||
expect_code("A5 未带 token 访问受保护接口返回 40101", b, 40101)
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/auth/me", token=demo_token)
|
||||
expect_code("A6 /auth/me 正常返回", b, 0)
|
||||
check("A7 返回角色码 SCHOOL_ADMIN", data_of(b).get("roleCodes") == ["SCHOOL_ADMIN"],
|
||||
str(data_of(b).get("roleCodes")))
|
||||
check("A8 学校管理员菜单不含 tenant(无平台权限)", "tenant" not in demo_menus,
|
||||
f"menus={sorted(demo_menus)}")
|
||||
|
||||
body = login(base, "platform", "Platform@123")
|
||||
plat_menus = set(data_of(body).get("menus") or [])
|
||||
check("A9 平台运营菜单含 tenant", "tenant" in plat_menus, f"menus={sorted(plat_menus)}")
|
||||
check("A10 两种角色菜单不同(权限渲染生效)", demo_menus != plat_menus,
|
||||
f"demo={sorted(demo_menus)} platform={sorted(plat_menus)}")
|
||||
|
||||
# ── B. 开通租户 ───────────────────────────────────
|
||||
print("\n[B] 开通租户")
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/tenants", token=demo_token)
|
||||
expect_code("B1 学校管理员无租户管理权限(40301)", b, 40301)
|
||||
|
||||
_, b = http("POST", f"{base}/api/v1/tenants", {
|
||||
"tenantName": f"E2E 测试学校 {stamp}",
|
||||
"tenantCode": new_code,
|
||||
"tenantType": 1,
|
||||
"contactName": "测试联系人",
|
||||
"contactPhone": "13900139000",
|
||||
"expireTime": "2027-12-31",
|
||||
"adminUsername": "admin",
|
||||
"adminRealName": "E2E 管理员",
|
||||
"adminPhone": "13900139001",
|
||||
}, token=platform_token)
|
||||
expect_code("B2 平台运营开通租户成功", b, 0)
|
||||
created = data_of(b)
|
||||
new_tenant_id = created.get("id")
|
||||
new_pwd = created.get("adminInitialPassword")
|
||||
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
|
||||
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
|
||||
str(created.get("miniProgramScene")))
|
||||
if new_tenant_id is None:
|
||||
return finish()
|
||||
|
||||
body = login(base, "admin", new_pwd or "x", tenant_code=new_code)
|
||||
expect_code("B5 新租户管理员用初始密码可登录(证明租户作用域切换生效)", body, 0)
|
||||
new_token = data_of(body).get("token")
|
||||
check("B6 新管理员归属租户正确",
|
||||
data_of(body).get("tenant", {}).get("tenantCode") == new_code,
|
||||
str(data_of(body).get("tenant")))
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/tenants?pageNum=1&pageSize=50", token=platform_token)
|
||||
expect_code("B7 租户列表可查", b, 0)
|
||||
ids = [t.get("id") for t in list_of(b)]
|
||||
check("B8 列表含新建租户", new_tenant_id in ids, f"ids={ids}")
|
||||
|
||||
_, b = http("POST", f"{base}/api/v1/tenants", {
|
||||
"tenantName": "重复编码", "tenantCode": new_code, "tenantType": 1,
|
||||
"contactName": "x", "contactPhone": "13900139000", "expireTime": "2027-12-31",
|
||||
"adminUsername": "admin", "adminRealName": "x", "adminPhone": "13900139002",
|
||||
}, token=platform_token)
|
||||
expect_code("B9 租户编码重复被拒(40001)", b, 40001)
|
||||
|
||||
# ── C. 组织树 ─────────────────────────────────────
|
||||
print("\n[C] 组织架构")
|
||||
|
||||
def mk_org(token, parent_id, name, otype, code):
|
||||
_, r = http("POST", f"{base}/api/v1/orgs", {
|
||||
"parentId": parent_id, "orgName": name, "orgType": otype, "orgCode": code,
|
||||
}, token=token)
|
||||
return r
|
||||
|
||||
b = mk_org(demo_token, None, f"E2E 示范学校 {stamp}", 1, f"ROOT{stamp}")
|
||||
expect_code("C1 建学校节点", b, 0)
|
||||
school_id = b.get("data")
|
||||
|
||||
b = mk_org(demo_token, school_id, f"E2E 机电系 {stamp}", 2, f"DEPT{stamp}")
|
||||
expect_code("C2 建院系节点", b, 0)
|
||||
dept_id = b.get("data")
|
||||
|
||||
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
|
||||
expect_code("C3 建班级节点", b, 0)
|
||||
class_id = b.get("data")
|
||||
|
||||
expect_code("C4 同级重复组织编码被拒(40001)",
|
||||
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001)
|
||||
expect_code("C5 组织类型层级颠倒被拒(40001)",
|
||||
mk_org(demo_token, class_id, "层级颠倒", 1, f"BAD{stamp}"), 40001)
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/orgs/tree?withCount=true", token=demo_token)
|
||||
expect_code("C6 组织树查询", b, 0)
|
||||
tree = list_of(b)
|
||||
|
||||
def find(nodes, target):
|
||||
for n in nodes:
|
||||
if n.get("id") == target:
|
||||
return n
|
||||
hit = find(n.get("children") or [], target)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
check("C7 三层结构正确(班级挂在院系下)", find(tree, class_id) is not None,
|
||||
f"树中未见 id={class_id}")
|
||||
root = find(tree, school_id)
|
||||
check("C8 学校 → 院系 层级正确",
|
||||
root is not None and dept_id in [c.get("id") for c in (root.get("children") or [])],
|
||||
f"children={[c.get('id') for c in (root.get('children') or [])] if root else None}")
|
||||
|
||||
_, b = http("DELETE", f"{base}/api/v1/orgs/{dept_id}", token=demo_token)
|
||||
expect_code("C9 有下级的组织不可删除(40015)", b, 40015)
|
||||
|
||||
# ── D. 跨租户隔离 ─────────────────────────────────
|
||||
print("\n[D] 跨租户隔离")
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/orgs/tree", token=new_token)
|
||||
expect_code("D1 新租户可查自己的组织树", b, 0)
|
||||
dumped = json.dumps(list_of(b), ensure_ascii=False)
|
||||
check("D2 新租户看不到 demo 租户的组织(租户条件自动注入)",
|
||||
str(school_id) not in dumped, f"新租户树里出现了 demo 的组织 id={school_id}")
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/tenants", token=new_token)
|
||||
expect_code("D3 新租户管理员调租户列表被拒(40301)", b, 40301)
|
||||
|
||||
# ── E. 异常与安全 ─────────────────────────────────
|
||||
print("\n[E] 异常与安全")
|
||||
|
||||
_, b = http("POST", f"{base}/api/v1/auth/login", {"username": "admin", "password": "not-encrypted"})
|
||||
check("E1 密码未加密(非法密文)也被拒,不泄露内部信息",
|
||||
b.get("code") in (40012, 40001), f"code={b.get('code')} msg={b.get('msg')}")
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/orgs/tree", token="garbage.token.value")
|
||||
expect_code("E2 伪造 token 返回 40101", b, 40101)
|
||||
|
||||
_, b = http("GET", f"{base}/api/v1/orgs/abc", token=demo_token)
|
||||
check("E3 路径参数类型错误返回 40001", b.get("code") == 40001, f"code={b.get('code')}")
|
||||
|
||||
_, b = http("POST", f"{base}/api/v1/orgs", {"orgName": "", "orgType": 99, "orgCode": ""},
|
||||
token=demo_token)
|
||||
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
|
||||
f"code={b.get('code')}")
|
||||
|
||||
return finish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user