按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
93 lines
3.7 KiB
Python
93 lines
3.7 KiB
Python
"""授权查询与计算,权限每次从数据库解析,不采用 JWT 中的角色作为依据。"""
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import object_session
|
||
|
||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||
from app.models import (
|
||
AuthorizationSetting,
|
||
Permission,
|
||
Role,
|
||
RolePermission,
|
||
User,
|
||
UserRole,
|
||
)
|
||
from app.services.permission_catalog import CATALOG, PAYROLL_WRITE_PERMISSIONS
|
||
|
||
|
||
def setting(session, *, lock=False):
|
||
statement = select(AuthorizationSetting).where(AuthorizationSetting.id == 1)
|
||
if lock:
|
||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||
row = session.scalar(statement)
|
||
if row is None:
|
||
raise ForbiddenError("授权配置尚未初始化,请先完成数据库迁移")
|
||
return row
|
||
|
||
|
||
def role_codes(session, user_id, *, for_update=False):
|
||
statement = select(Role.role_code).join(UserRole).where(
|
||
UserRole.user_id == user_id, Role.status == 1
|
||
)
|
||
if for_update:
|
||
statement = statement.with_for_update()
|
||
return list(session.scalars(statement))
|
||
|
||
|
||
def calculate(session, user, *, roles=None, overrides=None):
|
||
"""用户继承启用角色权限的并集;管理员固定全权限,操作依赖模块查看。"""
|
||
if overrides:
|
||
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
|
||
if user is None or user.status != 1:
|
||
return {"permissions": [], "sources": {}, "roles": []}
|
||
roles = roles if roles is not None else role_codes(session, user.id)
|
||
if "admin" in roles:
|
||
return {"permissions": sorted(CATALOG), "sources": {
|
||
code: {"roles": ["admin"], "override": None, "effective": True}
|
||
for code in CATALOG
|
||
}, "roles": roles}
|
||
inherited = {}
|
||
for code, role in session.execute(
|
||
select(Permission.perm_code, Role.role_code)
|
||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||
.join(Role, Role.id == RolePermission.role_id)
|
||
.where(Role.role_code.in_(roles), Role.status == 1, Permission.status == 1)
|
||
):
|
||
inherited.setdefault(code, []).append(role)
|
||
allowed = set(inherited)
|
||
allowed &= set(CATALOG)
|
||
allowed = {c for c in allowed if not CATALOG[c]["admin_only"]}
|
||
allowed = {c for c in allowed if CATALOG[c]["view_code"] in allowed}
|
||
sources = {c: {"roles": inherited.get(c, []), "override": None,
|
||
"effective": c in allowed} for c in CATALOG}
|
||
return {"permissions": sorted(allowed), "sources": sources, "roles": roles}
|
||
|
||
|
||
def is_maker(session, user):
|
||
"""兼容旧会话字段:现表示账号具备工资写权限,不再查询全局绑定。"""
|
||
return bool(set(calculate(session, user)["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
|
||
|
||
|
||
def require_permission(session, user, code, *, check_maker=True):
|
||
item = CATALOG.get(code)
|
||
if item is None or code not in calculate(session, user)["permissions"]:
|
||
label = f"{item['module_name']}:{item['name']}" if item else "此操作"
|
||
raise ForbiddenError(f"当前账号没有「{label}」权限,请联系管理员授权")
|
||
return user
|
||
|
||
|
||
def session_info(session, user):
|
||
result = calculate(session, user)
|
||
maker = bool(set(result["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
|
||
return {"permissions": result["permissions"], "authorization_revision": setting(session).revision,
|
||
"is_payroll_maker": maker, "can_write_payroll": maker,
|
||
"maker_required_permissions": [],
|
||
"roles": result["roles"]}
|
||
|
||
|
||
def user_session(user):
|
||
session = object_session(user) if isinstance(user, User) else None
|
||
if session is None:
|
||
raise ForbiddenError("账号状态不可用,请重新登录")
|
||
return session
|