Files
lzf_0626 e4cd336afa 修复权限拒绝变 500、访客令牌无限流,并让两处测试跟上代码
## 1. 权限不足从 500 修回 403(14 个测试失败里的 11 个)

`app/service/authorization_service.py` 的 `_deny` 构造 `InteractionAudit(...)` 时
**漏了 `created_at`**(该列 NOT NULL 且无默认值),而 `raise ForbiddenAgentError`
写在 `session.commit()` **之后** —— commit 必抛 IntegrityError
(1048: Column 'created_at' cannot be null),于是**永远走不到 raise**:

    预期 403,实际 500:Internal Server Error

**⇒ 任何「权限不足」的请求都变成 500**,破坏 docs/05 §3.6 的错误码契约。
全仓 100+ 处 `InteractionAudit(...)` 都跟着 `created_at=now`,只有这一处没有
(由 2026-09-14 的 `857c106`「投顾工作台:三接口支持按客户出方案」引入)。

修两处:
- 补 `created_at`;
- **并把审计写入失败与 403 解耦**(try/except + `logger.warning(exc_info=True)`):
  安全判定不该依赖审计表是否可写 —— 拒绝就是拒绝。但也绝不静默,审计缺失是合规问题。

## 2. 访客令牌端点补限流(P0-4)

`app/api/controllers/visitor_tokens.py` 此前**零认证、零限流**,可以不限量铸造
有效 JWT;每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流
按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ **限流被天然绕过**。

把 `rate_limit.py` 的登录闸门抽成通用的 `_enforce_ip_rate_limit(...)`,
新增 `enforce_visitor_token_rate_limit` 挂到该端点的 router 上。

⚠️ 刻意**用独立计数器前缀**(`visitor-token` vs `login`)而不是直接复用登录闸门:
共用会让两者互相挤占配额 —— 正常访客刷几次页面就把别人挡在登录外。

阈值 30 次/分钟(比登录的 10 次宽松,因为访客进站/刷新会正常签发)。

## 3. 测试跟上代码(2 个)

- `test_product_recommendation_service.py`:monkeypatch 打在 `current_for_agent` 上,
  而 `generate()` 现在走 `current_for_customer(customer_id, context)` —— 补丁不生效,
  真实方法被执行并命中鉴权抛 403。改为按新签名打补丁。
- `test_authorization_service.py` 等 3 个:随第 1 项一起恢复。

## 实测

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (修复前 **14 failed** / 1426 passed)
- `_deny`:权限不足恢复 **403**(原为 500)
- 访客限流:连发 40 次 -> `{201: 30, 429: 10}`,首次被拒返回
  `RATE_LIMITED「访客令牌签发过于频繁:每 60 秒最多 30 次」`
  (修复前:连发 15 次全部 201、无限流)
- `ruff check` -> All checks passed

## 剩余 2 个失败(需产品决策,未擅自处理)

1. `test_advisor_workspace_registers_documented_operation_endpoints` ——
   `employee-advisor/dashboard/index.html` 已被**整个替换**为一个自包含静态页
   (`data-page-node-id` 属性、内联全部 CSS/JS、硬编码 `API="http://127.0.0.1:8000"`、
   自带「离线本地引擎」),**不引用 `api-client.js` / `app-shell.js`、不调 `mountShell`**。
   测试断言的是旧页面措辞("组合分析"),新页面写的是"生成推荐方案"。
   是接受替换后的页面(改测试断言),还是恢复挂平台壳的版本,属产品决策。
