Files
group_fqcd_jr/app/api/controllers/public_platform.py
T
张胜宇 9675df8453 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W26 口径)
- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空)
- 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路)
  + 开发文档 62 份编号体系(D1.1 v1.17 索引)
  + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 21:26:30 +08:00

174 lines
7.3 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, Path, 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 = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
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 = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
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(
payload: Cancellation,
run_id: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
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 = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
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(
payload: HandoverRequest,
session_id: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
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/memories")
async def my_memories_debug(
context: RequestContext = Depends(build_request_context), # noqa: B008
query: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""记忆系统可观测端点:一次返回「库里有什么」「能不能召回到」「事件有没有被消费」。
这是排查"记忆到底有没有在工作"的唯一出口 —— 在此之前,`memory_unit` 原始行
没有任何读接口,`GET /users/me/memory-profile` 只返回画像快照(记忆的下游产物),
因此"写入成功但画像还没重建"与"根本没写入"在外部完全无法区分。
"""
return await PublicPlatformService().memories_debug(
int(context.user_id), context, query=query, limit=limit
)
@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)