修复权限拒绝变 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 表里被插入了分组标题行
   (`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
This commit is contained in:
2026-09-14 20:14:50 +08:00
parent e9539ae1be
commit e4cd336afa
4 changed files with 105 additions and 23 deletions
+11 -2
View File
@@ -1,10 +1,19 @@
from fastapi import APIRouter, status
from fastapi import APIRouter, Depends, status
from app.api.dependencies.rate_limit import enforce_visitor_token_rate_limit
from app.api.schemas.visitor_tokens import VisitorTokenResponse
from app.core.config import get_settings
from app.core.security import VisitorTokenIssuer
router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"])
#: ⚠️ 本端点**无认证**(访客此时还没有身份),但**必须限流** —— 它此前零限流,
#: 可以不限量铸造有效 JWT,每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,
#: 而 agent-runs 的限流按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ 限流被绕过。
#: 闸门与登录同级别(按客户端 IP),但用**独立计数器前缀**,不与登录互相挤占配额。
router = APIRouter(
prefix="/api/v1/visitor-tokens",
tags=["visitor-tokens"],
dependencies=[Depends(enforce_visitor_token_rate_limit)],
)
@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED)
+62 -16
View File
@@ -77,20 +77,37 @@ 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_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。
为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context`
(见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来
就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。
async def _enforce_ip_rate_limit(
request: Request,
*,
prefix: str,
window_seconds: int,
max_attempts: int,
label: str,
) -> None:
"""按客户端 IP 限流的通用闸门,**不依赖认证上下文**。
维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份),
用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录
端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。
适用于"请求到达时还没有身份"的端点:登录、访客令牌签发。
它们都不能用 `enforce_rate_limit` —— 那个声明依赖 `build_request_context`
(见本模块文档"顺序保证"),挂上去就变成"要令牌先有令牌"。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警,
不因为限流组件故障把所有人挡在门外。
维度取客户端 IP + 路由模板:那时拿不到 `RequestContext.user_id`,IP 是唯一
可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但这些端点的价值
在于**挡住自动化脚本**,IP 维度足够,且不引入第二套鉴权解析。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**
并告警,不因为限流组件故障把所有人挡在门外。
"""
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
@@ -98,20 +115,49 @@ async def enforce_login_rate_limit(request: Request) -> None:
client = request.client.host if request.client is not None else "unknown"
template = route_template(request)
result = await get_counter_backend().increment(
f"{LOGIN_COUNTER_PREFIX}:{client}:{request.method}:{template}",
LOGIN_WINDOW_SECONDS,
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 > LOGIN_MAX_ATTEMPTS:
if count > max_attempts:
logger.warning(
"登录限流 route=%s ip=%s count=%s limit=%s",
template, client, count, LOGIN_MAX_ATTEMPTS,
"%s 限流 route=%s ip=%s count=%s limit=%s",
label, template, client, count, max_attempts,
)
raise RateLimitExceededError(
f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次,"
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="访客令牌签发",
)