docs: 品牌全量口径统一为「南方基金」+ 作废文档清理

1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
This commit is contained in:
张胜宇
2026-09-17 15:15:22 +08:00
commit e239eb778b
874 changed files with 188418 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""FastAPI dependencies."""
+68
View File
@@ -0,0 +1,68 @@
from functools import lru_cache
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.errors import UnauthorizedAgentError
from app.core.security import JwtAuthenticator
from app.service.identity_service import IdentityService
_bearer = HTTPBearer(auto_error=False)
# 与 `app/api/middleware.py` 的 `TRACE_ID_HEADER` 同名;认证阶段还不存在请求上下文,
# 这个头是唯一可复用的追踪标识来源。
TRACE_ID_HEADER = "X-Trace-ID"
@lru_cache(maxsize=1)
def _authenticator() -> JwtAuthenticator:
return JwtAuthenticator(get_settings())
async def build_request_context(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), # noqa: B008
) -> RequestContext:
"""认证 + 身份解析。
失败一律抛 `UnauthorizedAgentError`(`401 AUTHENTICATION_REQUIRED`),由
`app/main.py` 的 `AgentError` 处理器输出文档 §3.4 的统一错误信封——此前这里抛
FastAPI 原生 `HTTPException`,响应体是 `{"detail": "Unauthorized"}`,调用方无法用
同一套信封解析。令牌非法与 RBAC/身份解析失败**仍不区分**,都返回 401。
`meta.trace_id` 的来源(文档 §3.4):此时请求上下文尚未建立,因此先记住请求头里的
`X-Trace-ID`;没有就是空字符串,不凭空生成 id。
"""
request.state.trace_id = request.headers.get(TRACE_ID_HEADER)
def unauthorized() -> UnauthorizedAgentError:
"""消息固定成一句,涵盖令牌缺失、非法、过期、吊销与账号不可用。
文档 §3.6 只给了一个 401 码,因此客户端无法(也不应)据 `message` 区分失败类型,
更不会看到内部异常原文。
"""
return UnauthorizedAgentError("令牌缺失、无效或已吊销")
if credentials is None or credentials.scheme.lower() != "bearer":
raise unauthorized()
try:
context = _authenticator().authenticate(credentials.credentials)
if "visitor" not in context.roles:
context = await IdentityService().resolve(context)
except Exception as exc:
# 令牌非法、账号停用、角色读取失败一律按 401 处理,形态完全一致。
raise unauthorized() from exc
request.state.request_context = context
if (
"customer" in context.roles
and not request.url.path.startswith("/api/v1/onboarding/")
):
from app.service.risk_questionnaire_service import RiskQuestionnaireService
if await RiskQuestionnaireService().is_required(context):
from app.core.errors import OnboardingRequiredError
raise OnboardingRequiredError("请先完成开户风险测评问卷")
return context
+10
View File
@@ -0,0 +1,10 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.db import SessionFactory
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
+47
View File
@@ -0,0 +1,47 @@
"""SSE 内容协商(`docs/05` §3.2、§3.5)。
`Accept` 在文档里是**非必填**头,所以判定规则是"客户端是否**显式拒绝**了
`text/event-stream`",而不是"客户端是否显式接受"。
放在这里而不是写在某个 Controller 里,是因为平台有两个 SSE 端点
(`/api/v1/agent-runs/{run_id}/events` 与 `/api/v1/risk/daily-report/stream`):口径
一旦分叉,同一份客户端代码就会在一个端点上拿到 200、在另一个端点上拿到 406。
"""
from __future__ import annotations
SSE_MEDIA_TYPE = "text/event-stream"
# `text/*` 与 `*/*` 都覆盖 `text/event-stream`,属于"可接受"。
ACCEPTABLE_SSE_TYPES = frozenset({SSE_MEDIA_TYPE, "text/*", "*/*"})
def accepts_event_stream(accept: str | None) -> bool:
"""`Accept` 是否接受 `text/event-stream`(文档 §3.2:该头**非必填**)。
- 未携带(`None` 或空串)→ 放行:文档写明"默认 `application/json`;SSE 为
`text/event-stream`",即由接口自身决定响应类型,不是客户端错误;
- 携带 `text/event-stream`、`text/*` 或 `*/*` 且 `q != 0` → 放行;
- 显式携带但只接受其他类型(如 `application/json`)→ 拒绝,由调用方转
`406 SSE_NOT_ACCEPTABLE`(文档 §3.5/§6.4)。
只做"是否可接受"的判定,不参与内容协商排序:SSE 端点只有一种表示。
"""
if accept is None or not accept.strip():
return True
for entry in accept.split(","):
parts = entry.split(";")
media_type = parts[0].strip().lower()
if media_type not in ACCEPTABLE_SSE_TYPES:
continue
quality = 1.0
for parameter in parts[1:]:
name, _, value = parameter.partition("=")
if name.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
if quality > 0:
return True
return False
+163
View File
@@ -0,0 +1,163 @@
"""限流闸门(文档 §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="访客令牌签发",
)