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
@@ -1,5 +1,18 @@
import type { FormInstance } from '@arco-design/web-vue'
import EditTable from './GiEditTable.vue'
export type * from './type'
export type EditTableInstance = InstanceType<typeof EditTable>
/**
* 组件实例类型。
*
* 与 `GiTable/index.ts` 同一个原因:泛型 SFC 不能用 `InstanceType<>` 推导
* (`<script setup generic="T extends TableData">` 让组件类型成为泛型函数签名),
* 所以按 `defineExpose({ formRef })` 的真实形状手工声明。
*/
export interface EditTableInstance {
/** 内层 a-form 的实例(校验用) */
formRef: FormInstance | null
}
export { EditTable as GiEditTable }
+15 -15
View File
@@ -4,23 +4,23 @@ export interface FlexProps {
column?: boolean
/** 水平对齐方式(与 CSS justify-content 一致) */
justify?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
/** 垂直对齐方式(与 CSS align-items 一致) */
align?:
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'baseline'
| 'stretch'
| 'start'
| 'center'
| 'end'
| 'flex-start'
| 'flex-end'
| 'baseline'
| 'stretch'
/** 是否自动换行 */
wrap?: boolean
/** `boolean` 须写在联合类型最前,否则模板里单独写 `gap` 会被解析成 `""` 而不是 `true` */
+20 -1
View File
@@ -1,5 +1,24 @@
import type { TableInstance as ArcoTableInstance } from '@arco-design/web-vue'
import Table from './GiTable.vue'
export type * from './type'
export type TableInstance = InstanceType<typeof Table>
/**
* 组件实例类型。
*
* ⚠️ 这里**不能**写 `InstanceType<typeof Table>`。
* `GiTable.vue` 是泛型 SFC(`<script setup generic="T extends TableData">`),
* 它的类型是一个泛型函数签名而不是构造函数,`InstanceType<>` 对它不成立,
* vue-tsc 会报 `TS2344: ... does not satisfy the constraint
* 'abstract new (...args: any) => any'`。
*
* 所以按 `defineExpose({ tableRef })` 的**真实形状**手工声明:
* 暴露出来的就是那一个内层 a-table 的实例(模板 ref 在暴露时会被解包)。
* 这比 `InstanceType` 推导出来的"全部 props + slots"更准确 —— 后者本来也是假的。
*/
export interface TableInstance {
/** 内层 a-table 的实例 */
tableRef: ArcoTableInstance | null
}
export { Table as GiTable }
+10 -6
View File
@@ -1,15 +1,20 @@
/**
* 组件库出口。
*
* ⚠️ 这里的每一行都必须对应 `src/components/` 下的真实目录。
* 剥离 gi-demo 演示页时删掉过 6 个演示专用组件
* (GiCodeButton / GiCodeView / GiFooter / GiIconSelector / GiIframe / GiTrend),
* 但出口文件没同步 —— `export * from './已删除的组件'` 在构建时**不会报错**
* (Vite 只在使用到该导出时才解析),直到某天有人 import 它才炸。
* 所以增删组件时务必回来同步这一份清单。
*/
export * from './GiButton'
export * from './GiCell'
export * from './GiCodeButton'
export * from './GiCodeView'
export * from './GiDot'
export * from './GiEditTable'
export * from './GiFlex'
export * from './GiFooter'
export * from './GiForm'
export * from './GiIconBox'
export * from './GiIconSelector'
export * from './GiIframe'
export * from './GiInputSearch'
export * from './GiOption'
export * from './GiPageLayout'
@@ -17,4 +22,3 @@ export * from './GiTable'
export * from './GiTableSetting'
export * from './GiTag'
export * from './GiThemeBtn'
export * from './GiTrend'