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;补「两处共用同一渲染」回归测试
This commit is contained in:
Windows
2026-09-16 18:17:47 +08:00
parent 2fe7d0c506
commit 74b7d00dff
18 changed files with 1873 additions and 54 deletions
+49
View File
@@ -21,6 +21,15 @@ admin_router = APIRouter(
tags=["platform-admin"], tags=["platform-admin"],
dependencies=[Depends(enforce_rate_limit)], dependencies=[Depends(enforce_rate_limit)],
) )
#: 客户自助路由:客户看**自己**已发布的投顾交付物。
#: 前缀挂在 `/api/v1/users/me` 下,与 §T 段(交易)同一约定。
#: **故意不挂 `enforce_advisor_rollout`** —— 那是投顾业务的灰度闸门(按
#: `sys_customer_assignment` 归属命中白名单),客户看自己的交付物不该被它拦下。
client_router = APIRouter(
prefix="/api/v1/users/me",
tags=["advisor-deliveries"],
dependencies=[Depends(enforce_rate_limit)],
)
@advisor_router.post("/recommendations") @advisor_router.post("/recommendations")
@@ -41,6 +50,19 @@ async def published_recommendations(
return await ProductRecommendationService().published(context) return await ProductRecommendationService().published(context)
@advisor_router.get("/recommendations/history")
async def recommendation_history(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
"""历史方案留档:本人 + 名下客户的**全部状态**方案与方案书。
与 `/recommendations/published` 分开的原因:published 只给"已发布、对客户可见"的内容,
投顾刚生成、还在待审的草案不在其中。历史记录面板要的是"以前生成过什么",
所以这里返回全部状态,按生成时间倒序。
"""
return await ProductRecommendationService().history(context)
# ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------ # ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------
# #
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...` # 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
@@ -76,6 +98,20 @@ async def advisor_publish_recommendation(
return await ProductRecommendationService().publish(content_id, context, key) return await ProductRecommendationService().publish(content_id, context, key)
@advisor_router.delete("/recommendations/{content_id}")
async def advisor_delete_recommendation(
content_id: int = Path(gt=0),
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
"""删除推荐方案(历史记录里的「删除」按钮)。
仅推荐方案可删:投资方案书被 `advisor_investment_goal.goal_book_content_id`
(`NO ACTION` 外键、`NOT NULL`)引用,硬删会撞外键 —— 方案书走自己的生命周期。
"""
return await ProductRecommendationService().delete(content_id, context, key)
@admin_router.get( @admin_router.get(
"/advisor/pending-contents", "/advisor/pending-contents",
dependencies=[Depends(enforce_advisor_rollout)], dependencies=[Depends(enforce_advisor_rollout)],
@@ -123,3 +159,16 @@ async def publish_recommendation(
key: str | None = Header(default=None, alias="Idempotency-Key"), key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]: ) -> dict[str, object]:
return await ProductRecommendationService().publish(content_id, context, key) return await ProductRecommendationService().publish(content_id, context, key)
# ---- 客户侧:接收投顾交付物 ------------------------------------------------
#
# 「发送给客户」此前**没有落点** —— 投顾发布后只是把 `published_at` 置上,
# 客户端门户没有任何页面/接口能读到它(客户"可见"只体现在数据口径上)。
# 这条路由补上落点:客户登录后看**自己**已发布的推荐方案与方案书。
@client_router.get("/advisor-contents")
async def my_advisor_contents(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
"""我的投顾方案:本人已审核发布的投顾交付物(按发布时间倒序,最多 50 条)。"""
return await ProductRecommendationService().my_published(context)
+12
View File
@@ -102,6 +102,18 @@ class Settings(BaseSettings):
offsite_deepseek_api_key: str = "" offsite_deepseek_api_key: str = ""
offsite_deepseek_model: str = "deepseek-v4-flash" offsite_deepseek_model: str = "deepseek-v4-flash"
offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0) offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0)
#: 平台级 DeepSeek 密钥。`model_endpoint_config.secret_ref` 走 `env:DEEPSEEK_API_KEY`,
#: 投顾「推荐依据」的 LLM 增强也复用它 —— 密钥只有一个存放点,不按特性各配一把。
deepseek_api_key: str = ""
#: 投顾「推荐依据」的 LLM 增强(可选)。未启用、或取不到密钥/调用失败时,
#: **自动回退**到确定性的数据化文案:推荐流程绝不因为模型不可用而失败。
advisor_reason_llm_enabled: bool = False
advisor_reason_llm_base_url: str = "https://api.deepseek.com"
#: 实测 `api.deepseek.com`:`deepseek-chat` 正常返回文本;`deepseek-v4-flash` /
#: `deepseek-reasoner` 返回 200 但 `content` 为空(推理型内容在 `reasoning_content`),
#: 客户端已做兜底读取,但默认仍用最稳的 `deepseek-chat`。
advisor_reason_llm_model: str = "deepseek-chat"
advisor_reason_llm_timeout_seconds: float = Field(default=20, gt=0)
offsite_smtp_enabled: bool = False offsite_smtp_enabled: bool = False
offsite_smtp_dry_run: bool = True offsite_smtp_dry_run: bool = True
offsite_smtp_host: str = "" offsite_smtp_host: str = ""
+12
View File
@@ -8,6 +8,12 @@ from fastapi.staticfiles import StaticFiles
from app.api.controllers.admin import router as admin_router 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.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.asset_allocation import router as asset_allocation_router
from app.api.controllers.auth import router as auth_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.conversations import router as conversations_router
@@ -28,6 +34,9 @@ from app.api.controllers.recommendations import (
from app.api.controllers.recommendations import ( from app.api.controllers.recommendations import (
advisor_router as recommendation_advisor_router, 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.risk import router as risk_router
from app.api.controllers.trading import router as trading_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.controllers.visitor_tokens import router as visitor_tokens_router
@@ -136,6 +145,9 @@ def create_app() -> FastAPI:
application.include_router(asset_allocation_router) application.include_router(asset_allocation_router)
application.include_router(recommendation_advisor_router) application.include_router(recommendation_advisor_router)
application.include_router(recommendation_admin_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(admin_router)
application.include_router(trading_router) application.include_router(trading_router)
static_directory = Path(__file__).resolve().parent / "static" static_directory = Path(__file__).resolve().parent / "static"
+191
View File
@@ -0,0 +1,191 @@
"""投顾「推荐依据」的 LLM 增强(**可选**,任何一步失败都回退到确定性文案)。
## 为什么要有它
投顾工作台账推方案里,每只产品的「推荐依据」原先是一句**所有产品都一样**的套话,
客户看不出"为什么选这一只"。这里用大模型把**已经算出来的真实参数**
(风险等级、排序得分、区间收益、最大回撤、客户的期限与流动性要求)写成
一段面向客户的说明。
## 合规边界(三条,都在代码里强制执行)
1. **只用给定数据**:提示词里明确禁止编造数字/业绩/奖项/排名/基金经理信息;
2. **禁止收益承诺**:产出命中 `PROHIBITED_PHRASES`(保本/保证收益/稳赚/无风险…)
即**整条丢弃** —— 与 `investment_goal_service._PROHIBITED_GOAL_PHRASES` 同一口径;
3. **失败即回退**:未启用、缺密钥、超时、HTTP 错误、JSON 解析失败、字段缺失,
一律返回空字典,由调用方保留确定性文案。**推荐流程绝不因模型不可用而失败**。
## 哪些不算数
本服务**不参与选品**,只改文案。选品仍然是 `ProductRecommendationService` 的
硬约束 + 适当性 + 排序,模型看不到也改不了候选池。
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
from app.core.config import get_settings
logger = logging.getLogger(__name__)
#: 收益承诺/绝对化表述 —— 命中即丢弃该条 LLM 文案。
PROHIBITED_PHRASES: tuple[str, ...] = (
"保本", "保证收益", "保收益", "稳赚", "稳赢", "无风险", "零风险",
"收益承诺", "包赚", "必赚", "稳赚不赔", "绝对收益", "确保收益", "锁定收益",
)
#: 文案长度边界:太短没信息量、太长在卡片里读不完。
MIN_REASON_CHARS = 20
MAX_REASON_CHARS = 160
SYSTEM_PROMPT = """你是南方基金的投顾文案助手,为**已通过合规校验**的推荐产品撰写「推荐依据」。
硬性要求:
1. 只能使用我提供的数据,**严禁编造**任何数字、业绩、奖项、排名或基金经理信息;
2. **严禁**出现承诺收益或绝对化表述,例如:保本、保证收益、稳赚、无风险、零风险、收益承诺、包赚、必赚;
3. 每条 45~80 个汉字,面向个人客户,专业克制、可读,说明"为什么这只产品适合这位客户";
4. 必须点出该产品的风险等级,并说明它与客户风险承受能力、投资期限或流动性要求的匹配关系;
5. 只输出 JSON,不要 Markdown 代码块、不要任何解释文字。
输出格式(严格):
{"items": [{"product_code": "159329", "reason": "……"}]}"""
def _pct(value: Any) -> str:
if not isinstance(value, (int, float)):
return "暂无"
return f"{value:+.2f}%"
def build_prompt(customer: dict[str, Any], products: list[dict[str, Any]]) -> str:
"""把客户约束与每只产品的**真实参数**摊平成提示词。"""
lines = [
"【客户约束】",
f"- 风险承受等级:{customer.get('risk_level') or '未知'}",
f"- 投资期限:{customer.get('horizon_months') or '未知'} 个月",
f"- 流动性要求:{customer.get('liquidity') or '未知'}",
"",
"【待写依据的产品】",
]
for product in products:
lines.extend([
f"- product_code={product.get('product_code')}",
f" 名称:{product.get('product_name')}({product.get('product_category')})",
f" 风险等级:{product.get('risk_level')}",
f" 排序得分:{product.get('score')}(0~1,越高表示与客户越匹配)",
f" 近 20 个交易日区间收益:{_pct(product.get('return_20d_pct'))}",
f" 近 60 个交易日区间收益:{_pct(product.get('return_60d_pct'))}",
f" 近 60 个交易日最大回撤:{_pct(product.get('max_drawdown_60d_pct'))}",
f" 系统当前给出的依据(可改写得更易读,但事实不得改变):{product.get('rule_reason')}",
])
lines.append("")
lines.append("请为上面每一只产品各写一条 reason,product_code 必须原样返回。")
return "\n".join(lines)
def _strip_code_fence(raw: str) -> str:
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1] if "\n" in text else text
text = text.rsplit("```", 1)[0]
return text.strip()
def parse_items(raw: str) -> dict[str, str]:
"""从模型输出里解出 `{product_code: reason}`;结构不符一律返回空字典。"""
text = _strip_code_fence(raw)
start = text.find("{")
end = text.rfind("}")
if start == -1 or end <= start:
return {}
try:
payload = json.loads(text[start : end + 1])
except (ValueError, TypeError):
return {}
items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(items, list):
return {}
parsed: dict[str, str] = {}
for item in items:
if not isinstance(item, dict):
continue
code = item.get("product_code")
reason = item.get("reason")
if isinstance(code, str) and isinstance(reason, str) and reason.strip():
parsed[code.strip()] = reason.strip()
return parsed
def is_compliant(text: str) -> bool:
"""合规守卫:长度合理 + 不含收益承诺类表述。"""
if not (MIN_REASON_CHARS <= len(text) <= MAX_REASON_CHARS):
return False
return not any(phrase in text for phrase in PROHIBITED_PHRASES)
class AdvisorReasonService:
"""调用 OpenAI-compatible `/chat/completions` 生成推荐依据;失败返回空字典。"""
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
self.client = client
async def enhance(
self, *, customer: dict[str, Any], products: list[dict[str, Any]]
) -> dict[str, str]:
settings = get_settings()
if not settings.advisor_reason_llm_enabled or not products:
return {}
api_key = settings.deepseek_api_key
if not api_key:
logger.warning("推荐依据 LLM 已启用但缺少 DEEPSEEK_API_KEY,回退到规则文案")
return {}
url = settings.advisor_reason_llm_base_url.rstrip("/") + "/chat/completions"
payload = {
"model": settings.advisor_reason_llm_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_prompt(customer, products)},
],
"temperature": 0,
"max_tokens": 1500,
}
owns_client = self.client is None
client = self.client or httpx.AsyncClient()
try:
response = await client.post(
url,
headers={"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"},
json=payload,
timeout=httpx.Timeout(settings.advisor_reason_llm_timeout_seconds),
)
response.raise_for_status()
body: Any = response.json()
message = (body.get("choices") or [{}])[0].get("message") or {}
# 推理型模型把正文放在 `reasoning_content`,`content` 可能为空 —— 兜底读一次。
content = message.get("content") or message.get("reasoning_content") or ""
parsed = parse_items(str(content))
except Exception: # noqa: BLE001 — 模型不可用绝不能影响推荐主流程
logger.warning("推荐依据 LLM 调用失败,回退到规则文案", exc_info=True)
return {}
finally:
if owns_client:
await client.aclose()
allowed_codes = {str(product.get("product_code")) for product in products}
accepted: dict[str, str] = {}
for code, reason in parsed.items():
if code not in allowed_codes:
continue
if not is_compliant(reason):
logger.warning(
"推荐依据 LLM 文案未通过合规守卫,丢弃(product_code=%s)", code
)
continue
accepted[code] = reason
return accepted
+263 -3
View File
@@ -1,5 +1,6 @@
"""Constraint-first recommendations for the exchange-traded simulation domain.""" """Constraint-first recommendations for the exchange-traded simulation domain."""
import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -8,19 +9,28 @@ from sqlalchemy import select
from app.core.config import get_settings from app.core.config import get_settings
from app.core.contracts import RequestContext from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidStateError from app.core.errors import (
ForbiddenAgentError,
GenericResourceNotFoundError,
InvalidStateError,
)
from app.core.product_recommendation_contracts import ProductRecommendationQuery from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.infrastructure.db import SessionFactory from app.infrastructure.db import SessionFactory
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
from app.model.audit import InteractionAudit from app.model.audit import InteractionAudit
from app.model.fund import FundNavHistory
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
from app.repository.advisor_product_repository import ( from app.repository.advisor_product_repository import (
AdvisorProductRepository, AdvisorProductRepository,
AuthoritativeProductCandidate, AuthoritativeProductCandidate,
) )
from app.service.advisor_reason_service import AdvisorReasonService
from app.service.api_transaction_service import ApiTransactionService from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService from app.service.authorization_service import AuthorizationService
from app.service.investment_goal_service import InvestmentGoalService from app.service.investment_goal_service import InvestmentGoalService
# 流动性要求的中文文案与投资目标线**同源**(`investment_goal_service._LIQUIDITY_LABELS`):
# 这里直接复用,避免两处各翻一遍、日后口径漂移。
from app.service.investment_goal_service import _LIQUIDITY_LABELS
from app.service.product_governance_monitor_service import SALES_INSTITUTION from app.service.product_governance_monitor_service import SALES_INSTITUTION
from app.service.profile_governance_service import ProfileGovernanceService from app.service.profile_governance_service import ProfileGovernanceService
from app.service.relationship_service import RelationshipService from app.service.relationship_service import RelationshipService
@@ -31,6 +41,8 @@ from app.service.suitability_service import SuitabilityService
#: 推荐方案用 `content_id`,方案书用 `goal_no`。 #: 推荐方案用 `content_id`,方案书用 `goal_no`。
BOOK_CONTENT_TYPE = "investment_goal_book" BOOK_CONTENT_TYPE = "investment_goal_book"
logger = logging.getLogger(__name__)
class ProductRecommendationService: class ProductRecommendationService:
CONTENT_TYPE = "advisor_recommendation_plan" CONTENT_TYPE = "advisor_recommendation_plan"
@@ -95,6 +107,12 @@ class ProductRecommendationService:
self._view(item, index, goal, graph_context) self._view(item, index, goal, graph_context)
for index, item in enumerate(selected, start=1) for index, item in enumerate(selected, start=1)
] ]
# 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。
# 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。
# 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。
products = await self._enhance_reasons(
products, selected, goal, authority.customer_risk_level
)
plan = { plan = {
"document_type": "advisor_recommendation_plan", "document_type": "advisor_recommendation_plan",
"document_version": "1.0", "document_version": "1.0",
@@ -228,13 +246,24 @@ class ProductRecommendationService:
candidate, score = item candidate, score = item
product = candidate.product product = candidate.product
contract = candidate.contract contract = candidate.contract
# 推荐依据要给出**这一只**的真实参数,而不是每只都同一句套话。
# 全部取自本次计算过程(硬约束、适当性等级、目标的期限/流动性、排序得分),
# 不做任何收益承诺 —— 文案里不出现"稳赚/保本"这类词。
liquidity_raw = str(goal.get("liquidity_requirement") or "")
liquidity_label = _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw or "--")
reason = (
"已通过三项硬约束:场内可交易 / 权威适当性证据 / 基金合同快照;"
f"风险等级 {candidate.suitability.risk_level},与客户风险承受力匹配;"
f"按投资期限 {goal.get('investment_horizon_months')} 个月、"
f"流动性要求「{liquidity_label}」筛入;"
f"综合排序得分 {round(score, 4)}。"
)
return { return {
"rank": rank, "rank": rank,
"product_code": product.product_code, "product_code": product.product_code,
"product_name": product.product_name, "product_name": product.product_name,
"product_category": product.product_category, "product_category": product.product_category,
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验," "reason": reason,
"并与已确认投资目标的期限和流动性要求相匹配。",
"score": round(score, 4), "score": round(score, 4),
"recommendation_evidence_card": { "recommendation_evidence_card": {
"card_version": "1.0", "card_version": "1.0",
@@ -270,6 +299,80 @@ class ProductRecommendationService:
}, },
} }
async def _performance_snapshot(
self, product_ids: list[int]
) -> dict[int, dict[str, float | None]]:
"""每只产品的近 20/60 个交易日区间收益与近 60 日最大回撤(`fin_nav_history`)。
这些数字是「推荐依据」的**事实来源**:既给确定性文案,也给大模型当输入,
避免模型自己编业绩。表里没数据时对应值为 None(文案与提示词都显示「暂无」)。
"""
if not product_ids:
return {}
snapshot: dict[int, dict[str, float | None]] = {
pid: {"return_20d_pct": None, "return_60d_pct": None, "max_drawdown_60d_pct": None}
for pid in product_ids
}
async with self.session_factory() as session:
rows = (
await session.execute(
select(FundNavHistory.product_id, FundNavHistory.nav)
.where(FundNavHistory.product_id.in_(product_ids))
.order_by(FundNavHistory.product_id, FundNavHistory.nav_date.asc())
)
).all()
grouped: dict[int, list[float]] = {}
for product_id, nav in rows:
grouped.setdefault(int(product_id), []).append(float(nav))
for product_id, values in grouped.items():
snapshot[int(product_id)] = {
"return_20d_pct": _interval_return(values, 20),
"return_60d_pct": _interval_return(values, 60),
"max_drawdown_60d_pct": _max_drawdown_pct(values[-61:]),
}
return snapshot
async def _enhance_reasons(
self,
products: list[dict[str, object]],
selected: list[tuple[AuthoritativeProductCandidate, float]],
goal: dict[str, object],
customer_risk_level: int,
) -> list[dict[str, object]]:
"""给每只产品标 `reason_source`(llm / rule),并在可用时替换成 LLM 文案。"""
snapshot: dict[int, dict[str, float | None]] = {}
try:
snapshot = await self._performance_snapshot(
[int(candidate.product.id) for candidate, _ in selected]
)
except Exception: # noqa: BLE001 — 读不到历史不该影响出方案
logger.warning("推荐依据:历史净值读取失败,按「暂无」处理", exc_info=True)
prompt_products: list[dict[str, object]] = []
for index, product in enumerate(products):
product_id = int(selected[index][0].product.id) if index < len(selected) else None
facts = snapshot.get(product_id, {}) if product_id is not None else {}
product["performance"] = facts
prompt_products.append({**product, **facts})
liquidity_raw = str(goal.get("liquidity_requirement") or "")
generated = await AdvisorReasonService().enhance(
customer={
"risk_level": customer_risk_level,
"horizon_months": goal.get("investment_horizon_months"),
"liquidity": _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw),
},
products=prompt_products,
)
for product in products:
code = str(product.get("product_code"))
if code in generated:
product["reason"] = generated[code]
product["reason_source"] = "llm"
else:
product["reason_source"] = "rule"
return products
async def _graph_context(self, customer_id: int) -> dict[str, object]: async def _graph_context(self, customer_id: int) -> dict[str, object]:
if self.relationship_service is None: if self.relationship_service is None:
return {"degraded": True, "reason": "graph_not_configured"} return {"degraded": True, "reason": "graph_not_configured"}
@@ -349,6 +452,63 @@ class ProductRecommendationService:
operation, operation,
) )
async def delete(
self, content_id: int, context: RequestContext, key: str | None
) -> dict[str, object]:
"""删除推荐方案(投顾工作台「历史方案记录」里的「删除」)。
## 为什么只允许删推荐方案
投资方案书(`BOOK_CONTENT_TYPE`)被 `advisor_investment_goal.goal_book_content_id`
以 `NO ACTION` 外键引用,且该列 **`NOT NULL`** —— 硬删方案书必然撞外键(1451)。
方案书有自己的 `goal_no` 生命周期,不走这里。
## 归属
复用 `_visible_customer_ids`(本人 + 名下归属客户),与 `published` / `history`
同一把尺子:不是自己能看的客户,方案也删不得。
"""
await AuthorizationService.require(context, "product-recommendation:delete")
async def operation(session: Any) -> dict[str, object]:
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
if content is None or content.content_type != self.CONTENT_TYPE:
raise GenericResourceNotFoundError("推荐方案不存在")
if content.customer_id not in self._visible_customer_ids(context):
raise ForbiddenAgentError("无权操作该客户的方案")
now = datetime.now(UTC).replace(tzinfo=None)
await session.delete(content)
# 删除也留痕:`interaction_audit` 与 `client_facing_content` 无外键,
# 方案没了审计仍在(合规要求「删了什么、谁删的」可追)。
session.add(
InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
target_customer_id=content.customer_id,
portal=context.portal,
action_type="advisor.recommendation_deleted",
detail={
"content_id": str(content_id),
"content_type": self.CONTENT_TYPE,
"trace_id": context.trace_id,
},
created_at=now,
)
)
await session.flush()
return {
"data": {"content_id": str(content_id), "status": "deleted"},
"meta": {"trace_id": context.trace_id},
}
return await ApiTransactionService().execute(
context,
f"advisor:recommendations:{content_id}:delete",
key,
{"delete": True},
operation,
)
@staticmethod @staticmethod
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]: def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
"""可查看的客户 id:本人 + 名下归属客户。 """可查看的客户 id:本人 + 名下归属客户。
@@ -395,6 +555,83 @@ class ProductRecommendationService:
"meta": {"trace_id": context.trace_id}, "meta": {"trace_id": context.trace_id},
} }
async def my_published(self, context: RequestContext) -> dict[str, object]:
"""客户视角:**自己**已被审核发布的投顾交付物(`/api/v1/users/me/advisor-contents`)。
与 `published()`(投顾侧)的区别在**范围**:这里只看 `customer_id == 自己`,
不掺 `sys_customer_assignment` —— 那是投顾的归属概念,客户没有归属客户。
只返回 `published_at` 非空的:投顾「发送给客户」之前,客户看不到。
"""
await AuthorizationService.require(context, "product-recommendation:read:self")
customer_id = int(context.user_id)
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id == customer_id,
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
ClientFacingContent.published_at.is_not(None),
)
.order_by(ClientFacingContent.published_at.desc())
.limit(50)
)
)
return {
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"plan": row.draft_content,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
for row in rows
],
"meta": {"trace_id": context.trace_id},
}
async def history(self, context: RequestContext) -> dict[str, object]:
"""投顾本人的方案留档:本人 + 名下归属客户的**全部状态**方案与方案书。
与 `published()` 的唯一差别是**状态口径**:`published` 只给已发布(供客户看),
`history` 给全部(含 `pending_review`/`pending` 待审、`rejected` 已驳回),
供投顾在工作台回看"以前生成过什么"。归属过滤复用 `_visible_customer_ids`,
与 `published` 保持同一把尺子。
"""
await AuthorizationService.require(context, "product-recommendation:read:self")
customer_ids = self._visible_customer_ids(context)
if not customer_ids:
return {"data": [], "meta": {"trace_id": context.trace_id}}
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id.in_(customer_ids),
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
)
.order_by(ClientFacingContent.created_at.desc())
.limit(50)
)
)
return {
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"review_status": row.review_status,
"plan": row.draft_content,
"created_at": row.created_at.isoformat() if row.created_at else None,
"reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
for row in rows
],
"meta": {"trace_id": context.trace_id},
}
async def pending_reviews(self, context: RequestContext) -> dict[str, object]: async def pending_reviews(self, context: RequestContext) -> dict[str, object]:
"""管理面复核队列:待审核的推荐方案与投资方案书。 """管理面复核队列:待审核的推荐方案与投资方案书。
@@ -468,3 +705,26 @@ async def product_recommendation_tool(
return await ProductRecommendationService(enforce_profile_governance=True).generate( return await ProductRecommendationService(enforce_profile_governance=True).generate(
arguments, context, None arguments, context, None
) )
def _interval_return(values: list[float], trading_days: int) -> float | None:
"""近 N 个交易日的区间收益(%);数据不足或基准为 0 时返回 None。"""
if len(values) < 2:
return None
base = values[max(0, len(values) - 1 - trading_days)]
if not base:
return None
return (values[-1] - base) / base * 100
def _max_drawdown_pct(values: list[float]) -> float | None:
"""区间最大回撤(%,负值);数据不足返回 None。"""
if len(values) < 2:
return None
peak = values[0]
worst = 0.0
for value in values:
peak = max(peak, value)
if peak:
worst = min(worst, (value - peak) / peak * 100)
return worst
@@ -0,0 +1,397 @@
// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
//
// 为什么单独抽一个模块:这两处展示的是同一份 `advisor_recommendation_plan`,
// 各写一套必然漂移(改了一边忘了另一边)。本模块保持**纯渲染 + 一次注解式取数**:
// · `renderPlanProducts(products)` —— 同步产出骨架(只用方案自带字段);
// · `hydratePlanView(root, apiClient, products)` —— 取行情/净值后填图表与指标。
//
// ⚠️ 不 import `api-client.js`:投顾页与客户页引入的 `apiClient` 版本不同,
// 在共享模块里再 import 一份会多出一个实例(`ENDPOINTS` 注册表也就有了两份)。
// 所以 apiClient 由调用方当参数传进来(依赖注入)。
//
// 数据来源(都已在 `api-client.js` 注册):
// · P001 `GET /api/v1/products` → 最新净值/当日涨跌/基金经理/费率
// · P002 `GET /api/v1/products/{code}/nav-history` → 近 N 个交易日净值序列(画折线)
//
// 颜色口径:**涨红跌绿**(A 股习惯)。样式里用 `--plan-up` / `--plan-down` 两个变量
// 集中定义(`common/advisor-plan.css`),要与产品列表页的“绿涨红跌”一致时对调即可。
import { escapeHtml } from '/static/portal/common/formatters.js';
//: 从净值序列里取最近多少个交易日画走势。
const SPARK_DAYS = 180;
//: 卡片上展示的区间涨跌(交易日数)。
const INTERVALS = Object.freeze([
['近 20 日', 20],
['近 60 日', 60],
]);
//: 环形图配色(最多 6 色循环)。用固定色值而不是主题变量:环形切片需要彼此可区分。
const SLICE_COLORS = Object.freeze([
'#2d6f67', '#c99a2e', '#4a7fb5', '#a6573f', '#7a6bab', '#5f8f52',
]);
/**
* ⚠️ 这里必须显式挡 `null` / `undefined` / `''`。
* 直接 `Number(null)` 会得到 **0** —— 于是"没有数据"被渲染成 `0.00%`(曾真出过这个 bug:
* 近 20/60 日与管理费全显示 0.00%,看起来像"真的是 0")。`Number('')` 同理。
*/
function num(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/** 涨跌幅文本;拿不到就 `--`,绝不渲染成 `+0.00%`(会把"不知道"说成"平盘")。 */
function pctText(value) {
const parsed = num(value);
if (parsed === null) return '--';
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(2)}%`;
}
/** 涨跌配色类;`null` 用中性色。 */
function pctClass(value) {
const parsed = num(value);
if (parsed === null) return 'advisor-plan__value--flat';
if (parsed > 0) return 'advisor-plan__value--up';
if (parsed < 0) return 'advisor-plan__value--down';
return 'advisor-plan__value--flat';
}
function navValues(points) {
return points.map((point) => num(point.nav)).filter((value) => value !== null);
}
function intervalChange(values, tradingDays) {
if (values.length < 2) return null;
const baseIndex = Math.max(0, values.length - 1 - tradingDays);
const base = values[baseIndex];
if (!base) return null;
return ((values[values.length - 1] - base) / base) * 100;
}
function metric(label, value, className = '') {
return `<div class="advisor-plan__metric"><dt>${escapeHtml(label)}</dt>`
+ `<dd class="${className}">${escapeHtml(value)}</dd></div>`;
}
// ---- 折线图(带坐标轴/网格;内联 SVG,不引图表库) ----
function shortDate(iso) {
return String(iso || '').slice(5);
}
/**
* 通用折线图。`series` 是 `[{date, value}]`;`formatter` 决定 y 轴刻度文本。
* 缺数据(<2 点)返回空串,由调用方给"暂无数据"文案。
*/
function lineChartSvg(series, { formatter, ariaLabel }) {
if (series.length < 2) return '';
const width = 640;
const height = 220;
const left = 58;
const right = 16;
const top = 14;
const bottom = 30;
const values = series.map((point) => point.value);
let min = Math.min(...values);
let max = Math.max(...values);
if (min === max) {
min -= 0.01;
max += 0.01;
}
const padding = (max - min) * 0.08;
min -= padding;
max += padding;
const xAt = (index) => left + (index * (width - left - right)) / (series.length - 1);
const yAt = (value) => top + (1 - (value - min) / (max - min)) * (height - top - bottom);
const rows = 4;
const grid = Array.from({ length: rows + 1 }, (_, index) => {
const value = min + ((max - min) * index) / rows;
const y = yAt(value).toFixed(1);
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}"/>`
+ `<text x="${left - 8}" y="${(Number(y) + 3.5).toFixed(1)}" text-anchor="end">`
+ `${escapeHtml(formatter(value))}</text>`;
}).join('');
const labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1];
const xLabels = labelIndexes.map((index, position) => {
const anchor = position === 0 ? 'start' : (position === 2 ? 'end' : 'middle');
return `<text x="${xAt(index).toFixed(1)}" y="${height - 9}" text-anchor="${anchor}">`
+ `${escapeHtml(shortDate(series[index].date))}</text>`;
}).join('');
const coords = series
.map((point, index) => `${xAt(index).toFixed(1)},${yAt(point.value).toFixed(1)}`)
.join(' ');
const rising = values[values.length - 1] >= values[0];
return `<svg class="advisor-plan__chart-svg" viewBox="0 0 ${width} ${height}" role="img"`
+ ` aria-label="${escapeHtml(ariaLabel)}">`
+ `<g class="advisor-plan__chart-grid">${grid}</g>`
+ `<g class="advisor-plan__axis">${xLabels}</g>`
+ `<polyline class="advisor-plan__line advisor-plan__line--${rising ? 'up' : 'down'}"`
+ ` points="${coords}"/></svg>`;
}
/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
function normalizedPct(points) {
const cleaned = points
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
.filter((point) => point.value !== null);
if (cleaned.length < 2) return [];
const base = cleaned[0].value;
if (!base) return [];
return cleaned.map((point) => ({ date: point.date, value: (point.value / base - 1) * 100 }));
}
/** 等权组合:各基金按共同交易日对齐后取均值。 */
function equalWeightSeries(navsByCode) {
const series = Object.values(navsByCode)
.map((points) => normalizedPct(points))
.filter((pts) => pts.length >= 2);
if (series.length < 2) return [];
let common = new Set(series[0].map((point) => point.date));
series.slice(1).forEach((pts) => {
const dates = new Set(pts.map((point) => point.date));
common = new Set([...common].filter((date) => dates.has(date)));
});
const dates = [...common].sort();
if (dates.length < 2) return [];
return dates.map((date) => ({
date,
value: series.reduce((sum, pts) => {
const hit = pts.find((point) => point.date === date);
return sum + (hit ? hit.value : 0);
}, 0) / series.length,
}));
}
function maxDrawdownPct(series) {
let peak = -Infinity;
let worst = 0;
series.forEach((point) => {
peak = Math.max(peak, point.value);
worst = Math.min(worst, point.value - peak);
});
return worst;
}
// ---- 环形图(资产配置比例) ----
function donutSvg(entries) {
const total = entries.reduce((sum, entry) => sum + entry.weight, 0) || 1;
const radius = 54;
const circumference = 2 * Math.PI * radius;
let offset = 0;
const slices = entries.map((entry, index) => {
const length = (entry.weight / total) * circumference;
const slice = `<circle class="advisor-plan__slice" cx="70" cy="70" r="${radius}"`
+ ` stroke="${SLICE_COLORS[index % SLICE_COLORS.length]}"`
+ ` stroke-dasharray="${length.toFixed(2)} ${(circumference - length).toFixed(2)}"`
+ ` stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
offset += length;
return slice;
}).join('');
return `<svg class="advisor-plan__donut" viewBox="0 0 140 140" role="img" aria-label="组合配置比例">`
+ slices
+ '<text class="advisor-plan__donut-title" x="70" y="66" text-anchor="middle">等权组合</text>'
+ `<text class="advisor-plan__donut-sub" x="70" y="84" text-anchor="middle">${entries.length} 只</text>`
+ '</svg>';
}
// ---- 方案骨架 ----
function riskOf(product) {
const evidence = product.recommendation_evidence_card || {};
return ((evidence.suitability || {}).risk_level) || product.risk_level || '--';
}
function cardHtml(product) {
const code = String(product.product_code || '');
return '<article class="advisor-plan__card" data-plan-card="' + escapeHtml(code) + '">'
+ '<header class="advisor-plan__head">'
+ `<div><h4 class="advisor-plan__name">${escapeHtml(product.product_name || '--')}</h4>`
+ `<p class="advisor-plan__sub">${escapeHtml(code)}`
+ ` · ${escapeHtml(product.product_category || '--')}</p></div>`
+ '<div class="advisor-plan__badges">'
+ `<span class="advisor-plan__risk">${escapeHtml(riskOf(product))}</span>`
+ `<span class="advisor-plan__score">评分 ${escapeHtml(String(product.score ?? '--'))}</span>`
+ '</div></header>'
+ `<div class="advisor-plan__chart" data-plan-chart="${escapeHtml(code)}">`
+ '<p class="advisor-plan__chart-pending">走势加载中…</p></div>'
+ `<dl class="advisor-plan__metrics" data-plan-metrics="${escapeHtml(code)}">`
+ metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--'))
+ '</dl>'
+ `<ul class="advisor-plan__highlights" data-plan-highlights="${escapeHtml(code)}"></ul>`
// 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。
+ '<p class="advisor-plan__reason"><strong>推荐依据'
+ (product.reason_source === 'llm'
? '<span class="advisor-plan__tag">AI 生成</span>'
: '<span class="advisor-plan__tag advisor-plan__tag--rule">规则生成</span>')
+ `</strong>${escapeHtml(product.reason || '--')}</p>`
+ '</article>';
}
/** 同步骨架:只用方案自带字段,行情/净值随后由 `hydratePlanView` 补。 */
export function renderPlanProducts(products) {
if (!products.length) return '';
const codes = products.map((product) => String(product.product_code || '')).join(',');
const entries = products.map((product) => ({
label: product.product_name || product.product_code || '--',
code: String(product.product_code || ''),
weight: 1,
}));
const legend = entries.map((entry, index) => {
const share = Math.round((entry.weight / entries.length) * 100);
return `<li><i style="background:${SLICE_COLORS[index % SLICE_COLORS.length]}"></i>`
+ `<span class="advisor-plan__legend-name">${escapeHtml(entry.label)}</span>`
+ `<span class="advisor-plan__legend-code">${escapeHtml(entry.code)}</span>`
+ `<b>${share}%</b></li>`;
}).join('');
return `<div class="advisor-plan" data-plan-view data-codes="${escapeHtml(codes)}">`
+ '<section class="advisor-plan__block">'
+ '<h4>组合业绩(等权合成)</h4>'
+ '<div class="advisor-plan__stats" data-plan-combo-stats></div>'
+ '<div class="advisor-plan__chart" data-plan-combo>'
+ '<p class="advisor-plan__chart-pending">组合走势加载中…</p></div>'
+ '</section>'
+ '<section class="advisor-plan__block">'
+ '<h4>资产配置比例(等权)</h4>'
+ '<div class="advisor-plan__config">'
+ `<div class="advisor-plan__donut-wrap">${donutSvg(entries)}</div>`
+ `<ul class="advisor-plan__legend">${legend}</ul>`
+ '</div></section>'
+ `<div class="advisor-plan__grid">${products.map(cardHtml).join('')}</div>`
+ '</div>';
}
// ---- 注解式补水 ----
async function fetchNav(apiClient, code) {
try {
const response = await apiClient.get('P002', {
pathParams: { productCode: code },
query: { days: SPARK_DAYS },
});
const points = response?.data?.points;
return Array.isArray(points) ? points : [];
} catch {
// 单只基金取不到净值不该拖垮整块:留空,卡片显示“暂无净值数据”。
return [];
}
}
function fillCardProducts(products) {
return new Map(products.map((product) => [String(product.product_code || ''), product]));
}
function enrichCard(block, code, product, facts, points) {
const chartNode = block.querySelector(`[data-plan-chart="${code}"]`);
const metricsNode = block.querySelector(`[data-plan-metrics="${code}"]`);
const highlightsNode = block.querySelector(`[data-plan-highlights="${code}"]`);
const values = navValues(points);
if (chartNode) {
const series = points
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
.filter((point) => point.value !== null);
chartNode.innerHTML = series.length >= 2
? lineChartSvg(series, {
formatter: (value) => value.toFixed(3),
ariaLabel: `${product?.product_name || code} 近 ${series.length} 个交易日净值走势`,
})
: '<p class="advisor-plan__chart-empty">暂无净值数据'
+ '(可运行 tools/sync_nav_history.py 同步)</p>';
}
if (metricsNode) {
const latestNav = values.length ? values[values.length - 1] : num(facts?.current_nav);
const rows = [
metric('最新净值', latestNav === null ? '--' : latestNav.toFixed(4)),
metric('当日涨跌', pctText(facts?.change_pct), pctClass(facts?.change_pct)),
];
INTERVALS.forEach(([label, days]) => {
const change = intervalChange(values, days);
rows.push(metric(label, pctText(change), pctClass(change)));
});
rows.push(metric('基金经理', facts?.fund_manager || '--'));
const fee = num(facts?.management_fee_rate);
// `management_fee_rate` 在库里可能是 NULL 或 0(未维护)——显示成「0.00%/年」
// 会被读成"这只基金不要管理费",与事实相反,所以按"没数据"处理。
rows.push(metric('管理费', fee === null || fee <= 0 ? '--' : `${(fee * 100).toFixed(2)}%/年`));
metricsNode.innerHTML = rows.join('');
}
if (highlightsNode) {
const items = [];
const c60 = intervalChange(values, 60);
const c20 = intervalChange(values, 20);
if (c60 !== null) items.push(`近 60 个交易日区间收益 ${pctText(c60)}`);
if (c20 !== null) items.push(`近 20 日区间收益 ${pctText(c20)}`);
if (values.length >= 2) {
items.push(`区间最大回撤 ${pctText(Math.min(0, maxDrawdownPct(
values.map((value) => ({ date: '', value })),
)))}`);
}
items.push(`风险等级 ${riskOf(product)},已按你的风险测评与投资期限做匹配校验`);
if (facts?.fund_manager) items.push(`基金经理 ${facts.fund_manager}`);
highlightsNode.innerHTML = items.map((text) => `<li>${escapeHtml(text)}</li>`).join('');
}
}
function fillCombo(block, navsByCode) {
const chartNode = block.querySelector('[data-plan-combo]');
const statsNode = block.querySelector('[data-plan-combo-stats]');
const series = equalWeightSeries(navsByCode);
if (!chartNode) return;
if (series.length < 2) {
chartNode.innerHTML = '<p class="advisor-plan__chart-empty">成分基金净值不足,'
+ '暂无法合成组合走势。</p>';
return;
}
const total = series[series.length - 1].value;
const drawdown = maxDrawdownPct(series);
chartNode.innerHTML = lineChartSvg(series, {
formatter: (value) => `${value.toFixed(1)}%`,
ariaLabel: `等权组合近 ${series.length} 个交易日累计收益走势`,
});
if (statsNode) {
statsNode.innerHTML = metric('区间收益', pctText(total), pctClass(total))
+ metric('最大回撤', pctText(drawdown), pctClass(drawdown))
+ metric('成分基金', `${Object.keys(navsByCode).length} 只`)
+ metric('区间', `${series[0].date} ~ ${series[series.length - 1].date}`);
}
}
/**
* 注解式补水:取 P001(一次,含全部产品)+ P002(每只一次),
* 填组合走势、卡片折线图、指标与看点。任何一步失败都只降级为「暂无数据」,不抛错。
*/
export async function hydratePlanView(root, apiClient, products = []) {
const block = root?.querySelector?.('[data-plan-view]');
if (!block || typeof apiClient?.get !== 'function') return;
const codes = String(block.dataset.codes || '').split(',').filter(Boolean);
if (!codes.length) return;
const byCode = fillCardProducts(products);
const productsByCode = new Map();
try {
const response = await apiClient.get('P001');
(response?.data?.products || []).forEach((item) => {
productsByCode.set(String(item.product_code), item);
});
} catch {
// 产品清单取不到:仍能画净值走势,只是基金经理/费率显示 `--`。
}
const navs = await Promise.all(codes.map((code) => fetchNav(apiClient, code)));
const navsByCode = {};
codes.forEach((code, index) => { navsByCode[code] = navs[index] || []; });
fillCombo(block, navsByCode);
codes.forEach((code, index) => {
enrichCard(block, code, byCode.get(code), productsByCode.get(code), navs[index] || []);
});
}
+119
View File
@@ -0,0 +1,119 @@
/* 推荐方案「可视化」样式 —— 投顾工作台结果区与客户「我的投顾方案」页共用。
*
* 颜色口径:**涨红跌绿**(A 股习惯)。
* ⚠️ 仓库里 `formatters.js` 的 `value--positive` 是**绿涨红跌**(产品列表页/收益明细沿用)。
* 若要与那几页统一,把下面两个变量对调即可(只需改这一处)。
*/
.advisor-plan {
--plan-up: var(--danger); /* 涨 → 红 */
--plan-down: var(--success); /* 跌 → 绿 */
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
/* ---- 区块(组合业绩 / 资产配置比例) ---- */
.advisor-plan__block {
padding: var(--space-3) var(--space-4);
display: grid;
gap: var(--space-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.advisor-plan__block h4 { margin: 0; font-size: var(--fs-body); }
/* ---- 组合统计(区间收益 / 最大回撤 / 成分 / 区间) ---- */
.advisor-plan__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
gap: var(--space-2);
}
/* ---- 配置环形图 + 图例 ---- */
.advisor-plan__config { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.advisor-plan__donut-wrap { flex: 0 0 auto; width: 140px; }
.advisor-plan__donut { width: 140px; height: 140px; display: block; }
.advisor-plan__slice { fill: none; stroke-width: 24; }
.advisor-plan__donut-title { fill: var(--ink); font-size: 13px; font-weight: 700; }
.advisor-plan__donut-sub { fill: var(--muted); font-size: 11px; }
.advisor-plan__legend { margin: 0; padding: 0; flex: 1 1 240px; display: grid; gap: var(--space-1); list-style: none; }
.advisor-plan__legend li { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: var(--space-2); font-size: var(--fs-small); }
.advisor-plan__legend i { width: 10px; height: 10px; border-radius: 2px; }
.advisor-plan__legend-name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.advisor-plan__legend-code { color: var(--muted); }
.advisor-plan__legend b { color: var(--ink-soft); }
/* ---- 折线图 ---- */
.advisor-plan__chart { display: grid; gap: var(--space-1); }
.advisor-plan__chart-svg { width: 100%; height: auto; display: block; }
.advisor-plan__chart-grid line { stroke: var(--line); stroke-width: 1; }
.advisor-plan__axis text, .advisor-plan__chart-grid text { fill: var(--muted); font-size: 11px; }
.advisor-plan__line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.advisor-plan__line--up { stroke: var(--plan-up); }
.advisor-plan__line--down { stroke: var(--plan-down); }
.advisor-plan__chart-pending, .advisor-plan__chart-empty { margin: 0; color: var(--muted); font-size: var(--fs-small); }
/* ---- 产品卡片 ---- */
.advisor-plan__grid { display: grid; gap: var(--space-3); }
.advisor-plan__card {
padding: var(--space-4);
display: grid;
gap: var(--space-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.advisor-plan__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.advisor-plan__name { margin: 0; font-size: var(--fs-title); font-weight: 700; }
.advisor-plan__sub { margin: var(--space-1) 0 0; color: var(--muted); font-size: var(--fs-small); }
.advisor-plan__badges { display: inline-flex; align-items: center; gap: var(--space-2); }
.advisor-plan__risk {
padding: 2px var(--space-2);
color: var(--danger);
background: var(--danger-soft);
border-radius: var(--radius-sm);
font-size: var(--fs-small);
font-weight: 700;
}
.advisor-plan__score {
padding: 2px var(--space-2);
color: var(--brand-dark);
background: var(--brand-soft);
border-radius: var(--radius-sm);
font-size: var(--fs-small);
font-weight: 700;
}
/* ---- 指标 ---- */
.advisor-plan__metrics {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: var(--space-2);
}
.advisor-plan__metric { padding: var(--space-2); background: var(--surface-soft); border-radius: var(--radius-sm); }
.advisor-plan__metric dt { color: var(--muted); font-size: var(--fs-small); }
.advisor-plan__metric dd { margin: var(--space-1) 0 0; font-weight: 700; }
.advisor-plan__value--up { color: var(--plan-up); }
.advisor-plan__value--down { color: var(--plan-down); }
.advisor-plan__value--flat { color: var(--ink-soft); }
/* ---- 看点 ---- */
.advisor-plan__highlights { margin: 0; padding-left: 1.1em; display: grid; gap: var(--space-1); color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.6; }
.advisor-plan__highlights:empty { display: none; }
.advisor-plan__reason { margin: 0; color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.7; }
.advisor-plan__reason strong { display: block; margin-bottom: var(--space-1); color: var(--ink); }
/* 文案来源标注:AI 生成的必须可辨识。 */
.advisor-plan__tag {
margin-left: var(--space-2);
padding: 1px var(--space-2);
color: var(--brand-dark);
background: var(--brand-soft);
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
vertical-align: middle;
}
.advisor-plan__tag--rule { color: var(--muted); background: var(--surface-soft); }
@@ -1,4 +1,4 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1';
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913'; import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderError, renderLoading } from '/static/portal/common/state-view.js'; import { renderError, renderLoading } from '/static/portal/common/state-view.js';
@@ -15,6 +15,7 @@ const PUBLIC_LINKS = [
const CUSTOMER_LINKS = [ const CUSTOMER_LINKS = [
['dashboard', '资产总览', '/portal/customer/dashboard/'], ['dashboard', '资产总览', '/portal/customer/dashboard/'],
['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'],
['holdings', '我的持仓', '/portal/customer/holdings/'], ['holdings', '我的持仓', '/portal/customer/holdings/'],
['profit-loss', '收益明细', '/portal/customer/profit-loss/'], ['profit-loss', '收益明细', '/portal/customer/profit-loss/'],
['orders', '交易记录', '/portal/customer/orders/'], ['orders', '交易记录', '/portal/customer/orders/'],
@@ -0,0 +1,46 @@
/* 「我的投顾方案」页面样式。
*
* 只写本页自己的东西(交付物卡片列表);表格沿用 common/base.css 的 `.data-table`。
* 取色/取间距一律用 tokens.css 的变量,不写死色值与 px。
*/
.advisor-plan-card {
padding: var(--space-4);
display: grid;
gap: var(--space-2);
border-bottom: 1px solid var(--line);
}
.advisor-plan-card:last-child { border-bottom: 0; }
.advisor-plan-card__title { margin: 0; font-size: var(--fs-title); font-weight: 700; }
.advisor-plan-card__no { color: var(--muted); font-size: var(--fs-small); font-weight: 500; }
.advisor-plan-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
.advisor-plan-card .button--quiet { justify-self: start; }
.advisor-plan-card__detail { margin-top: var(--space-3); display: grid; gap: var(--space-2); }
.advisor-plan-card__note { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; }
.advisor-plan-card__json {
margin: 0;
padding: var(--space-3);
max-height: 320px;
overflow: auto;
color: var(--ink-soft);
background: var(--surface-soft);
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.advisor-plan-card .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
/* ---- 申报表单(`.form-field` / `.form-alert` 来自 base.css,这里只排布) ---- */
.advisor-request-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
align-items: end;
}
.advisor-request-form__wide { grid-column: 1 / -1; }
.advisor-request-form .button { justify-self: start; }
.advisor-request-form .form-alert { grid-column: 1 / -1; }
@media (max-width: 900px) {
.advisor-request-form { grid-template-columns: 1fr; }
.advisor-request-form__wide { grid-column: auto; }
}
@@ -0,0 +1,212 @@
// 「我的投顾方案」——客户侧的接收页。
//
// 现状背景:投顾工作台的「发送给客户」原先**没有落点**(只把 `published_at` 置上),
// 客户端门户没有任何页面/接口能读到已发布交付物。本页 + `MY_ADVISOR_CONTENTS`
// 端点是那条链路的接收端:投顾发布 → 客户登录这里就能看到。
//
// 数据口径(后端 `ProductRecommendationService.my_published`):
// **本人**(`customer_id == 登录客户`)、`review_status ∈ {approved, published}` 且
// `published_at` 非空的两类内容 —— 推荐方案与投资方案书。
//
// 推荐明细的**可视化**(走势图/指标/组合构成)与投顾工作台结果区共用
// `common/advisor-plan-view.js`:同一份方案在两处渲染成同一个样子。
// `apiClient` 用页面统一的那份(`?v=20260916-plan1`,与 `customer-list-page.js` 一致),
// 由调用方传给 `hydratePlanView`(共享模块内不 import,避免多出一个实例)。
import { setupCustomerListPage } from '/static/portal/common/customer-list-page.js';
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1';
import { showToast } from '/static/portal/common/notifications.js';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { renderEmpty } from '/static/portal/common/state-view.js';
import {
hydratePlanView,
renderPlanProducts,
} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3';
//: 内容类型文案。与投顾侧 `advisor-config.js` 的 `CONTENT_TYPE_LABELS` 同源口径。
const TYPE_LABELS = Object.freeze({
advisor_recommendation_plan: '产品推荐方案',
investment_goal_book: '投资目标方案书',
});
function typeLabel(type) {
return TYPE_LABELS[type] || '投顾方案';
}
function detailHtml(item) {
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
const products = Array.isArray(plan.products) ? plan.products : [];
let html = '';
if (products.length) {
html += renderPlanProducts(products);
const summary = plan.selection_summary;
if (summary) {
html += '<p class="advisor-plan-card__note">'
+ `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
+ `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
+ `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。</p>`;
}
} else {
// 方案书等结构不固定的内容:原样呈现,不臆造字段。
html += `<pre class="advisor-plan-card__json">${escapeHtml(JSON.stringify(plan, null, 2))}</pre>`;
}
(Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
html += `<p class="advisor-plan-card__note">${escapeHtml(String(text))}</p>`;
});
return html;
}
function cardHtml(item) {
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
const products = Array.isArray(plan.products) ? plan.products : [];
const names = products.map((product) => product.product_name || product.product_code || '--').slice(0, 5);
return '<article class="advisor-plan-card">'
+ `<h3 class="advisor-plan-card__title">${escapeHtml(typeLabel(item.content_type))}`
+ ` <span class="advisor-plan-card__no">编号 ${escapeHtml(item.content_id)}</span></h3>`
+ `<p class="advisor-plan-card__meta">发布于 ${escapeHtml(formatDateTime(item.published_at))}</p>`
+ (names.length
? `<p class="advisor-plan-card__meta">推荐产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}</p>`
: '')
+ `<button class="button button--quiet" type="button" data-plan-detail="${escapeHtml(item.content_id)}">查看详情</button>`
+ '<div class="advisor-plan-card__detail" data-plan-body hidden></div>'
+ '</article>';
}
setupCustomerListPage({
active: 'advisor-plans',
endpointId: 'MY_ADVISOR_CONTENTS',
// 后端不做游标分页(一次最多 50 条),恒返回空游标 ⇒ 「下一页」按钮自动禁用。
getNextCursor: () => null,
render(container, items) {
if (!items.length) {
renderEmpty(container, '暂无投顾方案', '投顾审核通过并发送给你的方案会显示在这里。');
return;
}
const byId = new Map(items.map((item) => [String(item.content_id), item]));
container.innerHTML = items.map(cardHtml).join('');
container.querySelectorAll('[data-plan-detail]').forEach((button) => {
button.addEventListener('click', () => {
const body = button.parentElement.querySelector('[data-plan-body]');
const item = byId.get(String(button.dataset.planDetail));
if (!body || !item) return;
if (!body.hidden) {
body.hidden = true;
button.textContent = '查看详情';
return;
}
body.innerHTML = detailHtml(item);
body.hidden = false;
button.textContent = '收起';
// 走势图/组合曲线/指标是异步补的;失败只降级为"暂无净值数据"。
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
void hydratePlanView(body, apiClient, Array.isArray(plan.products) ? plan.products : []);
});
});
},
});
// ---- 主动申报投顾方案 ------------------------------------------------------
//
// 客户在这里提交申报(金额/期限/风险偏好/备注)→ 后端落 `advisor_service_request`
// (status=pending)→ 投顾工作台「客户申报」受理后**自动出方案草稿** →
// 投顾审核通过并发送,方案才会出现在上面的「已收到的方案」里。
//
// 服务端会再校验一次风险测评有效性(FM-03),前端不做前置拦截:
// 拦截只是体验层,真正的边界在服务端。
//: 申报单状态 → 文案 / 标签色。`delivered` 由后端**推导**(关联方案已发布)。
const REQUEST_STATUS_LABELS = Object.freeze({
pending: '待受理',
accepted: '已受理 · 方案待审核',
delivered: '已发送方案',
rejected: '已驳回',
});
const REQUEST_STATUS_TONES = Object.freeze({
pending: 'medium',
accepted: 'active',
delivered: 'low',
rejected: 'failed',
});
function requestCardHtml(item) {
const label = REQUEST_STATUS_LABELS[item.status] || item.status;
const tone = REQUEST_STATUS_TONES[item.status] || 'neutral';
return '<article class="advisor-plan-card">'
+ `<h3 class="advisor-plan-card__title">申报单 ${escapeHtml(item.request_no)}`
+ ` <span class="status-tag status-tag--${tone}">${escapeHtml(label)}</span></h3>`
+ `<p class="advisor-plan-card__meta">${escapeHtml(String(item.amount_wan))} 万元`
+ ` · 期限 ${escapeHtml(item.horizon || '--')}`
+ ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}`
+ ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}</p>`
+ (item.note ? `<p class="advisor-plan-card__meta">备注:${escapeHtml(item.note)}</p>` : '')
+ (item.advisor_note
? `<p class="advisor-plan-card__note">投顾意见:${escapeHtml(item.advisor_note)}</p>`
: '')
+ '</article>';
}
function initRequestSection() {
const form = document.querySelector('[data-request-form]');
const listNode = document.querySelector('[data-request-list]');
const alertNode = document.querySelector('[data-request-alert]');
const submit = document.querySelector('[data-request-submit]');
if (!form || !listNode) return;
function showAlert(message, tone = 'error') {
if (!alertNode) return;
alertNode.textContent = message || '';
alertNode.classList.toggle('form-alert--visible', Boolean(message));
alertNode.classList.toggle('form-alert--info', tone === 'info');
}
async function loadRequests() {
try {
const response = await apiClient.get('MY_ADVISOR_REQUESTS');
const rows = Array.isArray(response.data) ? response.data : [];
listNode.innerHTML = rows.length
? rows.map(requestCardHtml).join('')
: '<p class="advisor-plan-card__note">还没有提交过申报。填好上面的表单点「提交申报」即可。</p>';
} catch (error) {
apiClient.reportError(error);
listNode.innerHTML = '<p class="advisor-plan-card__note">申报记录加载失败,请稍后刷新。</p>';
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
showAlert('');
const amount = Number(form.querySelector('[data-request-amount]').value);
if (!Number.isFinite(amount) || amount <= 0) {
showAlert('请填写大于 0 的投资金额');
return;
}
const note = form.querySelector('[data-request-note]').value.trim();
submit.disabled = true;
submit.textContent = '提交中…';
try {
await apiClient.post('ADVISOR_REQUEST_CREATE', {
amount_wan: amount,
horizon: form.querySelector('[data-request-horizon]').value,
risk_preference: form.querySelector('[data-request-risk]').value,
note: note || null,
});
showToast('申报已提交,投顾受理后会为你出具方案');
form.querySelector('[data-request-note]').value = '';
await loadRequests();
} catch (error) {
apiClient.reportError(error);
// 后端的业务拒绝(例如"请先完成风险测评")要原样给客户看到,否则不知道卡在哪。
showAlert(error.message || '提交失败,请稍后重试');
} finally {
submit.disabled = false;
submit.textContent = '提交申报';
}
});
void loadRequests();
}
if (requireCustomer()) initRequestSection();
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>我的投顾方案 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/advisor-plan.css?v=20260916-plan3"><link rel="stylesheet" href="/static/portal/customer/advisor-plans/advisor-plans.css?v=20260916-plan3"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">我的投顾方案</h1><p class="page-heading__description">可在下方申报投顾方案(需已完成且在有效期内的风险测评);投顾受理并发送后,方案会出现在「已收到的方案」里。</p></div><div class="page-heading__actions"><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel"><div class="panel__header"><h2 class="panel__title">申报投顾方案</h2></div><div class="panel__body"><form class="advisor-request-form" data-request-form><label class="form-field"><span class="form-field__label">投资金额(万元)</span><input class="form-field__input" data-request-amount type="number" min="1" step="1" value="500" required></label><label class="form-field"><span class="form-field__label">投资期限</span><select class="form-field__input" data-request-horizon><option>&lt;1年</option><option>1-3年</option><option selected>3-5年</option><option>&gt;5年</option></select></label><label class="form-field"><span class="form-field__label">风险偏好</span><select class="form-field__input" data-request-risk><option>稳健</option><option selected>平衡</option><option>进取</option></select></label><label class="form-field advisor-request-form__wide"><span class="form-field__label">补充说明(可选)</span><input class="form-field__input" data-request-note maxlength="200" placeholder="例如:希望偏债、不想承受太大波动"></label><button class="button button--primary" type="submit" data-request-submit>提交申报</button><p class="form-alert" data-request-alert></p></form></div></section><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">我的申报</h2></div><div class="panel__body" data-request-list></div></section><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">已收到的方案</h2></div><div class="panel__body" data-list-content></div></section></main><script type="module" src="/static/portal/customer/advisor-plans/advisor-plans.js?v=20260916-plan3"></script></body></html>
@@ -13,6 +13,12 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5'; import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
// 推荐方案的可视化卡片(走势图 / 指标 / 组合构成)。与客户「我的投顾方案」页**共用**,
// 避免两处各写一套后漂移。取数用的是本模块的 apiClient(依赖注入,模块内不 import)。
import {
hydratePlanView,
renderPlanProducts,
} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3';
import { import {
ACTION_DESCRIPTIONS, ACTION_DESCRIPTIONS,
ACTION_ENDPOINTS, ACTION_ENDPOINTS,
@@ -256,15 +262,9 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
'info', 'info',
); );
} else { } else {
html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => { // 可视化卡片:每只基金一张卡(走势图 + 净值/区间涨跌/基金经理/费率/评分 + 推荐依据),
const evidence = product.recommendation_evidence_card || {}; // 外加组合构成条。行情与净值是**异步**补进去的(见 show() 里的 hydratePlanView)。
return [ html += renderPlanProducts(products);
`<span class="data-table__primary">${escapeHtml(product.product_name)}</span><span class="data-table__secondary">${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}</span>`,
escapeHtml((evidence.suitability || {}).risk_level || '--'),
escapeHtml(String(product.score ?? '--')),
escapeHtml(product.reason || '--'),
];
}));
} }
(data.disclosures || []).forEach((text) => { html += disclaimer(text); }); (data.disclosures || []).forEach((text) => { html += disclaimer(text); });
return html; return html;
@@ -399,6 +399,8 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
animate(blockedIndex, () => { animate(blockedIndex, () => {
output.innerHTML = markupFor(action, data, source); output.innerHTML = markupFor(action, data, source);
bindRecommendReviewActions(); bindRecommendReviewActions();
// 结果里若有推荐卡片骨架,异步补走势图、组合曲线与指标(失败只降级为"暂无数据")。
void hydratePlanView(output, apiClient, (data && data.products) || []);
}); });
} }
@@ -3,7 +3,7 @@
* 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层) * 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层)
* → 本文件(只写投顾页自己的东西)。 * → 本文件(只写投顾页自己的东西)。
* *
* 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。 * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 历史方案记录)**。
* 约定: * 约定:
* · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距; * · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距;
* · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式; * · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式;
@@ -169,12 +169,25 @@
.advisor-inline-form .form-field { flex: 1 1 180px; } .advisor-inline-form .form-field { flex: 1 1 180px; }
.advisor-output .form-alert { margin: var(--space-3) 0; } .advisor-output .form-alert { margin: var(--space-3) 0; }
/* ---- 已发布交付物 ---- */ /* ---- 历史方案记录(卡片形态与结果区一致) ---- */
.advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); } .advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); }
.advisor-card:last-child { border-bottom: 0; } .advisor-card:last-child { border-bottom: 0; }
.advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; } .advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; }
.advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } .advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
.advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; } .advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
/* 标题里的状态标签:`operations.css` 只给了 `.status-tag--*` 的配色,这里补布局。 */
.advisor-card__title .status-tag { margin-left: var(--space-2); padding: 1px var(--space-2); border-radius: var(--radius-sm); font-size: var(--fs-small); font-weight: 600; vertical-align: middle; }
/* 卡片可点开详情:给出可点击的视觉与键盘焦点反馈。 */
.advisor-card--clickable { cursor: pointer; transition: background 160ms ease; }
.advisor-card--clickable:hover { background: var(--surface-soft); }
.advisor-card--clickable:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.advisor-card__hint { margin: var(--space-2) 0 0; color: var(--brand-dark); font-size: var(--fs-small); font-weight: 600; }
/* 四个操作按钮:换行排列,状态不适用时禁用(口径与后端状态机一致)。 */
.advisor-card__actions { margin-top: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); }
.advisor-card__actions .button[disabled] { opacity: .5; cursor: not-allowed; }
/* 详情弹窗:表格沿用结果区的边框样式(`.advisor-output .data-table-wrap` 只作用于结果区)。 */
.advisor-history-dialog .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
/* ---- 执行日志 ---- */ /* ---- 执行日志 ---- */
.log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; } .log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; }
@@ -0,0 +1,262 @@
// 历史方案记录(列表 + 详情弹窗 + 四个操作按钮)。
//
// 与已移除的「已发布交付物」面板不同:这里列**全部状态**的方案与方案书
// (含 `pending_review` / `pending` 待审、`rejected` 已驳回),用于回看
// 「以前生成过什么」。数据口径见后端 `GET /api/v1/advisor/recommendations/history`。
//
// 推荐方案(`advisor_recommendation_plan`)卡片带四个操作:审核通过 / 驳回 /
// 发送给客户 / 删除 —— 对应 ADVISOR_REVIEW_RECOMMENDATION、ADVISOR_PUBLISH_RECOMMENDATION
// 与 ADVISOR_DELETE_RECOMMENDATION 三个端点。**投资方案书不挂这四个按钮**:
// 它按 `goal_no` 走自己的确认/审核/发布链路,且被 `advisor_investment_goal` 外键引用,删不掉。
//
// 刷新时机:本模块自己操作后会 `load()`;此外 `actions-module` 在结果区审核/发布成功后
// 会派发 `advisor:published-refresh`,这里一并监听(事件名沿用旧约定)。
import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
import { showToast } from '/static/portal/common/notifications.js';
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913';
import { BOOK_STATUS_LABELS, CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor9';
//: 只有推荐方案有下面这四个动作。
const PLAN_CONTENT_TYPE = 'advisor_recommendation_plan';
//: 动作 → 按钮文案(顺序即展示顺序)。
const PLAN_ACTIONS = Object.freeze([
['approve', '审核通过'],
['reject', '驳回'],
['publish', '发送给客户'],
['delete', '删除'],
]);
//: 两套状态表之外的补充:推荐方案的「待审核」态(`generate` 置 `pending_review`),
//: 以及方案书下架后置的 `draft`(既非 `pending` 也非 `approved`,故 `BOOK_STATUS_LABELS`
//: 里没有)。其余状态两套共用 `BOOK_STATUS_LABELS`。
const EXTRA_STATUS_LABELS = Object.freeze({
pending_review: '待审核',
draft: '草稿',
});
//: 状态 → 标签色(对齐 common/operations.css 的 `.status-tag--*` 修饰类)。
const STATUS_TONES = Object.freeze({
pending: 'medium',
pending_review: 'medium',
draft: 'medium',
approved: 'low',
published: 'active',
rejected: 'failed',
});
//: 动作禁用口径 —— 与后端状态机一致(`ProductRecommendationService.review/publish`):
//: 审核只对 `pending_review`;发布只对已审核通过且**尚未发布**的。
function actionDisabled(action, row) {
if (action === 'approve' || action === 'reject') return row.review_status !== 'pending_review';
if (action === 'publish') return row.review_status !== 'approved' || Boolean(row.published_at);
return false;
}
function statusLabel(status) {
return EXTRA_STATUS_LABELS[status] || BOOK_STATUS_LABELS[status] || String(status || '--');
}
function typeLabel(type) {
return CONTENT_TYPE_LABELS[type] || '交付物';
}
function actionButtons(row) {
return '<div class="advisor-card__actions">'
+ PLAN_ACTIONS.map(([action, label]) => {
const disabled = actionDisabled(action, row) ? ' disabled' : '';
return `<button class="button table-action" type="button" data-history-action="${action}"${disabled}>`
+ `${escapeHtml(label)}</button>`;
}).join('')
+ '</div>';
}
//: 表格标记与 `actions-module.js` 的 `table()` 一致(同一页两套写法会漂移)。
function table(headers, rows) {
if (!rows.length) return '';
return '<div class="data-table-wrap"><table class="data-table"><thead><tr>'
+ headers.map((head) => `<th>${escapeHtml(head)}</th>`).join('')
+ '</tr></thead><tbody>'
+ rows.map((cells) => `<tr>${cells.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')
+ '</tbody></table></div>';
}
// ---- 详情 ----
function detailFacts(row) {
const facts = [
['方案编号', `#${row.content_id}`],
['类型', typeLabel(row.content_type)],
['状态', statusLabel(row.review_status)],
['客户', `客户 ${row.customer_id || '--'}`],
['生成时间', formatDateTime(row.created_at)],
];
if (row.reviewed_at) facts.push(['审核时间', formatDateTime(row.reviewed_at)]);
if (row.published_at) facts.push(['发布时间', formatDateTime(row.published_at)]);
return `<div class="detail-grid">${facts
.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`)
.join('')}</div>`;
}
function renderDetail(row) {
const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
const products = Array.isArray(plan.products) ? plan.products : [];
let html = detailFacts(row);
if (products.length) {
html += '<h3 class="advisor-output__subtitle">推荐产品</h3>'
+ table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
const evidence = product.recommendation_evidence_card || {};
return [
`<span class="data-table__primary">${escapeHtml(product.product_name || '--')}</span>`
+ `<span class="data-table__secondary">${escapeHtml(product.product_code || '')}`
+ ` · ${escapeHtml(product.product_category || '')}</span>`,
escapeHtml((evidence.suitability || {}).risk_level || '--'),
escapeHtml(String(product.score ?? '--')),
escapeHtml(product.reason || '--'),
];
}));
const summary = plan.selection_summary;
if (summary) {
html += '<p class="advisor-disclaimer">'
+ `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
+ `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
+ `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。</p>`;
}
} else {
// 方案书等非推荐类交付物:结构不固定,原样呈现,避免臆造字段。
html += '<h3 class="advisor-output__subtitle">方案内容</h3>'
+ `<div class="advisor-card__content">${escapeHtml(JSON.stringify(plan, null, 2))}</div>`;
}
if (plan.review_comment) {
html += '<h3 class="advisor-output__subtitle">审核意见</h3>'
+ `<p class="advisor-output__message">${escapeHtml(String(plan.review_comment))}</p>`;
}
(Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
html += `<p class="advisor-disclaimer">${escapeHtml(String(text))}</p>`;
});
return html;
}
export function createHistoryModule({ list, dialog }) {
let currentRows = [];
let busy = false;
function rowAt(node) {
const card = node.closest('[data-history-index]');
if (!card) return null;
return currentRows[Number(card.dataset.historyIndex)] || null;
}
function openDetail(row) {
if (!dialog || !row) return;
const titleNode = dialog.querySelector('[data-history-dialog-title]');
const bodyNode = dialog.querySelector('[data-history-dialog-body]');
if (titleNode) titleNode.textContent = `${typeLabel(row.content_type)} · 编号 ${row.content_id}`;
if (bodyNode) bodyNode.innerHTML = renderDetail(row);
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
async function runAction(action, row) {
if (busy || !row) return;
const contentId = row.content_id;
if (action === 'delete' && !window.confirm(`确认删除方案 #${contentId}?删除后不可恢复。`)) return;
busy = true;
list.setAttribute('aria-busy', 'true');
try {
if (action === 'approve' || action === 'reject') {
await apiClient.post(
'ADVISOR_REVIEW_RECOMMENDATION',
{ decision: action === 'approve' ? 'approved' : 'rejected', comment: '' },
{ pathParams: { contentId } },
);
showToast(action === 'approve' ? `方案 #${contentId} 已审核通过` : `方案 #${contentId} 已驳回`);
} else if (action === 'publish') {
await apiClient.post('ADVISOR_PUBLISH_RECOMMENDATION', undefined, { pathParams: { contentId } });
showToast(`方案 #${contentId} 已发送给客户`);
} else if (action === 'delete') {
await apiClient.del('ADVISOR_DELETE_RECOMMENDATION', { pathParams: { contentId } });
showToast(`方案 #${contentId} 已删除`);
}
await load();
} catch (error) {
apiClient.reportError(error);
showToast(error.message || '操作未完成', 'error');
} finally {
busy = false;
list.removeAttribute('aria-busy');
}
}
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-history-action]');
if (button) {
const row = rowAt(button);
if (row) runAction(button.dataset.historyAction, row);
return;
}
const row = rowAt(event.target);
if (row) openDetail(row);
});
list.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
// 按钮自身会派发 click,别再当成「卡片激活」重复处理。
if (event.target.closest('[data-history-action]')) return;
const row = rowAt(event.target);
if (!row) return;
event.preventDefault();
openDetail(row);
});
if (dialog) {
dialog.querySelector('[data-history-dialog-close]')?.addEventListener('click', () => dialog.close());
// 点遮罩(事件目标就是 dialog 本身)关闭。
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
}
async function load() {
renderLoading(list, 3);
try {
const response = await apiClient.get('ADVISOR_HISTORY');
const rows = Array.isArray(response.data) ? response.data : [];
currentRows = rows;
if (!rows.length) {
renderEmpty(list, '暂无历史方案', '当前账号名下的客户还没有生成过方案或方案书;生成并保存一份推荐方案后,这里会留档。');
return;
}
list.innerHTML = rows.map((row, index) => {
const tone = STATUS_TONES[row.review_status] || 'active';
const products = Array.isArray(row.plan?.products) ? row.plan.products : [];
const names = products.map((item) => item.product_name || item.product_code || '--').slice(0, 5);
return `<article class="advisor-card advisor-card--clickable" data-history-index="${index}"`
+ ' tabindex="0" role="button">'
+ `<h3 class="advisor-card__title">${escapeHtml(typeLabel(row.content_type))}`
+ ` · 编号 ${escapeHtml(row.content_id)}`
+ ` <span class="status-tag status-tag--${tone}">${escapeHtml(statusLabel(row.review_status))}</span></h3>`
+ `<p class="advisor-card__meta">客户 ${escapeHtml(row.customer_id || '--')}`
+ ` · 生成于 ${escapeHtml(formatDateTime(row.created_at))}`
+ ` · 最近更新 ${escapeHtml(formatDateTime(row.published_at || row.reviewed_at || row.created_at))}</p>`
+ (names.length ? `<p class="advisor-card__meta">产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}</p>` : '')
+ (row.content_type === PLAN_CONTENT_TYPE ? actionButtons(row) : '')
+ '<p class="advisor-card__hint">点击卡片查看详情 ›</p>'
+ '</article>';
}).join('');
} catch (error) {
currentRows = [];
apiClient.reportError(error);
renderError(list, error, load);
}
}
// 演示模式(未登录)没有后端可查,给一句说明而不是空白。
function showPlaceholder() {
renderEmpty(list, '历史方案需先登录', '当前为本地演示模式,未连接后端,因此没有历史记录可回放。');
}
window.addEventListener('advisor:published-refresh', load);
return Object.freeze({ load, showPlaceholder });
}
+43 -3
View File
@@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None:
"/portal/customer/orders/", "/portal/customer/orders/",
"/portal/customer/transactions/", "/portal/customer/transactions/",
"/portal/customer/cash-ledger/", "/portal/customer/cash-ledger/",
"/portal/customer/advisor-plans/",
"/portal/customer/risk-questionnaire/", "/portal/customer/risk-questionnaire/",
"/portal/employee-console/login/", "/portal/employee-console/login/",
"/portal/employee-console/workspace/", "/portal/employee-console/workspace/",
@@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
encoding="utf-8" encoding="utf-8"
) )
for endpoint_id in ( for endpoint_id in (
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS", "ADVISOR_PUBLISHED", "ADVISOR_HISTORY", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL", "ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
"ADVISOR_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION",
"ADVISOR_DELETE_RECOMMENDATION",
): ):
assert f"{endpoint_id}:" in source assert f"{endpoint_id}:" in source
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"): for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"):
assert label in dashboard assert label in dashboard
def test_advisor_plan_view_is_shared_by_both_surfaces() -> None:
"""推荐方案的可视化渲染必须**只有一份**,投顾工作台与客户页共用。
两处都是同一份 `advisor_recommendation_plan`,各写一套必然漂移
(改了一边忘了另一边)。这条测试同时守住「共享模块存在」与「两处都在用它」。
"""
view = (PORTAL / "common" / "advisor-plan-view.js").read_text(encoding="utf-8")
assert "export function renderPlanProducts" in view
assert "export async function hydratePlanView" in view
assert "P002" in view
for page in (
"employee-advisor/dashboard/actions-module.js",
"customer/advisor-plans/advisor-plans.js",
):
source = (PORTAL / page).read_text(encoding="utf-8")
assert "advisor-plan-view.js" in source, page
def test_advisor_history_module_exposes_plan_actions() -> None:
"""历史方案记录里,推荐方案卡片要带审核/驳回/发送/删除四个操作。
四个动作对应三个端点(审核与驳回共用一个 reviews 端点,用 `decision` 区分)。
少了 `ADVISOR_DELETE_RECOMMENDATION` 就只剩"能看不能删"。
"""
source = (
PORTAL / "employee-advisor" / "dashboard" / "history-module.js"
).read_text(encoding="utf-8")
for label in ("审核通过", "驳回", "发送给客户", "删除"):
assert label in source
for endpoint in (
"ADVISOR_REVIEW_RECOMMENDATION",
"ADVISOR_PUBLISH_RECOMMENDATION",
"ADVISOR_DELETE_RECOMMENDATION",
):
assert endpoint in source
def test_advisor_dashboard_is_composed_from_feature_modules() -> None: def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text( source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert "./actions-module.js" in source assert "./actions-module.js" in source
assert "./published-module.js" in source assert "./history-module.js" in source
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text( config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
encoding="utf-8" encoding="utf-8"
) )
@@ -0,0 +1,153 @@
"""推荐依据 LLM 增强的守卫测试。
重点不是"能不能调通模型",而是**三条合规边界**:
1. 收益承诺类表述必须被丢弃;
2. 未启用 / 缺密钥 / 调用失败一律回退(返回空字典),不得影响推荐主流程;
3. 只接受自己请求过的 product_code(模型返回别的代码不得采纳)。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.service import advisor_reason_service as module
from app.service.advisor_reason_service import (
PROHIBITED_PHRASES,
AdvisorReasonService,
build_prompt,
is_compliant,
parse_items,
)
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self):
return self._payload
class _FakeClient:
def __init__(self, response=None, error=None):
self.response = response
self.error = error
self.calls = 0
async def post(self, *args, **kwargs):
self.calls += 1
if self.error is not None:
raise self.error
return self.response
def _settings(**overrides):
values = {
"advisor_reason_llm_enabled": True,
"advisor_reason_llm_base_url": "https://example.invalid",
"advisor_reason_llm_model": "deepseek-chat",
"advisor_reason_llm_timeout_seconds": 5.0,
"deepseek_api_key": "sk-test",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_is_compliant_rejects_every_prohibited_phrase() -> None:
for phrase in PROHIBITED_PHRASES:
text = f"该产品风险等级 R3,与您的风险承受能力匹配,{phrase},可长期持有。"
assert not is_compliant(text), phrase
def test_is_compliant_rejects_out_of_range_length() -> None:
assert not is_compliant("太短")
assert not is_compliant("风" * 200)
def test_is_compliant_accepts_grounded_text() -> None:
text = (
"该产品为 R3 中等风险,与您的风险承受能力匹配;近 60 个交易日最大回撤 -8.71%,"
"建议作为组合的一部分配置。"
)
assert is_compliant(text)
def test_parse_items_tolerates_code_fence_and_noise() -> None:
raw = '结果如下:\n```json\n{"items":[{"product_code":"159329","reason":"x"}]}\n```'
assert parse_items(raw) == {"159329": "x"}
def test_parse_items_returns_empty_on_bad_shape() -> None:
assert parse_items("not json at all") == {}
assert parse_items('{"items": "oops"}') == {}
def test_build_prompt_carries_real_numbers() -> None:
prompt = build_prompt(
{"risk_level": 4, "horizon_months": 60, "liquidity": "30 日后可使用"},
[{
"product_code": "159329", "product_name": "沙特ETF南方",
"product_category": "ETF", "risk_level": "R5", "score": 0.96,
"return_20d_pct": -0.7126, "return_60d_pct": -1.8213,
"max_drawdown_60d_pct": -4.9219, "rule_reason": "规则文案",
}],
)
assert "-0.71%" in prompt
assert "-4.92%" in prompt
assert "30 日后可使用" in prompt
@pytest.mark.asyncio
async def test_enhance_drops_non_compliant_items(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(module, "get_settings", lambda: _settings())
client = _FakeClient(_FakeResponse({"choices": [{"message": {"content": (
'{"items":['
'{"product_code":"159329","reason":"该产品风险等级 R5,与您的风险承受能力匹配,'
'建议作为组合分散配置的一部分。"},'
'{"product_code":"159382","reason":"这只保证收益,稳赚,放心买。"},'
'{"product_code":"999999","reason":"该产品风险等级 R3,与您的风险承受能力匹配,'
'建议作为组合的一部分配置。"}'
']}'
)}}]}))
accepted = await AdvisorReasonService(client=client).enhance(
customer={}, products=[{"product_code": "159329"}, {"product_code": "159382"}]
)
# 命中收益承诺的 159382 被丢弃;未请求过的 999999 也不采纳。
assert set(accepted) == {"159329"}
@pytest.mark.asyncio
async def test_enhance_returns_empty_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
module, "get_settings", lambda: _settings(advisor_reason_llm_enabled=False)
)
client = _FakeClient(_FakeResponse({"choices": []}))
result = await AdvisorReasonService(client=client).enhance(
customer={}, products=[{"product_code": "159329"}]
)
assert result == {}
assert client.calls == 0 # 未启用时**根本不发请求**
@pytest.mark.asyncio
async def test_enhance_returns_empty_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(module, "get_settings", lambda: _settings(deepseek_api_key=""))
client = _FakeClient(_FakeResponse({"choices": []}))
assert await AdvisorReasonService(client=client).enhance(
customer={}, products=[{"product_code": "159329"}]
) == {}
assert client.calls == 0
@pytest.mark.asyncio
async def test_enhance_returns_empty_on_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(module, "get_settings", lambda: _settings())
client = _FakeClient(error=RuntimeError("boom"))
assert await AdvisorReasonService(client=client).enhance(
customer={}, products=[{"product_code": "159329"}]
) == {}
+85 -36
View File
@@ -1,66 +1,115 @@
@echo off @echo off
chcp 936 >nul chcp 936 >nul
setlocal setlocal EnableDelayedExpansion
title 金融 Agent 平台 · 后端服务(API) title ???? Agent ?? ?? ??????(API)
rem ============================================================ rem ============================================================
rem 双击本文件启动后端 API(FastAPI + uvicorn,127.0.0.1:8000)。 rem ?????????????? API??FastAPI + uvicorn??127.0.0.1:8000????
rem 只起接口与前端页面;Agent Worker(客服对话/知识向量同步/ rem ????????????棻Agent Worker????????/?????????/
rem 风控扫描)不含在内,需要时请另外启动。 rem ?????裩????????????????????????
rem PROJ 自动取本文件所在目录(项目根),仓库复制到哪都能跑。 rem PROJ ????????????????????????????????????????
rem 可选参数:启动后端.bat 8100 指定端口(默认 8000) rem ???????????????.bat 8100 ?????????? 8000??
rem
rem ??????????? start.ps1 ??????£???
rem 1) ????????? .venv
rem 2) ???? conda ???? jr_py313
rem 3) py -3.13 ?????
rem 4) PATH ??? python????? 3.11+??
rem ?????? import fastapi/sqlalchemy/asyncmy/pydantic?????????
rem ????????汾????????????????????
rem ============================================================ rem ============================================================
set "PROJ=%~dp0" set "PROJ=%~dp0"
set "HOST=127.0.0.1" set "HOST=127.0.0.1"
set "PORT=%~1" set "PORT=%~1"
if "%PORT%"=="" set "PORT=8000" if "%PORT%"=="" set "PORT=8000"
set "PY=%PROJ%.venv\Scripts\python.exe" set "PY="
echo ============================================================ rem ---------- 1) ????????? / conda ??????????? exe ·????----------
echo 金融 Agent 平台 · 后端服务 for %%C in (
echo ============================================================ "%PROJ%.venv\Scripts\python.exe"
echo. "D:\conda\envs\jr_py313\python.exe"
"%USERPROFILE%\miniconda3\envs\jr_py313\python.exe"
"%USERPROFILE%\anaconda3\envs\jr_py313\python.exe"
) do (
if not defined PY (
if exist %%C (
%%C -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
if !errorlevel!==0 set "PY=%%~C"
)
)
)
if not exist "%PY%" ( rem ---------- 2) py -3.13 ??????????????? exe ·????----------
echo [错误] 找不到虚拟环境解释器: if not defined PY (
echo %PY% for /f "delims=" %%X in ('py -3.13 -c "import sys;print(sys.executable)" 2^>nul') do (
echo. set "PY=%%X"
echo 请先在项目根执行一次环境安装: )
echo py -3.13 -m venv .venv if defined PY (
echo .venv\Scripts\python.exe -m pip install -e . "!PY!" -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
echo. if not !errorlevel!==0 set "PY="
pause )
exit /b 1 )
rem ---------- 3) PATH ??? python?????????? exe ·????У?? >=3.11??----------
if not defined PY (
for /f "delims=" %%X in ('python -c "import sys;print(sys.executable)" 2^>nul') do (
set "PY=%%X"
)
if defined PY (
"!PY!" -c "import sys;v=sys.version_info;assert (v[0],v[1])>=(3,11);import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
if not !errorlevel!==0 set "PY="
)
)
if not defined PY (
echo [????] ??????????б?????? Python ??????
echo ??? 3.11+ ????? fastapi / sqlalchemy / asyncmy / pydantic??
echo.
echo ??????????????????????????У???
echo py -3.13 -m venv .venv
echo .venv\Scripts\python.exe -m pip install -e .
echo.
echo ??????? conda ???? jr_py313???????????????λ??????
echo D:\conda\envs\jr_py313\python.exe
echo %USERPROFILE%\miniconda3\envs\jr_py313\python.exe
echo %USERPROFILE%\anaconda3\envs\jr_py313\python.exe
echo.
pause
exit /b 1
) )
if not exist "%PROJ%app\main.py" ( if not exist "%PROJ%app\main.py" (
echo [错误] 未找到 app\main.py,本 .bat 必须放在项目根目录。 echo [????] δ??? app\main.py???? .bat ????????????????
echo 当前 PROJ = %PROJ% echo ??? PROJ = %PROJ%
echo. echo.
pause pause
exit /b 1 exit /b 1
) )
cd /d "%PROJ%" cd /d "%PROJ%"
echo 项目目录 : %PROJ% echo ============================================================
echo 解释器 : %PY% echo ???? Agent ?? ?? ??????
echo 监听地址 : http://%HOST%:%PORT% echo ============================================================
echo 前端入口 : http://%HOST%:%PORT%/portal/
echo 健康检查 : http://%HOST%:%PORT%/health
echo. echo.
echo 提示:请确认 MySQL(127.0.0.1:3306) 已启动。 echo ????? : %PROJ%
echo 按 Ctrl+C 可停止服务。 echo ?????? : !PY!
echo ??????? : http://%HOST%:%PORT%
echo ?????? : http://%HOST%:%PORT%/portal/
echo ??????? : http://%HOST%:%PORT%/health
echo.
echo ?????????? MySQL(127.0.0.1:3306) ???????
echo ?? Ctrl+C ????????
echo ============================================================ echo ============================================================
echo. echo.
"%PY%" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info "!PY!" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info
set "RC=%ERRORLEVEL%" set "RC=%ERRORLEVEL%"
echo. echo.
echo ============================================================ echo ============================================================
echo 服务已退出(退出码 %RC%)。 echo ???????????????? %RC%????
echo ============================================================ echo ============================================================
pause pause
endlocal endlocal