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表示使用角色默认权限") custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_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") assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
+1
View File
@@ -6,6 +6,7 @@ PERMISSIONS = {
"user:view": "查看用户列表", "user:view": "查看用户列表",
"user:create": "创建用户", "user:create": "创建用户",
"user:edit": "编辑用户", "user:edit": "编辑用户",
"user:rename": "修改用户名",
"user:delete": "删除用户", "user:delete": "删除用户",
"user:assign_permissions": "分配权限", "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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User, AuditLog from ..models import Account, AuditLog, HuyaAccount, User
from ..security import hash_password from ..security import hash_password
from ..permissions import ROLE_LABELS, PERMISSIONS, get_user_permissions from ..permissions import PERMISSIONS, get_user_permissions
from ..schemas import UserCreate, UserUpdate, UserInfo from ..schemas import UserCreate, UserRename, UserUpdate, UserInfo
from ..deps import require_permission, get_current_user from ..deps import require_permission
router = APIRouter(prefix="/api/users", tags=["用户管理"]) router = APIRouter(prefix="/api/users", tags=["用户管理"])
@@ -18,7 +22,7 @@ def list_users(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_permission("user:view")), _: 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 = [] result = []
for u in users: for u in users:
result.append(UserInfo( result.append(UserInfo(
@@ -76,6 +80,8 @@ def update_user(
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
if user.deleted_at is not None:
raise HTTPException(status_code=404, detail="用户不存在")
if req.password: if req.password:
user.password_hash = hash_password(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}") @router.delete("/{user_id}")
def delete_user( def delete_user(
user_id: int, user_id: int,
@@ -112,14 +163,39 @@ def delete_user(
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
if user.deleted_at is not None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "super_admin": if user.role == "super_admin":
raise HTTPException(status_code=400, detail="不能删除超级管理员") 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, db.add(AuditLog(user_id=current.id, username=current.username,
action="user:delete", target=user.username)) action="user:delete", target=deleted_username))
db.delete(user) 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() db.commit()
return {"message": "已删除", "success": True} return {
"message": "已删除",
"success": True,
"released_accounts": released_accounts,
"released_huya_accounts": released_huya_accounts,
}
@router.get("/permissions/list") @router.get("/permissions/list")
+4
View File
@@ -97,6 +97,10 @@ class UserUpdate(BaseModel):
custom_permissions: Optional[list[str]] = None custom_permissions: Optional[list[str]] = None
class UserRename(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
# ---- 账号 ---- # ---- 账号 ----
class AccountImport(BaseModel): 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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { App as AntdApp, ConfigProvider, Spin, theme } from 'antd'; import { App as AntdApp, ConfigProvider, Spin, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import { getUser } from './store/auth'; import { fetchCurrentUser, getUser } from './store/auth';
import { ThemeProvider } from './store/theme'; import { ThemeProvider } from './store/theme';
import { useTheme } from './store/useTheme'; import { useTheme } from './store/useTheme';
import AntdMessageBridge from './utils/AntdMessageBridge'; import AntdMessageBridge from './utils/AntdMessageBridge';
@@ -44,6 +44,12 @@ function AppContent() {
const isLoggedIn = !!getUser(); const isLoggedIn = !!getUser();
const { isDark } = useTheme(); const { isDark } = useTheme();
// localStorage 仅是前端缓存;每次加载应用时以服务端的实时权限为准。
useEffect(() => {
if (!getUser()) return;
void fetchCurrentUser().finally(refreshAuth);
}, [refreshAuth]);
return ( return (
<ConfigProvider <ConfigProvider
locale={zhCN} locale={zhCN}
+2
View File
@@ -7,6 +7,8 @@ export const userApi = {
api.post<UserInfo, UserInfo>('/users', data), api.post<UserInfo, UserInfo>('/users', data),
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) => update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) =>
api.put<UserInfo, UserInfo>(`/users/${id}`, data), 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}`), delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'), listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
}; };
+47 -20
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { import {
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, Space, Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, Space,
Checkbox, Divider, Alert, Tooltip, Checkbox, Divider, Alert,
} from 'antd'; } from 'antd';
import type { TableProps } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
import { userApi, type UserInfo } from '../api/modules'; import { userApi, type UserInfo } from '../api/modules';
@@ -61,6 +62,11 @@ export default function UsersPage() {
const { can } = usePermissions(); const { can } = usePermissions();
const canAssignPerm = can('user:assign_permissions'); 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 () => { const loadUsers = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -87,6 +93,7 @@ export default function UsersPage() {
const handleEdit = (user: UserInfo) => { const handleEdit = (user: UserInfo) => {
setEditing(user); setEditing(user);
form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
username: user.username, username: user.username,
role: user.role, role: user.role,
@@ -99,9 +106,14 @@ export default function UsersPage() {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
if (editing) { 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 }; const updateData: { role: string; remark: string; password?: string } = { role: values.role, remark: values.remark };
if (values.password) updateData.password = values.password; if (values.password) updateData.password = values.password;
await userApi.update(editing.id, updateData); await userApi.update(editing.id, updateData);
}
message.success('已更新'); message.success('已更新');
} else { } else {
await userApi.create(values); await userApi.create(values);
@@ -170,12 +182,13 @@ export default function UsersPage() {
setSelectedPerms(rolePerms); setSelectedPerms(rolePerms);
}; };
const columns = [ const columns: TableProps<UserInfo>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 60 }, { title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username', width: 128, ellipsis: true },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 100,
render: (role: string) => { render: (role: string) => {
const label = ROLE_OPTIONS.find((r) => r.value === role)?.label || role; const label = ROLE_OPTIONS.find((r) => r.value === role)?.label || role;
return <Tag color={ROLE_COLORS[role] || 'default'}>{label}</Tag>; return <Tag color={ROLE_COLORS[role] || 'default'}>{label}</Tag>;
@@ -196,21 +209,27 @@ export default function UsersPage() {
width: 80, width: 80,
render: (active: boolean) => active ? <Tag color="green"></Tag> : <Tag></Tag>, render: (active: boolean) => active ? <Tag color="green"></Tag> : <Tag></Tag>,
}, },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', width: 180, ellipsis: true },
{ {
title: '操作', title: '操作',
width: 200, width: 200,
fixed: 'right',
align: 'left',
render: (_: unknown, record: UserInfo) => ( render: (_: unknown, record: UserInfo) => (
<Space> <Space size={4} wrap={false}>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button> {canManageUser && (
{canAssignPerm && ( <Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Tooltip title="权限管理">
<Button size="small" icon={<SafetyOutlined />} onClick={() => handlePermClick(record)}></Button>
</Tooltip>
)} )}
{record.role !== 'super_admin' && ( {canAssignPerm && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}> <Button size="small" type="text" icon={<SafetyOutlined />} onClick={() => handlePermClick(record)}></Button>
<Button danger size="small" icon={<DeleteOutlined />}></Button> )}
{canDelete && record.role !== 'super_admin' && (
<Popconfirm
title="确认删除该用户?"
description="该用户将被停用并解除账号分配,历史任务会保留。"
onConfirm={() => handleDelete(record.id)}
>
<Button danger size="small" type="text" icon={<DeleteOutlined />}></Button>
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
@@ -222,7 +241,7 @@ export default function UsersPage() {
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2> <h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button> {canCreate && <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}
</div> </div>
<Table <Table
columns={columns} columns={columns}
@@ -230,6 +249,8 @@ export default function UsersPage() {
rowKey="id" rowKey="id"
loading={loading} loading={loading}
size="small" size="small"
tableLayout="fixed"
scroll={{ x: 844 }}
pagination={{ pagination={{
current: currentPage, current: currentPage,
pageSize, pageSize,
@@ -252,24 +273,30 @@ export default function UsersPage() {
open={modalOpen} open={modalOpen}
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onOk={handleSave} onOk={handleSave}
okText="保存" okText={editing && !canEdit ? '保存用户名' : '保存'}
width={560}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}> <Form.Item
<Input disabled={!!editing} /> 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>
<Form.Item <Form.Item
name="password" name="password"
label={editing ? '新密码(留空不修改)' : '密码'} label={editing ? '新密码(留空不修改)' : '密码'}
rules={editing ? [] : [{ required: true, message: '请输入密码' }]} rules={editing ? [] : [{ required: true, message: '请输入密码' }]}
> >
<Input.Password /> <Input.Password disabled={!!editing && !canEdit} autoComplete="new-password" placeholder={editing ? '留空不修改' : undefined} />
</Form.Item> </Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}> <Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={ROLE_OPTIONS} /> <Select disabled={!!editing && !canEdit} options={ROLE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label="备注">
<Input /> <Input disabled={!!editing && !canEdit} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>