fix: 已停用角色导致用户无法保存编辑

This commit is contained in:
文睿 张 2026-09-22 16:41:19 +08:00
parent 688dffebaa
commit aa017a32b4
5 changed files with 273 additions and 20 deletions

View File

@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Users from './Users.vue'
import { organizationsApi } from '../api/organizations'
@ -99,6 +100,25 @@ describe('用户管理角色', () => {
expect(wrapper.text()).toContain('采购人员')
wrapper.unmount()
})
// 列表里同样要标出来:停用角色的 tag 和启用的一模一样,管理员会以为这个用户还在
// 通过它拿权限,实际上 GenerateToken 已经把它过滤掉了。
test('列表里已停用的角色也带后缀,启用的不带', async () => {
usersStore.users = [{
...usersStore.users[0],
roles: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
]
}]
const wrapper = mountUsers()
await flushPromises()
expect(wrapper.text()).toContain('采购人员(已停用)')
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).not.toContain('仓库管理员(已停用)')
wrapper.unmount()
})
})
describe('新增用户后列表与搜索框保持一致', () => {
@ -155,3 +175,113 @@ describe('新增用户后列表与搜索框保持一致', () => {
wrapper.unmount()
})
})
// 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换
// 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。
// 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人
// 新分配一个停用角色。
describe('已停用角色的选择状态', () => {
const disabledRole = { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
const unassignedDisabledRole = { id: 4, code: 'obsolete_role', name: '作废角色', enabled: false, permissions: [] }
beforeEach(() => {
// 用户 7 正持有已停用的角色 3;角色 4 同样停用但该用户没有
usersStore.users = [{
id: 7,
username: 'zhangsan',
description: '测试用户',
created_at: '2026-09-16T09:00:00+08:00',
roles: [disabledRole]
}]
usersStore.updateUser.mockReset()
usersStore.updateUser.mockResolvedValue({ data: {} })
organizationsApi.listRoles.mockResolvedValue({
data: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
disabledRole,
unassignedDisabledRole
]
})
})
async function openEdit(wrapper) {
const state = wrapper.vm.$.setupState
state.showEditModal(usersStore.users[0])
await flushPromises()
return state
}
function findOption(wrapper, value) {
return wrapper.findAllComponents(RoleOptionStub).find(option => option.props('value') === value)
}
test('用户已持有的停用角色不禁用,否则它摘不掉', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = await openEdit(wrapper)
expect(state.form.role_ids).toEqual([3])
// disabled 跟着 form.role_ids 走:已选中就不禁用,tag 上才有关闭按钮
expect(findOption(wrapper, 3).props('disabled')).toBe(false)
wrapper.unmount()
})
test('未持有的停用角色仍然禁用,不能通过编辑新分配', async () => {
const wrapper = mountUsers()
await flushPromises()
await openEdit(wrapper)
expect(findOption(wrapper, 4).props('disabled')).toBe(true)
wrapper.unmount()
})
test('停用的角色在下拉里标注出来', async () => {
const wrapper = mountUsers()
await flushPromises()
await openEdit(wrapper)
expect(findOption(wrapper, 3).props('label')).toBe('采购人员(已停用)')
expect(findOption(wrapper, 2).props('label')).toBe('仓库管理员')
wrapper.unmount()
})
})
// 上面几条用的是 stub,只能证明 :disabled 这个 prop 算对了;而"管理员究竟能不能把那个
// 停用角色摘掉"取决于 element-plus 内部——multiple 模式下 tag 的关闭按钮绑在 option 的
// isDisabled 上(select2.mjs 的 closable: !selectDisabled && !item.isDisabled)。这里是
// 用真实组件把这条依赖验一遍,否则 Users.vue 的修复只是纸面上成立。
describe('element-plus 多选下停用角色 tag 的可关闭性', () => {
const roles = [
{ id: 2, name: 'warehouse_admin', enabled: true },
{ id: 3, name: 'purchaser', enabled: false }
]
// 只铺一个最小场景:已选中那个停用的角色 3
function buildSelect(disabledExpression) {
return defineComponent({
template: `
<el-select v-model="ids" multiple>
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id"
:disabled="${disabledExpression}" />
</el-select>
`,
setup() {
return { ids: ref([3]), roles }
}
})
}
async function closeIconCount(disabledExpression) {
const wrapper = mount(buildSelect(disabledExpression), { global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper.findAll('.el-tag__close').length
}
test('Users.vue 的写法:已选中的停用角色 tag 保留关闭按钮', async () => {
expect(await closeIconCount('!role.enabled && !ids.includes(role.id)')).toBe(1)
})
test('静态 disabled 的写法:同一个 tag 没有关闭按钮(原死锁现场)', async () => {
expect(await closeIconCount('!role.enabled')).toBe(0)
})
})

View File

@ -32,7 +32,7 @@
:type="role.code === 'super_admin' ? 'danger' : 'info'"
class="role-tag"
>
{{ role.name }}
{{ roleLabel(role) }}
</el-tag>
<span v-if="!row.roles || row.roles.length === 0">-</span>
</template>
@ -60,13 +60,18 @@
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item label="角色" required>
<!-- disabled 依赖 form.role_ids 而不是写成静态的 !role.enabled:停用的角色只对
"尚未选中"的项禁用,已选中的必须留住关闭按钮。element-plus 把 tag 的
closable 绑在 option 的 isDisabled 上,所以这个表达式会跟着重算。若改回
静态判断,用户身上已有的停用角色就摘不掉,而保存又是全量替换角色,
这个用户将永远存不回去(后端会拒绝)。 -->
<el-select v-model="form.role_ids" multiple style="width: 100%" placeholder="请选择至少一个角色">
<el-option
v-for="role in roles"
:key="role.id"
:label="role.name"
:label="roleLabel(role)"
:value="role.id"
:disabled="!role.enabled"
:disabled="!role.enabled && !form.role_ids.includes(role.id)"
/>
</el-select>
</el-form-item>
@ -126,6 +131,13 @@ async function loadRoles() {
roles.value = response.data || []
}
// 列表的 tag 和编辑下拉都用它。已停用的角色照旧显示(用户可能正持有它,编辑时得
// 看得见、摘得掉),但必须把"已停用"标出来:停用意味着不再授予权限,只显示角色名
// 会让管理员以为这个角色还在生效。
function roleLabel(role) {
return role.enabled ? role.name : `${role.name}(已停用)`
}
function handleSearch() {
usersStore.fetchUsers(1, searchKeyword.value)
}

View File

@ -1076,8 +1076,8 @@ func (h *Handler) CreateUser(c *gin.Context) {
user, err := h.authSvc.CreateUser(req.Username, req.Password, req.Description, req.RoleIDs)
if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) {
response.BadRequest(c, err.Error())
if message := roleErrorText(err); message != "" {
response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@ -1107,8 +1107,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
err = h.authSvc.UpdateUser(id, req.Description, req.RoleIDs, req.Password)
if err != nil {
if errors.Is(err, repository.ErrInvalidRoles) || errors.Is(err, repository.ErrLastSuperAdmin) {
response.BadRequest(c, err.Error())
if message := roleErrorText(err); message != "" {
response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@ -1133,8 +1133,8 @@ func (h *Handler) DeleteUser(c *gin.Context) {
}
if err := h.authSvc.DeleteUser(id); err != nil {
if errors.Is(err, repository.ErrLastSuperAdmin) {
response.BadRequest(c, err.Error())
if message := roleErrorText(err); message != "" {
response.BadRequest(c, message)
return
}
response.InternalError(c, err.Error())
@ -1144,6 +1144,21 @@ func (h *Handler) DeleteUser(c *gin.Context) {
response.SuccessWithMessage(c, "user deleted successfully", nil)
}
// roleErrorText 把角色相关的仓储层错误翻成面向用户的中文,返回空字符串表示这个错误
// 与角色无关。翻译放在 handler 而非仓储层:仓储层的错误常量是给开发者看的标识,不该
// 承担界面文案。前端的 utils/errorText.js 只放行带中文的消息,英文会被换成笼统的
// "请求参数有误",用户看不出问题出在角色上。
func roleErrorText(err error) string {
switch {
case errors.Is(err, repository.ErrInvalidRoles):
return "所选角色无效或已被停用,请重新选择"
case errors.Is(err, repository.ErrLastSuperAdmin):
return "不能移除最后一个超级管理员"
default:
return ""
}
}
// Application handlers
func (h *Handler) CreateApplication(c *gin.Context) {

View File

@ -132,7 +132,7 @@ func (r *UserRepository) update(user *model.User, roleIDs []int64, updatePasswor
defer func() { _ = tx.Rollback() }()
roleIDs = uniqueRoleIDs(roleIDs)
if err := validateRolesTx(tx, roleIDs); err != nil {
if err := validateRolesTx(tx, user.ID, roleIDs); err != nil {
return err
}
if err := protectLastSuperAdminTx(tx, user.ID, roleIDs); err != nil {
@ -208,23 +208,37 @@ func loadRolesForUsers(queryer interface {
func assignRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
roleIDs = uniqueRoleIDs(roleIDs)
if err := validateRolesTx(tx, roleIDs); err != nil {
if err := validateRolesTx(tx, userID, roleIDs); err != nil {
return err
}
return insertUserRolesTx(tx, userID, roleIDs)
}
func validateRolesTx(tx *sql.Tx, roleIDs []int64) error {
// validateRolesTx 校验 roleIDs 是否都是可分配给该用户的角色。
// 可分配 = 角色处于启用状态,或它本来就是该用户当前持有的角色。
// - 新建用户时 user_roles 里还没有该用户的记录,EXISTS 恒为假,等价于原来的
// "只允许启用中的角色"。
// - 编辑用户时前端会把现有角色原样回传,其中可能含已被停用的。停用只是不再授予
// 权限(GenerateToken 会过滤掉停用角色),没必要把已有分配一并抹掉;若在这里
// 拒绝,管理员就再也保存不了这个用户:下拉里的停用角色是禁用项,已选中的 tag
// 也没有关闭按钮。
//
// 必须在 DELETE FROM user_roles 之前调用,否则 EXISTS 取不到旧分配。
func validateRolesTx(tx *sql.Tx, userID int64, roleIDs []int64) error {
if len(roleIDs) == 0 {
return ErrInvalidRoles
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(roleIDs)), ", ")
args := make([]interface{}, len(roleIDs))
for index, id := range roleIDs {
args[index] = id
args := make([]interface{}, 0, len(roleIDs)+1)
for _, id := range roleIDs {
args = append(args, id)
}
args = append(args, userID)
var count int
query := fmt.Sprintf(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (%s)`, placeholders)
query := fmt.Sprintf(`SELECT COUNT(*) FROM roles r
WHERE r.id IN (%s)
AND (r.enabled = TRUE OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`, placeholders)
if err := tx.QueryRow(query, args...).Scan(&count); err != nil {
return err
}

View File

@ -87,8 +87,11 @@ func TestUpdateRejectsRemovingRoleFromLastSuperAdmin(t *testing.T) {
database.DB = db
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?)`)).
WithArgs(int64(2)).
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
WHERE r.id IN (?)
AND (r.enabled = TRUE OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
WithArgs(int64(2), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
@ -110,6 +113,80 @@ func TestUpdateRejectsRemovingRoleFromLastSuperAdmin(t *testing.T) {
}
}
// 编辑用户时把已被停用的角色原样回传必须能保存成功。停用只是不再授予权限,不要求
// 管理员顺手把它从用户身上摘掉;旧实现在这里返回 ErrInvalidRoles,而前端下拉里的
// 停用角色是禁用项、已选中的 tag 也关不掉,结果这个用户再也编辑不了。
func TestUpdateKeepsDisabledRoleAlreadyAssignedToUser(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() { database.DB = nil; db.Close() })
database.DB = db
mock.ExpectBegin()
// count 命中 1:角色 3 虽然已停用,但该用户当前正持有它,EXISTS 放行。
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
WHERE r.id IN (?)
AND (r.enabled = TRUE OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
WithArgs(int64(3), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM roles WHERE code = 'super_admin' FOR UPDATE`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role_id = ?`)).
WithArgs(int64(7), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE users SET description = ? WHERE id = ?`)).
WithArgs("改个描述", int64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM user_roles WHERE user_id = ?`)).
WithArgs(int64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)`)).
WithArgs(int64(7), int64(3)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
user := &model.User{ID: 7, Description: "改个描述"}
if err := NewUserRepository().Update(user, []int64{3}); err != nil {
t.Fatalf("update user: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 反面:把一个该用户还没持有的停用角色塞进来仍要拒绝。EXISTS 为假,count 对不上,
// 这样"不能通过接口新分配停用角色"这条约束就没有被上面那个放行口子带松。
func TestUpdateRejectsNewlyAssignedDisabledRole(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() { database.DB = nil; db.Close() })
database.DB = db
mock.ExpectBegin()
// count 为 0:角色 4 已停用,且不在该用户当前持有的角色里。
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
WHERE r.id IN (?)
AND (r.enabled = TRUE OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
WithArgs(int64(4), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectRollback()
user := &model.User{ID: 7, Description: "改个描述"}
err = NewUserRepository().Update(user, []int64{4})
if !errors.Is(err, ErrInvalidRoles) {
t.Fatalf("update error = %v, want ErrInvalidRoles", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@ -122,8 +199,13 @@ func TestCreateUserAssignsMultipleEnabledRolesInTransaction(t *testing.T) {
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO users (username, password, description) VALUES (?, ?, ?)`)).
WithArgs("zhangsan", "hash", "测试用户").
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles WHERE enabled = TRUE AND id IN (?, ?)`)).
WithArgs(int64(2), int64(3)).
// 新建的用户在 user_roles 里还没有记录,EXISTS 恒为假,所以这里等价于
// "只允许启用中的角色"。
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM roles r
WHERE r.id IN (?, ?)
AND (r.enabled = TRUE OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.role_id = r.id AND ur.user_id = ?))`)).
WithArgs(int64(2), int64(3), int64(7)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?), (?, ?)`)).
WithArgs(int64(7), int64(2), int64(7), int64(3)).