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
@@ -0,0 +1,120 @@
<template>
<a-modal v-model:visible="visible" title="修改密码" :width="440" :mask-closable="false"
@ok="confirm" @cancel="close">
<a-alert type="info" :show-icon="false" class="change-pwd__tip">
<template #title>
密码用 RSA 加密后传输,不会以明文进入网关或日志。修改成功后请用新密码重新登录。
</template>
</a-alert>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item field="oldPassword" label="原密码">
<a-input-password v-model="form.oldPassword" placeholder="请输入当前密码" allow-clear />
</a-form-item>
<a-form-item field="newPassword" label="新密码">
<a-input-password v-model="form.newPassword" placeholder="8~32 位,需含字母与数字" allow-clear />
</a-form-item>
<a-form-item field="confirmPassword" label="确认新密码">
<a-input-password v-model="form.confirmPassword" placeholder="再输入一次" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { FormInstance } from '@arco-design/web-vue'
import { Message } from '@arco-design/web-vue'
import { changePassword } from '@/apis/xt'
import { encryptPassword } from '@/utils/rsa'
/**
* 修改自己的密码。
*
* 三点与后端一致的口径,界面上要提前说清楚,否则用户会以为是系统坏了:
* 1. **原密码必填** —— 文档里「强制改密场景可空」需要一个
* 「必须改密」标记来界定,而库里目前没有这个列。
* 在没有该标记时放行"不填原密码",等于任何拿到有效 token 的人
* 都能改掉密码、把原主永久挤出去,所以现在一律校验。
* 2. **新旧密码不能相同** —— 服务端会拒。
* 3. **改密后此前签发的 token 仍然有效**(已知缺口),所以这里提示的是
* "请用新密码重新登录",而不是"已踢出其他设备"——后者现在做不到。
*/
defineOptions({ name: 'ChangePasswordModal' })
const visible = ref(false)
const submitting = ref(false)
const formRef = useTemplateRef('formRef')
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const rules: FormInstance['rules'] = {
oldPassword: [{ required: true, message: '请输入原密码' }],
newPassword: [
{ required: true, message: '请输入新密码' },
{ minLength: 8, message: '密码至少 8 位' },
{ maxLength: 32, message: '密码最多 32 位' }
],
confirmPassword: [
{ required: true, message: '请再输入一次新密码' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== form.newPassword) {
callback('两次输入的新密码不一致')
return
}
callback()
}
}
]
}
function open() {
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
visible.value = true
}
function close() {
visible.value = false
}
async function confirm() {
const valid = await formRef.value?.validate()
if (valid) return false
submitting.value = true
try {
// 两个字段都要加密:后端把两个都当密文解,混着传会在解密处失败
const [oldCipher, newCipher] = await Promise.all([
encryptPassword(form.oldPassword),
encryptPassword(form.newPassword)
])
await changePassword({ oldPassword: oldCipher, newPassword: newCipher })
Message.success('密码已修改,请用新密码重新登录')
visible.value = false
return true
} catch {
return false
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.change-pwd {
&__tip {
margin-bottom: 16px;
line-height: 1.7;
}
}
</style>
@@ -128,10 +128,10 @@ const appStore = useAppStore()
.layout-mode-item {
box-sizing: border-box;
display: flex;
gap: 4px;
width: 100%;
height: 58px;
padding: 6px;
gap: 4px;
overflow: hidden;
cursor: pointer;
background-color: var(--color-bg-1);
@@ -156,20 +156,20 @@ const appStore = useAppStore()
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 4px;
min-width: 0;
}
.preview-side {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex-shrink: 0;
flex-direction: column;
gap: 3px;
width: 12px;
padding: 3px 2px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-side--main {
@@ -189,14 +189,14 @@ const appStore = useAppStore()
}
.preview-topbar {
box-sizing: border-box;
display: flex;
gap: 3px;
width: 100%;
height: 10px;
padding: 2px 3px;
gap: 3px;
background-color: var(--color-fill-3);
border-radius: 2px;
box-sizing: border-box;
}
.preview-pill {
@@ -206,14 +206,14 @@ const appStore = useAppStore()
}
.preview-content {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
justify-content: flex-start;
min-width: 0;
padding-right: 1px;
box-sizing: border-box;
.preview-line {
height: 6px;
@@ -222,8 +222,8 @@ const appStore = useAppStore()
.preview-line {
display: block;
height: 5px;
width: 100%;
height: 5px;
background-color: var(--color-fill-2);
border-radius: 1px;
@@ -77,6 +77,8 @@
</a-dropdown>
</a-space>
</a-row>
<ChangePasswordModal ref="changePwdRef" />
</template>
<script setup lang="ts">
@@ -84,6 +86,7 @@ import { Button, Drawer, Message, Modal } from '@arco-design/web-vue'
import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
@@ -107,6 +110,9 @@ const { isFullscreen, toggle } = useFullscreen()
/** 头像兜底文案:取姓名首字 */
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
/** 修改密码弹窗 */
const changePwdRef = useTemplateRef('changePwdRef')
/** 用户菜单配置 */
const USER_MENUS = [
{
@@ -114,11 +120,7 @@ const USER_MENUS = [
label: '修改密码',
icon: 'icon-unlock',
iconColor: 'primary',
onClick: () => {
// 后端 PUT /auth/password 尚未实现(属 M0 剩余任务),
// 这里给明确提示,而不是像模板那样假装成功
Message.info('修改密码功能待接入后端 PUT /auth/password')
}
onClick: () => changePwdRef.value?.open()
}
]
+14 -1
View File
@@ -5,7 +5,20 @@
<template>
<a-layout class="main">
<router-view v-slot="{ Component, route }">
<transition :name="appStore.pageTransitionName" mode="out-in" appear>
<!--
⚠️ 这里**不能加 `appear`**。
加了的后果是:首次渲染也被当成一次过渡,元素被加上
`zoom-fade-enter-from` + `zoom-fade-enter-active`(即 opacity:0 / scale(0.92))
之后**再也不会被清理** —— 因为进入动画的收尾依赖 transitionend 与
下一帧的补帧,而 `mode="out-in"` + `appear` + `keep-alive` 的组合下
没有"离场"元素时这条链路不成立。
表现极具迷惑性:**DOM 里内容齐全、接口数据也对、文字能选中,
就是人眼看不到**(计算样式 opacity 恒为 0),
看起来像"页面没渲染",实际是"渲染了但透明"。
2026-09-28 实测复现:整块内容区 opacity=0、transform=scale(0.92) 长时间不变。
首次加载本来也不需要入场动画,去掉 `appear` 即可。
-->
<transition :name="appStore.pageTransitionName" mode="out-in">
<keep-alive :include="(tabsStore.cacheList as string[])">
<component :is="Component" v-if="tabsStore.reloadFlag" :key="route.path" />
</keep-alive>
@@ -83,7 +83,6 @@ defineOptions({ name: 'MagicIcon' })
transform: rotate(90deg);
.magic-dot {
&::before,
&::after {
background: rgb(var(--primary-6));