PayrollManagementSystem/backend/app/api/deps.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

195 lines
7.8 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.

"""FastAPI 公共依赖注入。"""
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.constants import ROLE_ADMIN
from app.core.db import get_db
from app.core.exceptions import AuthError, ForbiddenError
from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
from app.services import token_blacklist
from app.services.audit_service import RequestMeta
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
_bearer_scheme = HTTPBearer(auto_error=False, description="Bearer 访问令牌")
BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)]
DbSession = Annotated[Session, Depends(get_db)]
def client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
def get_request_meta(request: Request) -> RequestMeta:
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
return RequestMeta(
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
method=request.method,
path=request.url.path,
)
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials:
raise AuthError()
return credentials.credentials
def get_current_user(credentials: BearerCredentials, session: DbSession) -> User:
"""从 Bearer 令牌解析当前登录用户。
**三重校验,缺一不可**:
1. 签名与有效期(`decode_token`)
2. 是否已被登出拉黑(Redis `ys:token:blacklist:{jti}`)
3. **令牌版本与库中是否一致** —— 禁用 / 改密 / 强制下线会把
`users.token_version` 加一,使此前签发的所有令牌立即失效。
这一步**不依赖 Redis**,因此 Redis 故障或数据被清空时,
离职员工依然无法继续访问
Redis 不可用时第 2 步会抛异常而非放行 —— **有意的 fail-closed**:
对能查看全员薪酬的系统,宁可拒绝请求也不能放行状态不明的令牌。
:raises AuthError: 任一项校验未通过(401)
"""
if credentials is None or not credentials.credentials:
raise AuthError()
payload = decode_token(credentials.credentials)
jti = payload.get("jti")
if jti and token_blacklist.is_revoked(jti):
raise AuthError("登录状态已失效,请重新登录")
subject = payload.get("sub")
user = user_repository.get_by_id(session, int(subject)) if _is_digits(subject) else None
if user is None or user.status != 1:
raise AuthError()
if payload.get("ver") != user.token_version:
raise AuthError("登录状态已失效,请重新登录")
return user
def _is_digits(value: object) -> bool:
"""判断令牌中的 sub 是否为合法的主键字符串。
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
"""
return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
# authorization_checked 短路,每次按数据库角色重新判定。
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_payroll_writer(current_user: LoginUser) -> User:
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
return current_user
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
def require_workbench_reader(current_user: LoginUser) -> User:
return current_user
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
held = set(user_repository.get_role_codes(current_user))
if not held & set(role_codes):
raise ForbiddenError(message)
return current_user
return _checker
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
from app.services import authorization_policy as policy
from app.services.authorization_service import require_permission
endpoint = request.scope["route"].endpoint
module = endpoint.__module__.removeprefix("app.api.v1.")
name = endpoint.__name__
if (module, name) in policy.PUBLIC_ENDPOINTS:
return
user = get_current_user(credentials, session)
body = {}
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
try:
value = await request.json()
body = value if isinstance(value, dict) else {}
except ValueError:
body = {}
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
if code is None:
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
if code:
try:
require_permission(session, user, code)
except ForbiddenError:
from app.services.audit_service import AuditRecord, audit
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
)):
raise
request.state.authorization_checked = True