Files
wangjianlong_0626 4766e3bd98 feat(benefit): 客户权益功能(T010)+ 修投顾迁移契约里写死 head 的脆弱断言
## 1. 新增客户权益(用户端)

`GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`):

- **层级**由 `fin_customer_profile.total_asset` **实时判定**
  (门槛来自 `knowledge/product/高净值客户服务规范.md`:
  金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户);
- **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"):
  金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测;
- 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。

### 新增表 `fin_customer_benefit`(1 张)

层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。

**基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表;
**未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义;
未改 `docs/00`。
复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。

### 两条设计取舍

1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。
   与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。
2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 ——
   否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。

### 数据来源与一处刻意省略

逐条照抄知识文档,不新增文档里没有的权益。**私行那条
「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** ——
文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。

## 2. 修投顾迁移契约里写死的断言

`tests/unit/test_advisor_migration_contract.py` 原先断言

```python
assert script.get_heads()[0] == "20260911_merge_adv_risk_heads"
```

那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`)
它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。

原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先**
(链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。
`len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。

## 3. 顺带发现的既有缺口(**不在本次改动范围**)

`app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**:
`docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`),
但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。
对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。

本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据,
且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。
T001–T009 如何补,需架构师定口径后另行处理。

## 4. 文档

- 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 +
  数据来源 + 权限 + 与仪表盘的关系 + 上述缺口
- `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为
  "T 段数据库零变更"的一部分)
- `AGENTS.md` 表数 89 → **90** 张业务表

## 验证

- `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed**
  (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示;
  鉴权先于读数据)
- 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → **0 错 / 252 文件**
- 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过
- `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突;
  端点编号无重复;RBAC 种子一致性通过
2026-09-12 17:24:37 +08:00

152 lines
7.3 KiB
Python

from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.api.controllers.admin import router as admin_router
from app.api.controllers.agent_runs import router as agent_runs_router
from app.api.controllers.asset_allocation import router as asset_allocation_router
from app.api.controllers.auth import router as auth_router
from app.api.controllers.benefit import router as benefit_router
from app.api.controllers.conversations import router as conversations_router
from app.api.controllers.health import router as health_router
from app.api.controllers.investment_goals import router as investment_goals_router
from app.api.controllers.knowledge import router as knowledge_router
from app.api.controllers.knowledge_management import router as knowledge_management_router
from app.api.controllers.offsite_fund import operation_router as offsite_operation_router
from app.api.controllers.offsite_fund import router as offsite_fund_router
from app.api.controllers.onboarding import router as onboarding_router
from app.api.controllers.portfolio_analysis import router as portfolio_analysis_router
from app.api.controllers.promotion_material import router as promotion_material_router
from app.api.controllers.public_platform import router as public_platform_router
from app.api.controllers.rbac import router as rbac_router
from app.api.controllers.recommendations import (
admin_router as recommendation_admin_router,
)
from app.api.controllers.recommendations import (
advisor_router as recommendation_advisor_router,
)
from app.api.controllers.risk import router as risk_router
from app.api.controllers.trading import router as trading_router
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
from app.api.middleware import attach_trace_id
from app.core.config import get_settings
from app.core.errors import AgentError
def _trace_id(request: Request) -> str:
"""取本次请求的追踪标识:请求上下文 → state → 客户端 `X-Trace-ID` → 空串。
没有就返回空字符串,绝不凭空生成——凭空生成会让客户端拿到的 trace_id 与服务端日志
里的不是同一个,反而失去定位价值。
"""
context = getattr(request.state, "request_context", None)
return str(
getattr(context, "trace_id", None)
or getattr(request.state, "trace_id", None)
or request.headers.get("X-Trace-ID")
or ""
)
def create_app() -> FastAPI:
settings = get_settings()
application = FastAPI(title=settings.app_name, version="0.1.0")
# 接口文档承诺的 X-Trace-ID 此前完全没实现;中间件对成功与错误响应都生效。
application.middleware("http")(attach_trace_id)
application.add_middleware(
CORSMiddleware,
allow_origins=[
origin.strip()
for origin in settings.cors_allowed_origins.split(",")
if origin.strip()
],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@application.exception_handler(AgentError)
async def agent_error_handler(request: Request, exc: AgentError) -> JSONResponse:
context = getattr(request.state, "request_context", None)
# 认证失败时请求上下文尚未建立(`build_request_context` 不会写 request_context),
# 按文档 §3.4 优先复用请求头里客户端带来的 `X-Trace-ID`;都没有就是空字符串,
# 绝不凭空生成 id——会让排障时把两个请求认成同一个。
trace_id = (getattr(context, "trace_id", None)
or getattr(request.state, "trace_id", None)
or request.headers.get("X-Trace-ID") or "")
# retryable 按文档 §3.6 逐码标注,不再简单按 5xx 推导
# (例如 RESOURCE_VERSION_CONFLICT 是 409 但文档标注可重试)。
headers: dict[str, str] = {}
retry_after = getattr(exc, "retry_after_seconds", None)
if isinstance(retry_after, int):
# 文档 §3.6 把 RATE_LIMITED 标注为可重试:只给 retryable=true 而不给
# Retry-After,客户端只能自己猜退避时长(或立刻重试再被拒)。
headers["Retry-After"] = str(retry_after)
return JSONResponse(status_code=exc.status_code, content={
"error": {"code": exc.code, "message": exc.message,
"retryable": exc.is_retryable, "field_errors": []},
"meta": {"trace_id": trace_id},
}, headers=headers or None)
@application.exception_handler(RequestValidationError)
async def request_validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""请求校验失败也必须走统一错误信封(文档 §3.4 / §3.6 `AGENT_INPUT_INVALID`)。
不加这个处理器时,FastAPI 会返回自己的 `{"detail": [...]}` 结构(422),客户端
必须为"参数错误"单独兼容一套解析逻辑;同一套接口因此出现两种错误体形态。
这里保留 422 状态码(文档 §3.5:已解析请求不满足字段或业务输入约束),
把字段级原因放进 `error.field_errors`,与业务异常的信封完全一致。
"""
field_errors = [
{
"field": ".".join(str(part) for part in error.get("loc", ())),
"message": str(error.get("msg", "")),
}
for error in exc.errors()
]
return JSONResponse(status_code=422, content={
"error": {
"code": "AGENT_INPUT_INVALID",
"message": "请求参数不满足接口约束",
"retryable": False,
"field_errors": field_errors,
},
"meta": {"trace_id": _trace_id(request)},
})
application.include_router(auth_router)
application.include_router(agent_runs_router)
application.include_router(conversations_router)
application.include_router(public_platform_router)
application.include_router(rbac_router)
application.include_router(risk_router)
application.include_router(visitor_tokens_router)
application.include_router(offsite_fund_router)
application.include_router(offsite_operation_router)
application.include_router(promotion_material_router)
application.include_router(knowledge_router)
application.include_router(knowledge_management_router)
application.include_router(health_router)
application.include_router(onboarding_router)
application.include_router(investment_goals_router)
application.include_router(portfolio_analysis_router)
application.include_router(asset_allocation_router)
application.include_router(recommendation_advisor_router)
application.include_router(recommendation_admin_router)
application.include_router(admin_router)
application.include_router(trading_router)
application.include_router(benefit_router)
application.mount(
"/customer-service-test",
StaticFiles(directory=Path(__file__).resolve().parent / "static", html=True),
name="customer-service-test",
)
return application
app = create_app()