1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
94 lines
4.2 KiB
Python
94 lines
4.2 KiB
Python
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import ForbiddenAgentError
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.audit import InteractionAudit
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AuthorizationService:
|
||
"""所有受保护操作的公共权限闸门。
|
||
|
||
契约见 `tests/unit/service/test_authorization_service.py`:
|
||
1. 有权限时**直接返回**,不写审计、更不开数据库会话;
|
||
2. 拒绝时**开新事务**写 `permission.denied` 审计,随后抛 `ForbiddenAgentError`;
|
||
3. `admin=True` 时除权限码外还须具备 `admin` / `super_admin` 角色;
|
||
4. 审计记录 `permission` 与 `trace_id`,供事后追责。
|
||
|
||
审计用独立的 `SessionFactory()` 事务,与调用方可能持有的业务会话解耦,
|
||
保证"拒绝即留痕"不受主流程回滚影响。
|
||
"""
|
||
|
||
_ADMIN_ROLES = ("admin", "super_admin")
|
||
|
||
@staticmethod
|
||
async def require(
|
||
context: RequestContext, permission: str, *, admin: bool = False
|
||
) -> None:
|
||
if permission not in context.permissions:
|
||
await AuthorizationService._deny(context, permission)
|
||
if admin and not any(role in AuthorizationService._ADMIN_ROLES for role in context.roles):
|
||
await AuthorizationService._deny(context, permission)
|
||
|
||
@staticmethod
|
||
async def require_customer_scope(
|
||
context: RequestContext, permission: str, customer_id: int
|
||
) -> None:
|
||
"""客户级权限:先校验权限码,再校验数据范围是否覆盖该客户。
|
||
|
||
数据范围取自 `sys_permission.data_scope`(由 `IdentityService.resolve` 放进
|
||
`context.permission_scopes`):
|
||
- `all`:覆盖全部客户,直接放行;
|
||
- `own_customers`:仅当 `customer_id` 在 `context.customer_ids` 内才放行;
|
||
- 其他(含默认 `self`):拒绝。
|
||
|
||
用于「顾问代客」场景(`customer_id` 非登录用户自身时),与
|
||
`InvestmentGoalService._assert_customer_access` 的口径保持一致。
|
||
"""
|
||
await AuthorizationService.require(context, permission)
|
||
scope = context.permission_scopes.get(permission, "self")
|
||
if scope == "all":
|
||
return
|
||
if scope == "own_customers" and str(customer_id) in context.customer_ids:
|
||
return
|
||
await AuthorizationService._deny(context, permission)
|
||
|
||
@staticmethod
|
||
async def _deny(context: RequestContext, permission: str) -> None:
|
||
actor_id: int | None = None
|
||
try:
|
||
actor_id = int(context.user_id)
|
||
except (TypeError, ValueError):
|
||
actor_id = None
|
||
audit = InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=actor_id,
|
||
portal=context.portal,
|
||
action_type="permission.denied",
|
||
detail={"permission": permission, "trace_id": context.trace_id},
|
||
# ⚠️ 必须显式给:`interaction_audit.created_at` 是 NOT NULL 且**无默认值**。
|
||
# 这里漏传过一次(2026-09-14 的 `857c106`),后果不是"审计少一条",而是
|
||
# `session.commit()` 抛 IntegrityError(1048: Column 'created_at' cannot be null),
|
||
# 而 `raise ForbiddenAgentError` 写在 commit 之后 —— 于是**任何权限不足的请求
|
||
# 都变成 500 而不是 403**,破坏 docs/05 §3.6 的错误码契约。
|
||
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
)
|
||
try:
|
||
async with SessionFactory() as session:
|
||
session.add(audit)
|
||
await session.commit()
|
||
except Exception:
|
||
# 审计写失败不能把「权限不足」变成 500 —— **拒绝就是拒绝**,
|
||
# 安全判定不该依赖审计表是否可写。
|
||
# 但也绝不静默:审计缺失是合规问题,必须留下告警供排查。
|
||
logger.warning(
|
||
"权限拒绝审计写入失败(不影响本次 403)permission=%s trace_id=%s",
|
||
permission,
|
||
context.trace_id,
|
||
exc_info=True,
|
||
)
|
||
raise ForbiddenAgentError("权限不足")
|