PayrollManagementSystem/backend/app/services/user_service.py
liuwenxin 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00

347 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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