diff --git a/tests/test_user_deletion.py b/tests/test_user_deletion.py new file mode 100644 index 0000000..392823a --- /dev/null +++ b/tests/test_user_deletion.py @@ -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() diff --git a/web/backend/migrations/versions/20260813_0023_soft_delete_users.py b/web/backend/migrations/versions/20260813_0023_soft_delete_users.py new file mode 100644 index 0000000..cf8ad5b --- /dev/null +++ b/web/backend/migrations/versions/20260813_0023_soft_delete_users.py @@ -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") diff --git a/web/backend/models.py b/web/backend/models.py index c9ef83a..c4273e1 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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") diff --git a/web/backend/permissions.py b/web/backend/permissions.py index b5ba135..2a9ae51 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -6,6 +6,7 @@ PERMISSIONS = { "user:view": "查看用户列表", "user:create": "创建用户", "user:edit": "编辑用户", + "user:rename": "修改用户名", "user:delete": "删除用户", "user:assign_permissions": "分配权限", # 账号管理 diff --git a/web/backend/routers/users.py b/web/backend/routers/users.py index a1a5ea3..0f54e22 100644 --- a/web/backend/routers/users.py +++ b/web/backend/routers/users.py @@ -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") diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 47969e7..4e17594 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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): """批量导入,文本格式:用户名|密码|邮箱|邮箱密码|标签(可选) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index df253db..2d35d3d 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -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 ( ('/users', data), update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string; custom_permissions?: string[] | null }) => api.put(`/users/${id}`, data), + rename: (id: number, username: string) => + api.patch(`/users/${id}/username`, { username }), delete: (id: number) => api.delete(`/users/${id}`), listPermissions: () => api.get('/users/permissions/list'), }; diff --git a/web/frontend/src/pages/UsersPage.tsx b/web/frontend/src/pages/UsersPage.tsx index f6eb266..ac7b4ec 100644 --- a/web/frontend/src/pages/UsersPage.tsx +++ b/web/frontend/src/pages/UsersPage.tsx @@ -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['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 {label}; @@ -196,21 +209,27 @@ export default function UsersPage() { width: 80, render: (active: boolean) => active ? 启用 : 禁用, }, - { title: '备注', dataIndex: 'remark', ellipsis: true }, + { title: '备注', dataIndex: 'remark', width: 180, ellipsis: true }, { title: '操作', width: 200, + fixed: 'right', + align: 'left', render: (_: unknown, record: UserInfo) => ( - - - {canAssignPerm && ( - - - + + {canManageUser && ( + )} - {record.role !== 'super_admin' && ( - handleDelete(record.id)}> - + {canAssignPerm && ( + + )} + {canDelete && record.role !== 'super_admin' && ( + handleDelete(record.id)} + > + )} @@ -222,7 +241,7 @@ export default function UsersPage() {

用户管理

- + {canCreate && }
setModalOpen(false)} onOk={handleSave} - okText="保存" + okText={editing && !canEdit ? '保存用户名' : '保存'} + width={560} > - - + + - + - - +