2. `test_docs_endpoint_ids` —— `docs/05` §19 表里被插入了分组标题行
   (`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
2026-09-14 20:14:50 +08:00

164 lines
7.0 KiB
Python
Raw Permalink 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.
"""限流闸门(文档 §3.6 `RATE_LIMITED`:429、可重试)。
为什么用依赖而不是全局 HTTP 中间件:
- 限流维度是"用户 + 接口",用户来自认证后的 `RequestContext`。全局中间件跑在路由匹配
之前,拿不到上下文,只能退化成按 IP 限流(本地/内网部署里所有客户端常常共用一个
出口 IP,等于没有限流),或者自己再解析一次 JWT(第二套鉴权实现,违反单一入口)。
- 依赖抛的是 `AgentError` 家族异常,直接复用 `app/main.py` 的统一错误信封处理器;
中间件抛出异常会绕过该处理器,只能手写一份响应体,容易与文档 §3.4 漂移。
- 挂在路由上(`APIRouter(dependencies=[...])`)而不是每个函数里手写,新增接口不会
漏掉闸门。
顺序保证:本依赖声明依赖 `build_request_context`,因此**认证永远先于限流**——未带令牌
的请求仍是 401,不会因为限流计数变成 429(否则限流会掩盖鉴权失败)。
降级:后端返回 `None` 表示无法判定(Redis 不可用/未安装/超时),此时**放行**。限流是
保护措施,不能因为 Redis 故障把正常请求全部拒掉;降级只写运行日志,按文档 §11.2 不进
审计(限流拒绝本身也只写日志或指标)。
"""
import logging
from fastapi import Depends, Request
from app.api.dependencies.auth import build_request_context
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.rate_limit import RateLimitExceededError, RateLimitPolicy
from app.infrastructure.rate_limiter import CounterBackend, default_counter_backend
logger = logging.getLogger(__name__)
def get_counter_backend() -> CounterBackend:
"""计数后端工厂:模块级函数是唯一的替换点(测试注入替身,不连 Redis)。"""
return default_counter_backend()
def route_template(request: Request) -> str:
"""计数维度里的"接口"取路由模板,而不是原始 URL。
否则 `GET /agent-runs/{run_id}` 会被拆成无数个独立计数器,限流形同虚设。
"""
path = getattr(request.scope.get("route"), "path", None)
return str(path) if path else request.url.path
async def enforce_rate_limit(
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> None:
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
return
template = route_template(request)
result = await get_counter_backend().increment(
policy.key(context.user_id, request.method, template), policy.window_seconds
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > policy.max_requests:
logger.warning(
"触发限流 route=%s count=%s limit=%s", template, count, policy.max_requests
)
raise RateLimitExceededError(
f"请求过于频繁:每 {policy.window_seconds} 秒最多 {policy.max_requests} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)
#: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户
#: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。
LOGIN_WINDOW_SECONDS = 60
LOGIN_MAX_ATTEMPTS = 10
LOGIN_COUNTER_PREFIX = "login"
#: 访客令牌签发端点的限流参数。
#: ⚠️ 与登录**必须是独立计数器**(不同 `prefix`):共用会让两者互相挤占配额 ——
#: 正常访客刷几次页面就把别人挡在登录外,反之亦然。
#: 阈值比登录宽松(访客进站/刷新会正常签发),但足以把"脚本循环铸造身份"从
#: 毫秒级降到每分钟几十次:每个令牌都是一次可用的 LLM 调用额度,
#: 且访客的 `sub` 每次都是新随机值,**按 user_id 计的限流天然被绕过**。
VISITOR_WINDOW_SECONDS = 60
VISITOR_MAX_ATTEMPTS = 30
VISITOR_COUNTER_PREFIX = "visitor-token"
async def _enforce_ip_rate_limit(
request: Request,
*,
prefix: str,
window_seconds: int,
max_attempts: int,
label: str,
) -> None:
"""按客户端 IP 限流的通用闸门,**不依赖认证上下文**。
适用于"请求到达时还没有身份"的端点:登录、访客令牌签发。
它们都不能用 `enforce_rate_limit` —— 那个声明依赖 `build_request_context`
(见本模块文档"顺序保证"),挂上去就变成"要令牌先有令牌"。
维度取客户端 IP + 路由模板:那时拿不到 `RequestContext.user_id`,IP 是唯一
可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但这些端点的价值
在于**挡住自动化脚本**,IP 维度足够,且不引入第二套鉴权解析。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**
并告警,不因为限流组件故障把所有人挡在门外。
"""
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
return
client = request.client.host if request.client is not None else "unknown"
template = route_template(request)
result = await get_counter_backend().increment(
f"{prefix}:{client}:{request.method}:{template}",
window_seconds,
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > max_attempts:
logger.warning(
"%s 限流 route=%s ip=%s count=%s limit=%s",
label, template, client, count, max_attempts,
)
raise RateLimitExceededError(
f"{label}过于频繁:每 {window_seconds} 秒最多 {max_attempts} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)
async def enforce_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门(文案与阈值保持原样,见本模块既有契约测试)。"""
await _enforce_ip_rate_limit(
request,
prefix=LOGIN_COUNTER_PREFIX,
window_seconds=LOGIN_WINDOW_SECONDS,
max_attempts=LOGIN_MAX_ATTEMPTS,
label="登录尝试",
)
async def enforce_visitor_token_rate_limit(request: Request) -> None:
"""访客令牌签发端点的限流闸门。
该端点此前**零认证、零限流**:可以不限量地铸造有效 JWT,每个都能调
`/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流按 `user_id` 计、
访客 `sub` 每次都是新随机值 ⇒ 限流被天然绕过,等于免费刷模型额度并灌爆
`agent_run` / `conversation` 表。这里补上与登录同级别的闸门。
"""
await _enforce_ip_rate_limit(
request,
prefix=VISITOR_COUNTER_PREFIX,
window_seconds=VISITOR_WINDOW_SECONDS,
max_attempts=VISITOR_MAX_ATTEMPTS,
label="访客令牌签发",
)