fix: harden user management operations
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from web.backend.database import Base
|
||||
from web.backend.models import Account, AuditLog, LoginTask, User
|
||||
from web.backend.routers.users import delete_user, list_users, rename_user
|
||||
from web.backend.schemas import UserRename
|
||||
|
||||
|
||||
class UserDeletionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://")
|
||||
|
||||
@event.listens_for(self.engine, "connect")
|
||||
def enable_foreign_keys(connection, _):
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
||||
self.user = User(username="support", password_hash="hash", role="support")
|
||||
self.session.add_all([self.admin, self.user])
|
||||
self.session.commit()
|
||||
self.session.add(Account(
|
||||
username="douyu-user",
|
||||
password="password",
|
||||
email="mail@example.com",
|
||||
email_password="mail-password",
|
||||
assigned_to=self.user.id,
|
||||
))
|
||||
self.session.commit()
|
||||
account = self.session.query(Account).one()
|
||||
self.session.add(LoginTask(
|
||||
batch_id="batch-1",
|
||||
account_id=account.id,
|
||||
created_by=self.user.id,
|
||||
))
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_delete_user_soft_deletes_and_releases_assigned_accounts(self):
|
||||
result = delete_user(
|
||||
self.user.id,
|
||||
db=self.session,
|
||||
current=SimpleNamespace(id=self.admin.id, username=self.admin.username),
|
||||
)
|
||||
|
||||
deleted = self.session.get(User, self.user.id)
|
||||
account = self.session.query(Account).one()
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["released_accounts"], 1)
|
||||
self.assertFalse(deleted.is_active)
|
||||
self.assertIsNotNone(deleted.deleted_at)
|
||||
self.assertEqual(deleted.deleted_username, "support")
|
||||
self.assertTrue(deleted.username.startswith(f"deleted-{deleted.id}-"))
|
||||
self.assertIsNone(account.assigned_to)
|
||||
self.assertEqual(self.session.query(AuditLog).one().target, "support")
|
||||
self.assertEqual(self.session.query(LoginTask).one().created_by, deleted.id)
|
||||
self.assertEqual([item.id for item in list_users(db=self.session)], [self.admin.id])
|
||||
|
||||
def test_cannot_delete_current_user(self):
|
||||
with self.assertRaisesRegex(Exception, "不能删除当前登录用户"):
|
||||
delete_user(
|
||||
self.user.id,
|
||||
db=self.session,
|
||||
current=SimpleNamespace(id=self.user.id, username=self.user.username),
|
||||
)
|
||||
|
||||
def test_rename_preserves_user_id_and_related_data(self):
|
||||
result = rename_user(
|
||||
self.user.id,
|
||||
UserRename(username="renamed-support"),
|
||||
db=self.session,
|
||||
current=SimpleNamespace(id=self.admin.id, username=self.admin.username),
|
||||
)
|
||||
|
||||
renamed = self.session.get(User, self.user.id)
|
||||
account = self.session.query(Account).one()
|
||||
task = self.session.query(LoginTask).one()
|
||||
self.assertEqual(result.username, "renamed-support")
|
||||
self.assertEqual(renamed.id, self.user.id)
|
||||
self.assertEqual(account.assigned_to, self.user.id)
|
||||
self.assertEqual(task.created_by, self.user.id)
|
||||
self.assertEqual(self.session.query(AuditLog).one().target, "support -> renamed-support")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""支持用户软删除并保留历史任务归属"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260813_0023"
|
||||
down_revision: Union[str, None] = "20260812_0022"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _columns(bind, table_name: str) -> set[str]:
|
||||
return {column["name"] for column in sa.inspect(bind).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = _columns(bind, "users")
|
||||
if "deleted_at" not in columns:
|
||||
op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
||||
if "deleted_username" not in columns:
|
||||
op.add_column("users", sa.Column("deleted_username", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = _columns(bind, "users")
|
||||
if "deleted_username" in columns:
|
||||
op.drop_column("users", "deleted_username")
|
||||
if "deleted_at" in columns:
|
||||
op.drop_index("ix_users_deleted_at", table_name="users")
|
||||
op.drop_column("users", "deleted_at")
|
||||
@@ -34,6 +34,8 @@ class User(Base):
|
||||
custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
deleted_at = Column(DateTime, nullable=True, index=True)
|
||||
deleted_username = Column(String(64), default="")
|
||||
|
||||
# 客服被分配的账号
|
||||
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
||||
|
||||
@@ -6,6 +6,7 @@ PERMISSIONS = {
|
||||
"user:view": "查看用户列表",
|
||||
"user:create": "创建用户",
|
||||
"user:edit": "编辑用户",
|
||||
"user:rename": "修改用户名",
|
||||
"user:delete": "删除用户",
|
||||
"user:assign_permissions": "分配权限",
|
||||
# 账号管理
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""用户管理路由(超管)"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, AuditLog
|
||||
from ..models import Account, AuditLog, HuyaAccount, User
|
||||
from ..security import hash_password
|
||||
from ..permissions import ROLE_LABELS, PERMISSIONS, get_user_permissions
|
||||
from ..schemas import UserCreate, UserUpdate, UserInfo
|
||||
from ..deps import require_permission, get_current_user
|
||||
from ..permissions import PERMISSIONS, get_user_permissions
|
||||
from ..schemas import UserCreate, UserRename, UserUpdate, UserInfo
|
||||
from ..deps import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||
|
||||
@@ -18,7 +22,7 @@ def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("user:view")),
|
||||
):
|
||||
users = db.query(User).order_by(User.id).all()
|
||||
users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all()
|
||||
result = []
|
||||
for u in users:
|
||||
result.append(UserInfo(
|
||||
@@ -76,6 +80,8 @@ def update_user(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if req.password:
|
||||
user.password_hash = hash_password(req.password)
|
||||
@@ -103,6 +109,51 @@ def update_user(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/username", response_model=UserInfo)
|
||||
def rename_user(
|
||||
user_id: int,
|
||||
req: UserRename,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("user:rename")),
|
||||
):
|
||||
"""仅变更用户名,保留用户 ID 和全部关联业务数据。"""
|
||||
user = db.query(User).filter(User.id == user_id, User.deleted_at.is_(None)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
username = req.username.strip()
|
||||
if len(username) < 2:
|
||||
raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
|
||||
if username == user.username:
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
||||
)
|
||||
if db.query(User.id).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
old_username = user.username
|
||||
user.username = username
|
||||
db.add(AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:rename",
|
||||
target=f"{old_username} -> {username}",
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="用户名已存在") from exc
|
||||
db.refresh(user)
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
@@ -112,14 +163,39 @@ def delete_user(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if user.role == "super_admin":
|
||||
raise HTTPException(status_code=400, detail="不能删除超级管理员")
|
||||
if user.id == current.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除当前登录用户")
|
||||
|
||||
# 保留任务与审计记录的创建者 ID,避免删除用户时破坏历史数据。
|
||||
released_accounts = (
|
||||
db.query(Account)
|
||||
.filter(Account.assigned_to == user.id)
|
||||
.update({Account.assigned_to: None}, synchronize_session=False)
|
||||
)
|
||||
released_huya_accounts = (
|
||||
db.query(HuyaAccount)
|
||||
.filter(HuyaAccount.assigned_to == user.id)
|
||||
.update({HuyaAccount.assigned_to: None}, synchronize_session=False)
|
||||
)
|
||||
deleted_username = user.username
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:delete", target=user.username))
|
||||
db.delete(user)
|
||||
action="user:delete", target=deleted_username))
|
||||
user.is_active = False
|
||||
user.deleted_at = datetime.now(timezone.utc)
|
||||
user.deleted_username = deleted_username
|
||||
# 释放用户名供新用户使用,同时不改变历史任务通过 user.id 保存的归属。
|
||||
user.username = f"deleted-{user.id}-{uuid4().hex[:12]}"
|
||||
db.commit()
|
||||
return {"message": "已删除", "success": True}
|
||||
return {
|
||||
"message": "已删除",
|
||||
"success": True,
|
||||
"released_accounts": released_accounts,
|
||||
"released_huya_accounts": released_huya_accounts,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/permissions/list")
|
||||
|
||||
@@ -97,6 +97,10 @@ class UserUpdate(BaseModel):
|
||||
custom_permissions: Optional[list[str]] = None
|
||||
|
||||
|
||||
class UserRename(BaseModel):
|
||||
username: str = Field(..., min_length=2, max_length=64)
|
||||
|
||||
|
||||
# ---- 账号 ----
|
||||
class AccountImport(BaseModel):
|
||||
"""批量导入,文本格式:用户名|密码|邮箱|邮箱密码|标签(可选)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { lazy, Suspense, useState, useCallback, type ReactNode } from 'react';
|
||||
import { lazy, Suspense, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { App as AntdApp, ConfigProvider, Spin, theme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { getUser } from './store/auth';
|
||||
import { fetchCurrentUser, getUser } from './store/auth';
|
||||
import { ThemeProvider } from './store/theme';
|
||||
import { useTheme } from './store/useTheme';
|
||||
import AntdMessageBridge from './utils/AntdMessageBridge';
|
||||
@@ -44,6 +44,12 @@ function AppContent() {
|
||||
const isLoggedIn = !!getUser();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// localStorage 仅是前端缓存;每次加载应用时以服务端的实时权限为准。
|
||||
useEffect(() => {
|
||||
if (!getUser()) return;
|
||||
void fetchCurrentUser().finally(refreshAuth);
|
||||
}, [refreshAuth]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
|
||||
@@ -7,6 +7,8 @@ export const userApi = {
|
||||
api.post<UserInfo, UserInfo>('/users', data),
|
||||
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
|
||||
api.put<UserInfo, UserInfo>(`/users/${id}`, data),
|
||||
rename: (id: number, username: string) =>
|
||||
api.patch<UserInfo, UserInfo>(`/users/${id}/username`, { username }),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
|
||||
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, Space,
|
||||
Checkbox, Divider, Alert, Tooltip,
|
||||
Checkbox, Divider, Alert,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { userApi, type UserInfo } from '../api/modules';
|
||||
@@ -61,6 +62,11 @@ export default function UsersPage() {
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canAssignPerm = can('user:assign_permissions');
|
||||
const canCreate = can('user:create');
|
||||
const canEdit = can('user:edit');
|
||||
const canRename = can('user:rename');
|
||||
const canDelete = can('user:delete');
|
||||
const canManageUser = canEdit || canRename;
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -87,6 +93,7 @@ export default function UsersPage() {
|
||||
|
||||
const handleEdit = (user: UserInfo) => {
|
||||
setEditing(user);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
@@ -99,9 +106,14 @@ export default function UsersPage() {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editing) {
|
||||
if (canRename && values.username !== editing.username) {
|
||||
await userApi.rename(editing.id, values.username);
|
||||
}
|
||||
if (canEdit) {
|
||||
const updateData: { role: string; remark: string; password?: string } = { role: values.role, remark: values.remark };
|
||||
if (values.password) updateData.password = values.password;
|
||||
await userApi.update(editing.id, updateData);
|
||||
}
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await userApi.create(values);
|
||||
@@ -170,12 +182,13 @@ export default function UsersPage() {
|
||||
setSelectedPerms(rolePerms);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
const columns: TableProps<UserInfo>['columns'] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{ title: '用户名', dataIndex: 'username', width: 128, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 100,
|
||||
render: (role: string) => {
|
||||
const label = ROLE_OPTIONS.find((r) => r.value === role)?.label || role;
|
||||
return <Tag color={ROLE_COLORS[role] || 'default'}>{label}</Tag>;
|
||||
@@ -196,21 +209,27 @@ export default function UsersPage() {
|
||||
width: 80,
|
||||
render: (active: boolean) => active ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '备注', dataIndex: 'remark', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
align: 'left',
|
||||
render: (_: unknown, record: UserInfo) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
{canAssignPerm && (
|
||||
<Tooltip title="权限管理">
|
||||
<Button size="small" icon={<SafetyOutlined />} onClick={() => handlePermClick(record)}>权限</Button>
|
||||
</Tooltip>
|
||||
<Space size={4} wrap={false}>
|
||||
{canManageUser && (
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
)}
|
||||
{record.role !== 'super_admin' && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
{canAssignPerm && (
|
||||
<Button size="small" type="text" icon={<SafetyOutlined />} onClick={() => handlePermClick(record)}>权限</Button>
|
||||
)}
|
||||
{canDelete && record.role !== 'super_admin' && (
|
||||
<Popconfirm
|
||||
title="确认删除该用户?"
|
||||
description="该用户将被停用并解除账号分配,历史任务会保留。"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button danger size="small" type="text" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -222,7 +241,7 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建用户</Button>
|
||||
{canCreate && <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建用户</Button>}
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
@@ -230,6 +249,8 @@ export default function UsersPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 844 }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
@@ -252,24 +273,30 @@ export default function UsersPage() {
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave}
|
||||
okText="保存"
|
||||
okText={editing && !canEdit ? '保存用户名' : '保存'}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input disabled={!!editing} />
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
extra={editing && canRename ? '改名不会改变用户 ID,账号分配、任务、支付和审计记录均会保留。' : undefined}
|
||||
rules={[{ required: true, message: '请输入用户名' }, { min: 2, max: 64, message: '用户名长度需为 2-64 个字符' }]}
|
||||
>
|
||||
<Input disabled={!!editing && !canRename} autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={editing ? '新密码(留空不修改)' : '密码'}
|
||||
rules={editing ? [] : [{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
<Input.Password disabled={!!editing && !canEdit} autoComplete="new-password" placeholder={editing ? '留空不修改' : undefined} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={ROLE_OPTIONS} />
|
||||
<Select disabled={!!editing && !canEdit} options={ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input />
|
||||
<Input disabled={!!editing && !canEdit} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user