需求 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 <noreply@anthropic.com>
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""用户管理业务编排。
|
||
|
||
**事务边界在本层**:每个公开方法自行决定何时 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)
|