PayrollManagementSystem/backend/app/services/user_service.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

359 lines
13 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 import authorization_management as auth_management
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} 已存在")
policy = auth_management.lock_policy(session, operator)
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)
auth_management.bump(policy, operator)
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:
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, roles=payload.roles)
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
auth_management.bump(policy, operator)
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("不能禁用自己")
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, status=status)
user.status = status
auth_management.bump(policy, operator)
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("不能删除自己")
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, deleting=True)
auth_management.bump(policy, operator)
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)