fix: harden user management operations

This commit is contained in:
yml2213
2026-08-13 15:07:33 +08:00
parent b07c5fdbf6
commit 090698e6b2
9 changed files with 286 additions and 33 deletions
+99
View File
@@ -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")
+2
View File
@@ -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")
+1
View File
@@ -6,6 +6,7 @@ PERMISSIONS = {
"user:view": "查看用户列表",
"user:create": "创建用户",
"user:edit": "编辑用户",
"user:rename": "修改用户名",
"user:delete": "删除用户",
"user:assign_permissions": "分配权限",
# 账号管理
+84 -8
View File
@@ -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")
+4
View File
@@ -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):
"""批量导入,文本格式:用户名|密码|邮箱|邮箱密码|标签(可选)
+8 -2
View File
@@ -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}
+2
View File
@@ -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'),
};
+50 -23
View File
@@ -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) {
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);
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>