Files
group_fqcd_jr/app/api/controllers/public_platform.py
T
lzf_0626 7208713c17 feat(portal): 接入历史净值,产品详情页恢复真实走势图
`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。

## 1. 取数(app/infrastructure/fund_market_adapter.py)

新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。

- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
  该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
  而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。

⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。

## 2. 落库(tools/sync_nav_history.py,新增)

写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。

## 3. 接口(P002)

`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。

**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。

## 4. 前端

详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。

## 5. 演示数据

`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。

验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
2026-09-13 23:38:13 +08:00

153 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, Query
from pydantic import Field
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.admin import StrictPayload
from app.api.schemas.conversations import HandoverRequest
from app.core.contracts import RequestContext
from app.service.public_platform_service import PublicPlatformService
from app.service.public_product_service import PublicProductService
router = APIRouter(prefix="/api/v1", tags=["public-platform"],
dependencies=[Depends(enforce_rate_limit)])
class SessionCreate(StrictPayload):
agent_type: str = Field(pattern=r"^[a-z][a-z0-9_]{1,31}$")
class Cancellation(StrictPayload):
reason: str = Field(default="user_cancelled", max_length=128)
class CandidateDecisionPayload(StrictPayload):
decision: Literal["confirmed", "rejected"]
@router.post("/conversations", status_code=201)
async def create_session(
payload: SessionCreate,
key: str | None = Header(default=None, alias="Idempotency-Key"),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().write("create", "", context, key, payload.model_dump())
@router.get("/conversations/{session_id}")
async def get_session(
session_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().session(session_id, context)
@router.post("/conversations/{session_id}/closures")
async def close_session(
session_id: str, key: str | None = Header(default=None, alias="Idempotency-Key"),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().write("close", session_id, context, key, {})
@router.post("/agent-runs/{run_id}/cancellations", status_code=202)
async def cancel_run(
run_id: str, payload: Cancellation,
key: str | None = Header(default=None, alias="Idempotency-Key"),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().write("cancel", run_id, context, key, payload.model_dump())
@router.get("/handover-requests/{handover_id}")
async def get_handover(
handover_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().handover(handover_id, context)
# 转人工申请的唯一入口:必须走 PublicPlatformService.write("handover"),
# 它在同一事务内写工单 + Outbox 事件(conversation.transfer_requested)+ 审计。
# 历史上 conversations.py 另有一份只写审计的实现,因注册顺序覆盖了本入口,
# 导致转人工的异步链路(Outbox → Worker)永不触发,已合并删除。
@router.post("/conversations/{session_id}/handover-requests", status_code=202)
async def request_handover(
session_id: str,
payload: HandoverRequest,
key: str | None = Header(default=None, alias="Idempotency-Key"),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().write(
"handover", session_id, context, key, payload.model_dump()
)
@router.get("/users/me/memory-profile")
async def my_memory(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().memory(int(context.user_id), context)
@router.get("/customers/{customer_id}/memory-profile")
async def customer_memory(
customer_id: int, context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return await PublicPlatformService().memory(customer_id, context)
@router.get("/users/me/memory-candidates")
async def my_memory_candidates(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""返回当前用户可确认的画像候选,不返回证据原文。"""
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
return await CustomerProfileCandidateService().list_for_customer(context)
@router.post("/users/me/memory-candidates/{candidate_id}/decisions")
async def decide_memory_candidate(
candidate_id: int,
payload: CandidateDecisionPayload,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""用户确认或拒绝自己的候选;确认后仍需管理员审核才能激活。"""
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
return await CustomerProfileCandidateService().decide_by_customer(
candidate_id, payload.decision, context
)
@router.get("/products")
async def list_products(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""公开产品列表:在售场内基金 + 各自最新行情(访客令牌即可访问)。
这是访客三个页面(首页推荐 / 产品列表 / 产品详情)的数据源,
替代原先前端手写的 `common/mock-data.js`。
只要求**有效令牌**、不检查权限码:访客令牌的上下文只有 `roles=("visitor",)`
且不带权限,与 `/api/v1/conversations`、`/api/v1/agent-runs` 的访客口径一致。
"""
return await PublicProductService().list_products(context)
@router.get("/products/{product_code}/nav-history")
async def product_nav_history(
product_code: str,
days: int = Query(default=90, ge=1, le=365),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
"""产品历史净值序列(编号 `P002`):产品详情页净值走势图的数据源。
数据来自 `fin_nav_history`,由 `tools/sync_nav_history.py` 从东财净值接口同步。
鉴权口径与 P001 相同:**要求有效令牌但不校验权限码**(访客令牌可用)。
表为空时返回 `count=0` 与空数组,**不是错误** —— 前端据此显示"尚未接入"。
"""
return await PublicProductService().nav_history(product_code, context, days=days)