Files
group_fqcd_jr/app/main.py
T
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
  「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口

可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
  资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
  现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义

推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)

数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)

测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

183 lines
8.4 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.agent_runs import router as agent_runs_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.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",
)
application.mount(
"/customer-service-test",
StaticFiles(directory=static_directory, html=True),
name="customer-service-test",
)
return application
app = create_app()