PayrollManagementSystem/backend/app/services/role_service.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

268 lines
10 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.

"""角色管理业务编排。
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
节点准入)。权限点按当前权限目录分配,管理员角色拥有固定权限基线。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
在途审批流失去可操作人
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
- 停用角色不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户。
"""
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_UPDATE,
BUILTIN_ROLE_CODES,
MODULE_ROLE,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import Role, RolePermission, User
from app.repositories import role_repository
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
from app.services import audit_service, authorization_management
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.permission_catalog import CATALOG
logger = logging.getLogger(__name__)
def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManageOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return RoleManageOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
description=role.description,
sort_order=role.sort_order,
status=role.status,
user_count=user_count,
permission_count=len(CATALOG) if role.role_code == "admin" else permission_count,
)
def _user_count(session: Session, role_id: int) -> int:
"""单个角色的绑定用户数(复用批量统计,保持口径一致)。"""
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
def _permission_count(session: Session, role_id: int) -> int:
return role_repository.count_permissions_by_role(session, [role_id]).get(role_id, 0)
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
"""分页查询角色。
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(session, query)
total = role_repository.count_roles(session, query)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
permissions = role_repository.count_permissions_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0), permissions.get(role.id, 0)) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def create_role(
session: Session,
payload: RoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""新增角色。编码唯一、创建后不可修改,新角色固定启用。
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
"""
audit_data = payload.model_dump(exclude_none=True)
if payload.permission_codes is not None and not payload.reason:
audit_data["reason"] = "创建角色并配置初始权限"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_CREATE,
summary=f"新增角色 {payload.role_name}({payload.role_code})",
request_data=audit_data,
),
meta=meta,
):
if role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
codes = None
policy = None
if payload.permission_codes is not None:
policy = authorization_management.lock_policy(session, operator)
codes = authorization_management.validate_role_permissions(payload.permission_codes)
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
description=payload.description,
sort_order=payload.sort_order,
status=1,
)
try:
role_repository.create(session, role)
if codes is not None:
ids = authorization_management.active_permission_ids(session, codes)
session.add_all([RolePermission(role_id=role.id, permission_id=ids[code]) for code in codes])
authorization_management.bump(policy, operator)
session.flush()
out = _to_out(role, 0, len(codes) if codes is not None else 0)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
session.rollback()
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc
logger.info("新增角色 | role_code=%s", payload.role_code)
return out
def update_role(
session: Session,
role_id: int,
payload: RoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""编辑角色名称/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 角色不存在
"""
role = role_repository.get_by_id(session, role_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_UPDATE,
summary=f"修改角色 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
role.role_name = payload.role_name
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
session.commit()
logger.info("修改角色 | role_id=%s", role_id)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""启用或禁用角色。
停用不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户;
内置角色不可停用。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置角色
"""
role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
raise ForbiddenError("内置角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
session.commit()
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除角色。
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置角色
:raises BadRequestError: 角色下仍有绑定的用户
"""
role = role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_DELETE,
summary=f"删除角色 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code in BUILTIN_ROLE_CODES:
raise ForbiddenError("内置角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id)
if user_count > 0:
raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定")
role_code = role.role_code # 固化:对象随删除失效
role_repository.delete(session, role)
session.commit()
logger.info("删除角色 | role_code=%s", role_code)