Files
张胜宇 e5b4d02b0d merge: 集成投顾组 3 个提交(解除与「投顾模块清除」的冲突)+ 客服 Agent 重构收口
## 为什么要合并
远端 `origin/qyqy_develop` 领先 3 个提交(`5607751` / `2fe7d0c` / `74b7d00`:投顾需求与架构文档、
客户主动申报投顾方案 + 受理自动出草稿、方案交付落点与推荐依据 LLM 增强),而本地 `5d0becb`
按 `D4.4` / `D4.5`(CS-PURGE-2026-012/013)把投顾模块整体清除了。**两个目标不可兼得**:
远端新代码反向 import 已被清除的模块(`app.model.investment_goal`、
`app.service.product_recommendation_service`、`app.service.advisor_rollout_service`),
强行推进只会让两边都跑不起来。

**裁定:投顾组的新功能 > 本地的投顾清除。** 依据是 `D4.4` §0-②③ 自己写下的风险
——按名字清投顾会同时拆掉产品数据底座与 MVP 硬阻断,并失去"改 6 个底座文件时的对照组"。
本次合并因此**恢复投顾模块**;就代码面而言,`D4.4` / `D4.5` 的清除结果被本次合并取代
(留痕见 `开发文档\D1.6` §4.37)。

## 冲突怎么解的(12 处)
- **8 处 modify/delete 取远端**:`recommendations.py` / `product_recommendation_service.py` /
  `employee-advisor/dashboard/{actions-module.js,dashboard.css,dashboard.js,index.html}` /
  `tools/{check_portal_modules.py,grant_advisor_role.py}` —— 即"我删、远端改",保留投顾文件。
- **3 处内容冲突取远端**:`app/main.py`(投顾 import 与 `include_router`)、
  `common/api-client.js`(投顾端点表)、`tests/unit/api/test_portal_frontend.py`(4 条投顾前端契约)。
- **1 处取远端 + 保留我方**:`app/main.py` 解除冲突的同时,保留本轮的
  `/customer-service-test` 挂载移除(该联调页与用例已随重构作废)。

## 因"取消清除"而必须回滚的语义改动(否则恢复出来的投顾代码跑不动)
- `app/service/agent/bootstrap.py`:恢复 `AdvisorAgent` 与 5 个投顾工具注册
  (`query_investment_goal` / `analyze_portfolio` / `generate_asset_allocation` /
  `recommend_products` / `compare_products`);客服 Agent 注释按本轮口径保留。
- `app/core/config.py`:恢复 `advisor_rollout_enabled` / `advisor_rollout_customer_ids`。
- `app/static/portal/common/layout/app-shell.js`:恢复投顾工作台导航与 `advisor` 角色名。
- `tools/seed_test_rbac.py`:恢复"admin 取全量元组"的授权模型(保留远端新增的
  9070-9074 权限码与客户侧 9071/9072 绑定)。
- `tools/portal_api_check.py`:恢复投顾实测用例(AD003/AD005/AD011/A047 与 `advisor_t` 登录),
  并**新增判定**:被渲染的集合为空(0 条)时判 `SKIP` 而不是 `FAIL`
  —— "没有行"与"字段没带"是两回事,混报会把排查方向带偏。
- `app/static/portal/common/api-client.js`:以远端为基准,重新叠加本轮的
  **访客令牌 `Authorization` 优先**修复(浮窗访客身份稳定性)。

## 数据库夹具同步(代码恢复 ⇒ 夹具也要恢复)
- `tools/grant_advisor_role.py`:新建 `advisor` 角色并授权(实测 34 项权限)。
- `tools/create_test_user.py --id 9020 --username advisor_t --role advisor`:重建演示账号。

## 集成期发现并修掉的过期断言
- `tests/unit/test_advisor_migration_contract.py`:alembic 末端钉死值仍是
  `20260914_baseline_auto_increment`,而远端新增了 `20260916_advisor_service_request`
  ⇒ 这条断言**在远端分支上本身就是红的**。本次把它更新到新末端并补了注释。

## 验证(本机实测)
| 门禁 | 结果 |
|---|---|
| `pytest -q`(全量,含集成) | **1909 passed / 3 skipped / 0 failed** |
| `ruff check app tools tests` | 20(远端分支 22,本地仅客服线基线 19) |
| `mypy app` | 2(= 既有基线) |
| `tools/portal_api_check.py` | 40 项:通过 35 / 失败 0 / 跳过 5 |
| `tools/e2e_smoke_test.py --read-only` | 31/31 |
| `_eval_harness/http_probe.py` | 11/11 succeeded |
| `_consistency.py` | GATE PASS |
| `_fe_boundary_http.py`(前端入参边界真机) | 全部符合预期 |

## 未做(如实登记)
- **投顾演示数据未灌**:`AD011` / `A047` 需要 `advisor_product_suitability_reference`
  这类带 `source_url` + `document_sha256` 的证据行,而披露文件不在仓库里;
  `tools/seed_advisor_demo.py` 明确"不编证据"(fail closed),故这两条按空集 SKIP。
- **客服线文档目录仍未入库**:`客服agent/`、`开发文档/`(权威副本在本机)与
  `_chunks_report.txt`(本地构建产物)依旧排除在提交之外。
2026-09-20 14:52:35 +08:00

178 lines
8.2 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, RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.api.controllers.admin import router as admin_router
from app.api.controllers.advisor_service_requests import (
advisor_router as advisor_service_request_router,
)
from app.api.controllers.advisor_service_requests import (
client_router as advisor_service_request_client_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.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.recommendations import (
client_router as recommendation_client_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(recommendation_client_router)
application.include_router(advisor_service_request_router)
application.include_router(advisor_service_request_client_router)
application.include_router(admin_router)
application.include_router(trading_router)
static_directory = Path(__file__).resolve().parent / "static"
@application.get("/", include_in_schema=False)
async def portal_home() -> RedirectResponse:
return RedirectResponse(url="/portal/guest/home/")
@application.get("/portal", include_in_schema=False)
@application.get("/portal/", include_in_schema=False)
async def portal_root() -> RedirectResponse:
return RedirectResponse(url="/portal/guest/home/")
application.mount(
"/static",
StaticFiles(directory=static_directory),
name="static",
)
application.mount(
"/portal",
StaticFiles(directory=static_directory / "portal", html=True),
name="portal",
)
return application
app = create_app()