feat(user): 账号管理(CRUD、Excel 导入、改密)并修掉 5 处缺陷

后端补齐 /users 全套(列表/新增/修改/软删/启停/重置密码/批量导入)与
PUT /auth/password;前端加「组织与账号 → 账号管理」一屏(左组织树 + 右列表、
新增编辑弹窗、导入体检报告、初始密码只显示一次的强确认)。

验收:e2e-m0-user.py 55/55;e2e-m0.py 回归 36/36;typecheck 0 错、build 通过。
账号管理页已在浏览器肉眼确认(首屏与切页均可见、数据与分页正确)。

本轮修掉的 5 个真问题 —— 都不是功能没做完,而是做完了但错的:

1) 学校租户也发放 PLATFORM_ADMIN 角色行
   学校管理员在新增账号时传 roleCodes=[PLATFORM_ADMIN] 就能造出带
   tenant:manage 的账号,而 /tenants 只校验权限码、不校验是否真在平台租户下
   → 任何学校管理员都能开通租户、拉全平台租户列表(租户级越权到平台级)。
   RoleProvisioner 不再给非平台租户发该角色行 + 服务端按租户白名单校验。

2) UserUpsertReq.username 的 @NotBlank 对修改也生效
   PUT /users/{id} 永远返回 40001,且明细只说存在阻断性校验问题,
   看不出是登录名 —— 改账号功能整体不可用。去掉该注解,
   必填性改由 UserService.create 判定。

3) Main.vue 的 <transition appear>
   首次渲染被当成一次过渡,元素被加上 zoom-fade-enter-from(opacity:0)后
   永不清理 → DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见。
   极易误判成页面没渲染而往路由/接口方向排查。去掉 appear。

4) assertAnotherAdminExists 在停用/删除路径上不可达
   能调这两个接口的人自己必然是活跃管理员,所以除目标外还有管理员恒成立;
   而真正会锁死租户的路径 —— 把管理员改成普通角色(目标可以是操作者自己)——
   当时没有拦。补 losingAdminRole() + UserMapper.countAdminRoles(),
   并在方法注释里写明哪条路径真生效、哪条只是防御性的。

5) sys_user.user_type 有两套互相冲突的枚举
   DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而权威契约
   (03-接口设计 §2.7)与 BuiltinRole.UserType 是
   「1平台运营 2学校管理员 3班主任 4学生」—— 2 和 3 完全错位。
   后果:userType=3(班主任,最高频的账号)被当成社会人员(二期)直接拒掉,
   建班主任账号会失败;前端还把学校管理员显示成填报人(学生)。
   改法:后端从角色反推用户类型再比对;新增 V3 迁移更正库注释
   (V1 已应用不能改,Flyway 会校验 checksum);更正 PRD 与 schema DDL 的同一行;
   前端「用户类型」从手选改为由角色推导,从根上消除矛盾组合;
   E2E 加 3 条回归断言锁住这个语义。

顺带修掉:
  · components/index.ts 里 6 个指向已删组件的悬空导出(构建不报错、用到才炸)
  · tsconfig 的 lib 停在 ES2020 而代码用了 Array.prototype.at
  · 两处 .at() 的空值风险(useBreakpoint / useTabsStore)
  · dashboard 的 userStore.menus 拼错,导致平台判断恒为 false
  · http.ts 里两个永不命中的错误码常量
  · 账号列表的 fixed 列缺 :scroll 配置,会浮在其它列上面
  · .gitignore 补 __pycache__

新增脚本:
  · scripts/e2e-m0-user.py            账号管理 55 项验收(含越权与自锁的负向断言)
  · scripts/dev-clean-test-users.py   清理 E2E 残留的测试账号
This commit is contained in:
2026-09-28 22:25:01 +08:00
parent 87ece5a971
commit 8480f431ba
52 changed files with 4264 additions and 122 deletions
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
清理 E2E 崩掉时残留在 demo 租户里的测试账号。
背景:e2e-m0-user.py 在 [5] 自锁防线那一段曾因脚本自身 bug 抛异常退出,
没有走到 [14] 的清理,于是 demo 租户里留下了 `dup*` / `t*` / `imp*` 之类的测试账号。
它们会出现在账号管理页上,必须清掉。
"""
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.request
sys.stdout.reconfigure(encoding='utf-8')
BASE = "http://localhost:8080"
PUB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"deploy", "rsa-public-key-dev.pem")
# E2E 造账号时用的前缀(见 scripts/e2e-m0-user.py)
TEST_PATTERNS = [
re.compile(r'^dup[0-9a-f]{6}$'), # 重名测试
re.compile(r'^t[0-9a-f]{6}$'), # 测试班主任
re.compile(r'^ht[0-9a-f]{6}$'), # 班主任回归护栏
re.compile(r'^imp[123][0-9a-f]{6}$'), # 导入进来的
re.compile(r'^esc2?[0-9a-f]{6}$'), # 提权测试(正常不该存在)
re.compile(r'^mix[0-9a-f]{6}$'), # 类型不符测试(正常不该存在)
re.compile(r'^bad[0-9a-f]{6}$'), # 手机号错(正常不该存在)
]
def rsa(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, path: str, token: str | None = None, body: dict | None = None):
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode())
except Exception:
return {"code": e.code}
# 登录
r = call("POST", "/api/v1/auth/login",
body={"username": "admin", "password": rsa("Admin@123"), "tenantCode": "demo"})
if r.get("code") != 0:
sys.exit(f"登录失败:{r}")
token = r["data"]["token"]
admin_uid = r["data"]["user"]["id"]
print(f" 已登录(admin uid={admin_uid})")
# 拉全量
r = call("GET", "/api/v1/users?pageSize=200", token)
users = (r.get("data") or {}).get("list") or []
print(f" demo 租户共 {len(users)} 个账号")
victims = []
for u in users:
if u["id"] == admin_uid:
continue
uname = u.get("username") or ""
if any(p.match(uname) for p in TEST_PATTERNS):
victims.append(u)
if not victims:
print(" ✓ 没有残留的测试账号")
sys.exit(0)
print(f" 发现 {len(victims)} 个残留测试账号:")
for v in victims:
print(f" id={v['id']:<4} username={v.get('username'):<14} realName={v.get('realName')}")
print()
for v in victims:
res = call("DELETE", f"/api/v1/users/{v['id']}", token)
flag = "✓" if res.get("code") == 0 else "✗"
print(f" {flag} 删除 {v.get('username')} code={res.get('code')}")
# 复核
r = call("GET", "/api/v1/users?pageSize=200", token)
left = (r.get("data") or {}).get("list") or []
print()
print(f" 清理后剩 {len(left)} 个账号:")
for u in left:
print(f" id={u['id']:<4} username={u.get('username'):<14} realName={u.get('realName')} 角色={u.get('roleNames')}")