2026-10-09 22:44:13 -04:00
|
|
|
|
"""用户管理业务编排。
|
|
|
|
|
|
|
|
|
|
|
|
**事务边界在本层**:每个公开方法自行决定何时 commit。
|
|
|
|
|
|
repository 只 flush,事务由这里控制。
|
|
|
|
|
|
|
|
|
|
|
|
设计取舍:
|
2026-10-10 04:28:04 -04:00
|
|
|
|
|
2026-10-09 22:44:13 -04:00
|
|
|
|
- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议
|
|
|
|
|
|
(停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了
|
|
|
|
|
|
「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username
|
|
|
|
|
|
保证删除后仍可追溯
|
|
|
|
|
|
- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限,
|
|
|
|
|
|
且没有其他账号可以救回来
|
2026-10-10 04:28:04 -04:00
|
|
|
|
- 全部写操作经 `audit_service.audit()` 留痕,**成功与失败都记**。摘要需要
|
|
|
|
|
|
「目标用户名」,故对目标的只读预取放在审计上下文**之前** —— 这样目标
|
|
|
|
|
|
不存在(NotFound 失败)时也能留下可读的记录
|
|
|
|
|
|
- **响应对象在 commit 之前构造**(`_to_out` 读的是内存中的已加载状态):
|
|
|
|
|
|
commit 后再触碰 ORM 对象会因过期而触发额外查询,该查询一旦抛错,
|
|
|
|
|
|
「已提交成功」的操作会被审计记成失败
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-10-09 22:44:13 -04:00
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
ACTION_CREATE,
|
|
|
|
|
|
ACTION_DELETE,
|
|
|
|
|
|
ACTION_DISABLE,
|
|
|
|
|
|
ACTION_ENABLE,
|
|
|
|
|
|
ACTION_RESET_PASSWORD,
|
|
|
|
|
|
ACTION_UPDATE,
|
|
|
|
|
|
MODULE_USER,
|
|
|
|
|
|
ROLE_ADMIN,
|
|
|
|
|
|
)
|
2026-10-09 22:44:13 -04:00
|
|
|
|
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
|
2026-10-10 04:28:04 -04:00
|
|
|
|
from app.repositories import role_repository, user_repository
|
|
|
|
|
|
from app.schemas.role import RoleOut
|
|
|
|
|
|
from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn
|
|
|
|
|
|
from app.services import audit_service
|
|
|
|
|
|
from app.services.audit_service import AuditRecord, RequestMeta
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
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]:
|
|
|
|
|
|
"""分页查询用户列表。"""
|
2026-10-22 07:43:20 -04:00
|
|
|
|
users = user_repository.list_users(session, query)
|
|
|
|
|
|
total = user_repository.count_users(session, query)
|
2026-10-09 22:44:13 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
def create_user(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
payload: UserCreateIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> UserOut:
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""新增用户。
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
:raises BadRequestError: 用户名已存在,或角色编码无效/已停用
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""
|
2026-10-10 04:28:04 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_USER,
|
|
|
|
|
|
action=ACTION_CREATE,
|
|
|
|
|
|
summary=f"新增用户 {payload.username}",
|
|
|
|
|
|
# password 会随字典进入审计管道,由 repository 层的结构化脱敏抹除其值
|
|
|
|
|
|
request_data=payload.model_dump(exclude_none=True),
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
if user_repository.get_by_username(session, payload.username) is not None:
|
|
|
|
|
|
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
user_repository.create(session, user)
|
|
|
|
|
|
user_repository.replace_roles(session, user, roles)
|
|
|
|
|
|
out = _to_out(user)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
|
# 先查后写不是原子操作:并发提交同一用户名会撞 uk_users_username。
|
|
|
|
|
|
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
|
|
|
|
|
|
session.rollback()
|
|
|
|
|
|
raise BadRequestError(f"用户名 {payload.username!r} 已存在") from exc
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles)
|
|
|
|
|
|
return out
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_user(
|
2026-10-10 04:28:04 -04:00
|
|
|
|
session: Session,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
payload: UserUpdateIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
2026-10-09 22:44:13 -04:00
|
|
|
|
) -> UserOut:
|
|
|
|
|
|
"""编辑用户的基本信息与角色。
|
|
|
|
|
|
|
|
|
|
|
|
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
|
|
|
|
|
|
以免"改资料"顺手改掉密码这类越权操作。
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交,
|
|
|
|
|
|
若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆);
|
|
|
|
|
|
归一后「清空输入框」= 写回 NULL,语义正确。
|
|
|
|
|
|
|
2026-10-09 22:44:13 -04:00
|
|
|
|
:raises NotFoundError: 用户不存在
|
2026-10-10 04:28:04 -04:00
|
|
|
|
:raises BadRequestError: 角色编码无效或已停用
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""
|
|
|
|
|
|
user = user_repository.get_by_id(session, user_id)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_USER,
|
|
|
|
|
|
action=ACTION_UPDATE,
|
|
|
|
|
|
summary=f"修改用户 {user.username if user else f'#{user_id}'}",
|
|
|
|
|
|
request_data=payload.model_dump(exclude_none=True),
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
if user is None:
|
|
|
|
|
|
raise NotFoundError("用户不存在")
|
|
|
|
|
|
|
|
|
|
|
|
if payload.real_name is not None:
|
|
|
|
|
|
user.real_name = payload.real_name or None
|
|
|
|
|
|
if payload.phone is not None:
|
|
|
|
|
|
user.phone = payload.phone or None
|
|
|
|
|
|
if payload.email is not None:
|
|
|
|
|
|
user.email = payload.email or None
|
|
|
|
|
|
if payload.remark is not None:
|
|
|
|
|
|
user.remark = payload.remark or None
|
|
|
|
|
|
|
|
|
|
|
|
# 不允许把自己降级掉管理角色,否则改完立刻失去管理权限
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
out = _to_out(user)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_password(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
new_password: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> None:
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""重置用户密码。
|
|
|
|
|
|
|
|
|
|
|
|
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
|
|
|
|
|
|
改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。
|
|
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 用户不存在
|
|
|
|
|
|
"""
|
|
|
|
|
|
user = user_repository.get_by_id(session, user_id)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_USER,
|
|
|
|
|
|
action=ACTION_RESET_PASSWORD,
|
|
|
|
|
|
summary=f"重置用户 {user.username if user else f'#{user_id}'} 的密码",
|
|
|
|
|
|
# 明文进审计管道,由 repository 层的结构化脱敏整体抹除
|
|
|
|
|
|
request_data={"new_password": new_password},
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
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)
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
def set_status(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
status: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> UserOut:
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""启用或禁用用户。
|
|
|
|
|
|
|
|
|
|
|
|
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
|
|
|
|
|
|
否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。
|
|
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 用户不存在
|
|
|
|
|
|
:raises ForbiddenError: 试图禁用自己
|
|
|
|
|
|
"""
|
|
|
|
|
|
user = user_repository.get_by_id(session, user_id)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
verb = "启用" if status == 1 else "禁用"
|
|
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_USER,
|
|
|
|
|
|
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
|
|
|
|
|
summary=f"{verb}用户 {user.username if user else f'#{user_id}'}",
|
|
|
|
|
|
request_data={"status": status},
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
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
|
|
|
|
|
|
out = _to_out(user)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_user(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> None:
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""删除用户。
|
|
|
|
|
|
|
|
|
|
|
|
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
|
|
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 用户不存在
|
|
|
|
|
|
:raises ForbiddenError: 试图删除自己
|
|
|
|
|
|
"""
|
|
|
|
|
|
user = user_repository.get_by_id(session, user_id)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_USER,
|
|
|
|
|
|
action=ACTION_DELETE,
|
|
|
|
|
|
summary=f"删除用户 {user.username if user else f'#{user_id}'}",
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
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)
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_roles(session: Session) -> list[RoleOut]:
|
2026-10-10 04:28:04 -04:00
|
|
|
|
"""取全部启用角色,供用户表单的角色选择使用。"""
|
|
|
|
|
|
return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)]
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
|
2026-10-10 04:28:04 -04:00
|
|
|
|
"""把角色编码解析为角色对象,校验全部存在且**未停用**。
|
|
|
|
|
|
|
|
|
|
|
|
停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前
|
|
|
|
|
|
加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用
|
|
|
|
|
|
角色的用户需先转为启用角色再保存。
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
:raises BadRequestError: 存在无效编码,或角色已停用
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""
|
|
|
|
|
|
if not role_codes:
|
|
|
|
|
|
raise BadRequestError("至少需要指定一个角色")
|
2026-10-10 04:28:04 -04:00
|
|
|
|
roles = role_repository.get_roles_by_codes(session, role_codes)
|
2026-10-09 22:44:13 -04:00
|
|
|
|
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}")
|
2026-10-10 04:28:04 -04:00
|
|
|
|
disabled = sorted(role.role_code for role in roles if role.status != 1)
|
|
|
|
|
|
if disabled:
|
|
|
|
|
|
raise BadRequestError(f"角色已停用,不可分配:{disabled}")
|
2026-10-09 22:44:13 -04:00
|
|
|
|
return list(roles)
|