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
+5
View File
@@ -79,3 +79,8 @@ export function logout() {
export function getCaptcha() {
return http.get<CaptchaVO>('/auth/captcha')
}
/** 修改自己的密码(原密码与新密码都需 RSA 加密) */
export function changePassword(data: { oldPassword: string, newPassword: string }) {
return http.put<void>('/auth/password', data)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export interface TenantCreateReq {
adminPhone: string
}
/** 开通结果:初始密码**只返回这一次** */
/** 开通结果:初始密码**只返回这一次 */
export interface TenantCreateResult {
id: number
adminInitialPassword: string
+214
View File
@@ -0,0 +1,214 @@
/**
* 账号管理接口(《03-接口设计》§5.6 ~ §5.9)。
*
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
/** 账号列表项(§5.6) */
export interface UserVO {
id: number
/** 登录名;学生走微信登录时可能为空 */
username: string | null
realName: string
/** 脱敏手机号,形如 138****8888 —— 后端不返回明文 */
phoneMasked: string | null
orgId: number | null
orgName: string | null
/** 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生(§2.7) */
userType: number
/** 0 停用 / 1 正常 / 2 待激活 */
status: number
roleCodes: string[]
roleNames: string[]
lastLoginTime: string | null
createTime: string
}
/** 新增 / 修改请求(§5.7 / §5.9) */
export interface UserUpsertReq {
/** 修改时后端忽略此字段 */
username?: string
realName: string
phone: string
orgId?: number | null
roleCodes: string[]
userType: number
status?: number
}
/** 新增结果:初始密码仅此一次返回 */
export interface UserCreateResult {
id: number
initialPassword: string
}
/** 重置密码结果 */
export interface UserResetResult {
username: string
newPassword: string
}
/** 批量导入的「体检报告」(§5.8) */
export interface UserImportResult {
total: number
success: number
failed: number
/** 服务端识别到的表头所在行号(从 1 开始)—— 必须展示给用户确认 */
headerRow: number
/** 表头那一行的原文(用 | 连接),供用户核对"系统认对了吗" */
headerText: string
/** 识别到的列映射:哪个字段落在哪一列 */
columns: Array<{ field: string, fieldName: string, column: string }>
errors: Array<{ row: number, field: string, value: string | null, reason: string }>
initialPasswords: Array<{ username: string, password: string }>
}
export interface UserListQuery {
keyword?: string
orgId?: number
roleCode?: string
userType?: number
status?: number
pageNum?: number
pageSize?: number
}
/** 账号分页列表 */
export function getUserList(params: UserListQuery) {
return http.get<{ list: UserVO[], total: number }>('/users', params)
}
/** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) {
return http.post<UserCreateResult>('/users', data)
}
/** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) {
return http.put<void>(`/users/${id}`, data)
}
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) {
return http.delete<void>(`/users/${id}`)
}
/** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
}
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
}
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) {
const fd = new FormData()
fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, {
params: defaultRoleCode ? { defaultRoleCode } : undefined
})
}
// ── 枚举镜像 ────────────────────────────────────────
/**
* 用户类型 → 中文名。
*
* ⚠️ 口径是《03-接口设计》§2.7:`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生。
*
* **不要照 `sys_user` 的库注释写。** 那行注释(以及 PRD 里的那行 DDL)写的是
* 「1教师/管理员 2填报人(学生) 3社会人员」,与契约是两套错位的枚举 ——
* 旧注释的 2=学生,契约的 2=学校管理员;旧注释的 3=社会人员,契约的 3=班主任。
* 已经因此误判过一次:把 `3` 当成"社会人员(一期不做)"拒掉,而它其实是**班主任**。
* 库注释已由 `V3__fix_user_type_comment.sql` 更正。
*/
export const USER_TYPE_NAMES: Record<number, string> = {
1: '平台运营',
2: '学校管理员',
3: '班主任',
4: '学生'
}
/**
* 角色 → 用户类型。
*
* 二者本来就是一对一绑定的,所以:
* · 表单里**不让用户选**用户类型,而是从角色推导出来(避免选出矛盾组合)
* · 服务端也会用同一套映射反推校验(`UserService.assertRoleMatchesUserType`)
*/
export const ROLE_TO_USER_TYPE: Record<string, number> = {
PLATFORM_ADMIN: 1,
SCHOOL_ADMIN: 2,
AUDITOR: 2,
HEAD_TEACHER: 3,
DEPUTY_TEACHER: 3,
STUDENT: 4
}
/** 从所选角色推导用户类型;角色为空或跨类时返回 `null`(调用方据此拦提交) */
export function deriveUserType(roleCodes: string[]): number | null {
const types = new Set<number>()
for (const c of roleCodes) {
const t = ROLE_TO_USER_TYPE[c]
if (t) types.add(t)
}
return types.size === 1 ? [...types][0] : null
}
/** 账号状态(对应 sys_user.status) */
export const USER_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '已停用', color: 'gray' },
1: { text: '正常', color: 'green' },
2: { text: '待激活', color: 'orange' }
}
/**
* 可分配的角色,**必须与后端 `BuiltinRole` 逐字一致**。
*
* 为什么在前端也写一份:一期没有任何"自定义角色"的界面,角色集合由代码决定、
* 不由数据库决定(这是项目已定的决策 —— 权限码是前后端契约,放 DB 必然漂移)。
* 所以这里是「与后端枚举对称的镜像」,不是"业务配置"。
*
* 真实的可分配范围仍由后端 `UserService.resolveAssignableRoles` 判定:
* 传了这里的列表之外的角色会拿到 40301,前端只负责不显示没得选的项。
*
* 平台运营角色只在平台租户可分配 —— 学校管理员拿到它会直接越权到平台级。
*/
export const SCHOOL_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'SCHOOL_ADMIN', label: '学校管理员' },
{ value: 'HEAD_TEACHER', label: '班主任' },
{ value: 'DEPUTY_TEACHER', label: '副班主任' },
{ value: 'AUDITOR', label: '审核员' },
{ value: 'STUDENT', label: '学生' }
]
/** 平台租户的可分配角色 */
export const PLATFORM_ROLE_OPTIONS: Array<{ value: string, label: string }> = [
{ value: 'PLATFORM_ADMIN', label: '平台运营' }
]
/** 导入时「角色」列允许写的角色中文名 → 角色码(与后端 BuiltinRole 对齐) */
export const ROLE_NAME_TO_CODE: Record<string, string> = {
平台运营: 'PLATFORM_ADMIN',
学校管理员: 'SCHOOL_ADMIN',
班主任: 'HEAD_TEACHER',
副班主任: 'DEPUTY_TEACHER',
审核员: 'AUDITOR',
学生: 'STUDENT'
}
/** 导入文件里被识别的列名(与后端 HEADER_ALIASES 对齐,供界面提示用) */
export const IMPORT_FIELD_NAMES: Record<string, string> = {
realName: '姓名',
username: '账号',
phone: '手机号',
org: '组织',
role: '角色'
}