From e22c4bcdc9b16f7a09fcc4214b6aa3d1a7e3e6bf Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 10:44:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 2.5.1 用户账号管理,全部要求管理员权限(require_admin 依赖): - 接口:列表/详情/新增/编辑/删除/重置密码/启停/角色下拉, 角色下拉放 /users/meta/roles 以避开 /{user_id} 的路由匹配冲突 - 删除、禁用、改角色三处自我保护:不允许对当前登录账号执行, 避免管理员误操作后立刻失去全部管理权限且无法自行恢复 - 重置密码与禁用均提升 token_version,使已签发令牌立即失效, 不依赖 Redis 黑名单 - 密码按字节数校验(bcrypt 5.0 硬上限 72 字节,中文一字 3 字节) - 用户列表按 id 倒序:create_time 为秒级精度,同秒数据分页结果不确定 - 修复 replace_roles 未同步关系集合的缺陷:改用 user.user_roles 集合追加,此前 session.add(UserRole(...)) 会表现为保存成功但返回空角色 - update_user 的自我保护原先依赖返回 None 的占位函数,恒不生效, 改为显式传入 operator_id Co-Authored-By: Claude Code --- backend/app/api/deps.py | 20 +- backend/app/api/router.py | 7 + backend/app/api/v1/system/__init__.py | 7 + backend/app/api/v1/system/users.py | 131 +++++++++++ backend/app/repositories/user_repository.py | 131 ++++++++++- backend/app/schemas/user.py | 126 +++++++++++ backend/app/services/user_service.py | 232 ++++++++++++++++++++ 7 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/v1/system/__init__.py create mode 100644 backend/app/api/v1/system/users.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6115b33..3dba999 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -6,8 +6,9 @@ from fastapi import Depends from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.core.constants import ROLE_ADMIN from app.core.db import get_db -from app.core.exceptions import AuthError +from app.core.exceptions import AuthError, ForbiddenError from app.core.security import decode_token from app.models import User from app.repositories import user_repository @@ -71,3 +72,20 @@ def _is_digits(value: object) -> bool: 令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。 """ return isinstance(value, str) and value.isdigit() + + +def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User: + """要求当前用户具备管理员角色。 + + 需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个 + 角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions` + 与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。 + + :raises ForbiddenError: 已登录但不具备管理员角色(403) + """ + if ROLE_ADMIN not in user_repository.get_role_codes(current_user): + raise ForbiddenError("需要管理员权限") + return current_user + + +AdminUser = Annotated[User, Depends(require_admin)] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 692fa8a..67b1529 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.system import users as system_users api_router = APIRouter() @@ -17,4 +18,10 @@ api_router.include_router(health.router) # 业务接口:/api/v1/* v1_router = APIRouter(prefix="/v1") v1_router.include_router(auth.router) + +# 系统管理(需求 2.5.1):/api/v1/system/* +system_router = APIRouter(prefix="/system") +system_router.include_router(system_users.router) +v1_router.include_router(system_router) + api_router.include_router(v1_router) diff --git a/backend/app/api/v1/system/__init__.py b/backend/app/api/v1/system/__init__.py new file mode 100644 index 0000000..6e2813b --- /dev/null +++ b/backend/app/api/v1/system/__init__.py @@ -0,0 +1,7 @@ +"""系统管理路由(需求 2.5.1)。 + +含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。 + +与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录, +以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。 +""" diff --git a/backend/app/api/v1/system/users.py b/backend/app/api/v1/system/users.py new file mode 100644 index 0000000..c9e6432 --- /dev/null +++ b/backend/app/api/v1/system/users.py @@ -0,0 +1,131 @@ +"""用户管理接口(需求 2.5.1:用户账号管理)。 + +全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」, +用户管理属管理员专属。 + +删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行, +否则管理员误操作会立刻失去全部管理权限且无法自行恢复。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.user import ( + PasswordResetIn, + RoleOut, + UserCreateIn, + UserOut, + UserQuery, + UserStatusIn, + UserUpdateIn, +) +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["系统管理 · 用户"]) + + +@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表") +def list_users( + session: DbSession, + _: AdminUser, + query: Annotated[UserQuery, Query()], +) -> ApiResponse[PageResult[UserOut]]: + """分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。""" + return ok(user_service.list_users(session, query)) + + +@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情") +def get_user( + session: DbSession, + _: AdminUser, + user_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[UserOut]: + """查询用户详情。""" + return ok(user_service.get_user(session, user_id)) + + +@router.post("", response_model=ApiResponse[UserOut], summary="新增用户") +def create_user( + session: DbSession, + _: AdminUser, + payload: UserCreateIn, +) -> ApiResponse[UserOut]: + """新增用户。用户名须唯一,至少指定一个角色。""" + return ok(user_service.create_user(session, payload), message="用户已创建") + + +@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户") +def update_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserUpdateIn, +) -> ApiResponse[UserOut]: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。 + """ + return ok( + user_service.update_user(session, user_id, payload, operator_id=current_user.id), + message="已保存", + ) + + +@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户") +def delete_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[None]: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。 + 业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。 + """ + user_service.delete_user(session, user_id, operator_id=current_user.id) + return ok(message="用户已删除") + + +@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码") +def reset_password( + session: DbSession, + _: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: PasswordResetIn, +) -> ApiResponse[None]: + """重置用户密码。 + + 会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** —— + 否则改密后旧令牌在有效期内仍可用。 + """ + user_service.reset_password(session, user_id, payload.new_password) + return ok(message="密码已重置,该用户需重新登录") + + +@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户") +def set_status( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserStatusIn, +) -> ApiResponse[UserOut]: + """启用或禁用用户。 + + 禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌 + 继续访问直到过期(最长 2 小时)。 + """ + result = user_service.set_status(session, user_id, payload.status, operator_id=current_user.id) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项") +def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]: + """取全部启用角色,供用户表单的角色选择使用。 + + 路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与 + `/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。 + """ + return ok(user_service.list_roles(session)) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 314968b..c99c3bc 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -5,10 +5,18 @@ from datetime import datetime -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session, selectinload -from app.models import User +from app.models import Role, User, UserRole + + +def _base_query() -> Select[tuple[User]]: + """用户查询基座。 + + 预加载角色关联,避免列表渲染时逐行查角色(N+1)。 + """ + return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role)) def get_by_username(session: Session, username: str) -> User | None: @@ -34,3 +42,120 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None: """更新最后登录时间。只 flush,提交由 service 决定。""" user.last_login_time = moment session.flush() + + +# ==================== 列表查询 ==================== + + +def list_users( + session: Session, + *, + offset: int, + limit: int, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> list[User]: + """分页查询用户列表。 + + **按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户 + 可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。 + id 是自增主键,天然唯一且与创建顺序一致。 + """ + stmt = _base_query() + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + # 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略 + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars().unique()) + + +def count_users( + session: Session, + *, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> int: + """统计符合条件的用户总数,供分页使用。""" + stmt = select(func.count(User.id)) + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + username: str | None, + real_name: str | None, + status: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if username: + stmt = stmt.where(User.username.like(f"%{username}%")) + if real_name: + stmt = stmt.where(User.real_name.like(f"%{real_name}%")) + if status is not None: + stmt = stmt.where(User.status == status) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, user: User) -> User: + """新增用户。只 flush 以取得自增 id。""" + session.add(user) + session.flush() + return user + + +def delete(session: Session, user: User) -> None: + """物理删除用户。 + + 本项目对用户采用「删除即禁用」的**业务**约定(见模型注释), + 但保留物理删除能力供测试清理与误建账号的彻底移除使用。 + `user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL, + 故不会留下悬挂引用。 + """ + session.delete(user) + session.flush() + + +# ==================== 角色 ==================== + + +def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]: + """按编码批量取角色。用于校验前端传来的角色是否存在。""" + if not role_codes: + return [] + stmt = select(Role).where(Role.role_code.in_(role_codes)) + return list(session.execute(stmt).scalars()) + + +def replace_roles(session: Session, user: User, roles: list[Role]) -> None: + """整体替换用户的角色。 + + 采用「先清后建」而非逐条比对:角色数量极少(本期仅两个), + 差异计算的复杂度换不来收益,且整体替换的语义更直白。 + + **必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**: + 后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内 + 再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色 + 列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。 + """ + user.user_roles.clear() + for role in roles: + user.user_roles.append(UserRole(role_id=role.id)) + session.flush() + + +def get_all_roles(session: Session) -> list[Role]: + """取全部启用角色,供前端下拉选择。""" + stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id) + return list(session.execute(stmt).scalars()) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..be21775 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,126 @@ +"""用户与角色的请求/响应模型。 + +与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户), +本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。 + +字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES +from app.core.response import PageQuery + + +def _validate_password_bytes(value: str) -> str: + """按**字节数**校验密码长度。 + + 必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError, + 而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。 + 与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。 + """ + length = len(value.encode("utf-8")) + if length < PASSWORD_MIN_BYTES: + raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节") + if length > PASSWORD_MAX_BYTES: + raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限") + return value + + +class UserOut(BaseModel): + """用户列表/详情响应。 + + 不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + real_name: str | None = None + phone: str | None = None + email: str | None = None + status: int = Field(description="状态:1启用 0禁用") + roles: list[str] = Field(default_factory=list, description="角色编码列表") + last_login_time: datetime | None = None + remark: str | None = None + create_time: datetime | None = None + + +class UserCreateIn(BaseModel): + """新增用户。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一") + password: str = Field(description="初始密码") + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserUpdateIn(BaseModel): + """编辑用户。 + + **不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史 + 操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] | None = Field(default=None, description="传 null 表示不改角色") + remark: str | None = Field(default=None, max_length=255) + + +class PasswordResetIn(BaseModel): + """重置密码(管理员操作,不需要原密码)。""" + + new_password: str = Field(description="新密码") + + @field_validator("new_password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserStatusIn(BaseModel): + """启用/禁用用户。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class UserQuery(PageQuery): + """用户列表查询条件。 + + 继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。 + """ + + username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配") + real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") + role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选") + + +class RoleOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + description: str | None = None + sort_order: int = 0 + status: int = 1 diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..8811a49 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,232 @@ +"""用户管理业务编排。 + +**事务边界在本层**:每个公开方法自行决定何时 commit。 +repository 只 flush,事务由这里控制。 + +设计取舍: +- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议 + (停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了 + 「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username + 保证删除后仍可追溯 +- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限, + 且没有其他账号可以救回来 +""" + +import logging + +from sqlalchemy.orm import Session + +from app.core.constants import ROLE_ADMIN +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.core.security import hash_password +from app.models import User +from app.repositories import user_repository +from app.schemas.user import RoleOut, UserCreateIn, UserOut, UserQuery, UserUpdateIn + +logger = logging.getLogger(__name__) + + +def _to_out(user: User) -> UserOut: + """ORM 对象转响应模型。 + + 禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。 + """ + return UserOut( + id=user.id, + username=user.username, + real_name=user.real_name, + phone=user.phone, + email=user.email, + status=user.status, + roles=user_repository.get_role_codes(user), + last_login_time=user.last_login_time, + remark=user.remark, + create_time=user.create_time, + ) + + +def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]: + """分页查询用户列表。""" + users = user_repository.list_users( + session, + offset=query.offset, + limit=query.page_size, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + total = user_repository.count_users( + session, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + return PageResult[UserOut]( + records=[_to_out(user) for user in users], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_user(session: Session, user_id: int) -> UserOut: + """查询用户详情。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + return _to_out(user) + + +def create_user(session: Session, payload: UserCreateIn) -> UserOut: + """新增用户。 + + :raises BadRequestError: 用户名已存在,或角色编码无效 + """ + if user_repository.get_by_username(session, payload.username) is not None: + raise BadRequestError(f"用户名 {payload.username!r} 已存在") + + roles = _resolve_roles(session, payload.roles) + + user = User( + username=payload.username, + password=hash_password(payload.password), + real_name=payload.real_name, + phone=payload.phone, + email=payload.email, + status=1, + remark=payload.remark, + ) + user_repository.create(session, user) + user_repository.replace_roles(session, user, roles) + session.commit() + + logger.info("新增用户 | username=%s | roles=%s", user.username, payload.roles) + return _to_out(user) + + +def update_user( + session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int +) -> UserOut: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口, + 以免"改资料"顺手改掉密码这类越权操作。 + + :raises NotFoundError: 用户不存在 + :raises BadRequestError: 角色编码无效 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if payload.real_name is not None: + user.real_name = payload.real_name + if payload.phone is not None: + user.phone = payload.phone + if payload.email is not None: + user.email = payload.email + if payload.remark is not None: + user.remark = payload.remark + + # 不允许把自己降级掉管理角色,否则改完立刻失去管理权限 + if payload.roles is not None and user_id == operator_id and ROLE_ADMIN not in payload.roles: + raise ForbiddenError("不能移除自己的管理员角色") + + session.flush() + + if payload.roles is not None: + roles = _resolve_roles(session, payload.roles) + user_repository.replace_roles(session, user, roles) + + session.commit() + logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles) + return _to_out(user) + + +def reset_password(session: Session, user_id: int, new_password: str) -> None: + """重置用户密码。 + + 同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** —— + 改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + user.password = hash_password(new_password) + user.token_version += 1 + session.commit() + logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id) + + +def set_status(session: Session, user_id: int, status: int, *, operator_id: int) -> UserOut: + """启用或禁用用户。 + + 禁用时同样提升 `token_version`,使已签发令牌立即失效 —— + 否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图禁用自己 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if status == 0 and user_id == operator_id: + raise ForbiddenError("不能禁用自己") + + user.status = status + if status == 0: + user.token_version += 1 + session.commit() + logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status) + return _to_out(user) + + +def delete_user(session: Session, user_id: int, *, operator_id: int) -> None: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图删除自己 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if user_id == operator_id: + raise ForbiddenError("不能删除自己") + + username = user.username + user_repository.delete(session, user) + session.commit() + logger.info("删除用户 | username=%s", username) + + +def list_roles(session: Session) -> list[RoleOut]: + """取全部启用角色,供前端下拉选择。""" + return [RoleOut.model_validate(role) for role in user_repository.get_all_roles(session)] + + +def _resolve_roles(session: Session, role_codes: list[str]) -> list: + """把角色编码解析为角色对象,并校验全部存在。 + + :raises BadRequestError: 存在无效编码 + """ + if not role_codes: + raise BadRequestError("至少需要指定一个角色") + roles = user_repository.get_roles_by_codes(session, role_codes) + if len(roles) != len(set(role_codes)): + valid = {role.role_code for role in roles} + invalid = sorted(set(role_codes) - valid) + raise BadRequestError(f"角色编码无效:{invalid}") + return list(roles)