diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py
index 72ceb26..b4aa03b 100644
--- a/app/api/controllers/recommendations.py
+++ b/app/api/controllers/recommendations.py
@@ -21,6 +21,15 @@ admin_router = APIRouter(
tags=["platform-admin"],
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")
@@ -41,6 +50,19 @@ async def published_recommendations(
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 新增)------------------------------------
#
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
@@ -76,6 +98,20 @@ async def advisor_publish_recommendation(
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(
"/advisor/pending-contents",
dependencies=[Depends(enforce_advisor_rollout)],
@@ -123,3 +159,16 @@ async def publish_recommendation(
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
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)
diff --git a/app/core/config.py b/app/core/config.py
index 21680b7..4211ced 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -102,6 +102,18 @@ class Settings(BaseSettings):
offsite_deepseek_api_key: str = ""
offsite_deepseek_model: str = "deepseek-v4-flash"
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_dry_run: bool = True
offsite_smtp_host: str = ""
diff --git a/app/main.py b/app/main.py
index 275d747..6fe97f4 100644
--- a/app/main.py
+++ b/app/main.py
@@ -8,6 +8,12 @@ from fastapi.staticfiles import StaticFiles
from app.api.controllers.admin import router as admin_router
from app.api.controllers.agent_runs import router as agent_runs_router
+from app.api.controllers.advisor_service_requests import (
+ advisor_router as advisor_service_request_router,
+)
+from app.api.controllers.advisor_service_requests import (
+ client_router as advisor_service_request_client_router,
+)
from app.api.controllers.asset_allocation import router as asset_allocation_router
from app.api.controllers.auth import router as auth_router
from app.api.controllers.conversations import router as conversations_router
@@ -28,6 +34,9 @@ from app.api.controllers.recommendations import (
from app.api.controllers.recommendations import (
advisor_router as recommendation_advisor_router,
)
+from app.api.controllers.recommendations import (
+ client_router as recommendation_client_router,
+)
from app.api.controllers.risk import router as risk_router
from app.api.controllers.trading import router as trading_router
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
@@ -136,6 +145,9 @@ def create_app() -> FastAPI:
application.include_router(asset_allocation_router)
application.include_router(recommendation_advisor_router)
application.include_router(recommendation_admin_router)
+ application.include_router(recommendation_client_router)
+ application.include_router(advisor_service_request_router)
+ application.include_router(advisor_service_request_client_router)
application.include_router(admin_router)
application.include_router(trading_router)
static_directory = Path(__file__).resolve().parent / "static"
diff --git a/app/service/advisor_reason_service.py b/app/service/advisor_reason_service.py
new file mode 100644
index 0000000..5970691
--- /dev/null
+++ b/app/service/advisor_reason_service.py
@@ -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
diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py
index 2b3ce83..f8ecb10 100644
--- a/app/service/product_recommendation_service.py
+++ b/app/service/product_recommendation_service.py
@@ -1,5 +1,6 @@
"""Constraint-first recommendations for the exchange-traded simulation domain."""
+import logging
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
@@ -8,19 +9,28 @@ from sqlalchemy import select
from app.core.config import get_settings
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.infrastructure.db import SessionFactory
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
from app.model.audit import InteractionAudit
+from app.model.fund import FundNavHistory
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
from app.repository.advisor_product_repository import (
AdvisorProductRepository,
AuthoritativeProductCandidate,
)
+from app.service.advisor_reason_service import AdvisorReasonService
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
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.profile_governance_service import ProfileGovernanceService
from app.service.relationship_service import RelationshipService
@@ -31,6 +41,8 @@ from app.service.suitability_service import SuitabilityService
#: 推荐方案用 `content_id`,方案书用 `goal_no`。
BOOK_CONTENT_TYPE = "investment_goal_book"
+logger = logging.getLogger(__name__)
+
class ProductRecommendationService:
CONTENT_TYPE = "advisor_recommendation_plan"
@@ -95,6 +107,12 @@ class ProductRecommendationService:
self._view(item, index, goal, graph_context)
for index, item in enumerate(selected, start=1)
]
+ # 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。
+ # 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。
+ # 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。
+ products = await self._enhance_reasons(
+ products, selected, goal, authority.customer_risk_level
+ )
plan = {
"document_type": "advisor_recommendation_plan",
"document_version": "1.0",
@@ -228,13 +246,24 @@ class ProductRecommendationService:
candidate, score = item
product = candidate.product
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 {
"rank": rank,
"product_code": product.product_code,
"product_name": product.product_name,
"product_category": product.product_category,
- "reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
- "并与已确认投资目标的期限和流动性要求相匹配。",
+ "reason": reason,
"score": round(score, 4),
"recommendation_evidence_card": {
"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]:
if self.relationship_service is None:
return {"degraded": True, "reason": "graph_not_configured"}
@@ -349,6 +452,63 @@ class ProductRecommendationService:
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
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
"""可查看的客户 id:本人 + 名下归属客户。
@@ -395,6 +555,83 @@ class ProductRecommendationService:
"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]:
"""管理面复核队列:待审核的推荐方案与投资方案书。
@@ -468,3 +705,26 @@ async def product_recommendation_tool(
return await ProductRecommendationService(enforce_profile_governance=True).generate(
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
diff --git a/app/static/portal/common/advisor-plan-view.js b/app/static/portal/common/advisor-plan-view.js
new file mode 100644
index 0000000..bd44aca
--- /dev/null
+++ b/app/static/portal/common/advisor-plan-view.js
@@ -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 `
${escapeHtml(label)}`
+ + `${escapeHtml(value)}`;
+}
+
+// ---- 折线图(带坐标轴/网格;内联 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 ``
+ + ``
+ + `${escapeHtml(formatter(value))}`;
+ }).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 ``
+ + `${escapeHtml(shortDate(series[index].date))}`;
+ }).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 ``;
+}
+
+/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
+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 = ``;
+ offset += length;
+ return slice;
+ }).join('');
+ return `';
+}
+
+// ---- 方案骨架 ----
+
+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 ''
+ + ''
+ + `'
+ + ``
+ + metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--'))
+ + '
'
+ + ``
+ // 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。
+ + '推荐依据'
+ + (product.reason_source === 'llm'
+ ? 'AI 生成'
+ : '规则生成')
+ + `${escapeHtml(product.reason || '--')}
`
+ + '';
+}
+
+/** 同步骨架:只用方案自带字段,行情/净值随后由 `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 ``
+ + `${escapeHtml(entry.label)}`
+ + `${escapeHtml(entry.code)}`
+ + `${share}%`;
+ }).join('');
+ return ``
+ + '
'
+ + '组合业绩(等权合成)
'
+ + ''
+ + ''
+ + ''
+ + '
'
+ + '资产配置比例(等权)
'
+ + ''
+ + `
${donutSvg(entries)}
`
+ + `
`
+ + '
'
+ + `
${products.map(cardHtml).join('')}
`
+ + '
';
+}
+
+// ---- 注解式补水 ----
+
+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} 个交易日净值走势`,
+ })
+ : '暂无净值数据'
+ + '(可运行 tools/sync_nav_history.py 同步)
';
+ }
+
+ 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) => `${escapeHtml(text)}`).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 = '成分基金净值不足,'
+ + '暂无法合成组合走势。
';
+ 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] || []);
+ });
+}
diff --git a/app/static/portal/common/advisor-plan.css b/app/static/portal/common/advisor-plan.css
new file mode 100644
index 0000000..cc3304e
--- /dev/null
+++ b/app/static/portal/common/advisor-plan.css
@@ -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); }
diff --git a/app/static/portal/common/customer-list-page.js b/app/static/portal/common/customer-list-page.js
index 9b7c4a5..8cf2b84 100644
--- a/app/static/portal/common/customer-list-page.js
+++ b/app/static/portal/common/customer-list-page.js
@@ -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 { mountShell } from '/static/portal/common/layout/app-shell.js';
import { renderError, renderLoading } from '/static/portal/common/state-view.js';
diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js
index 3d3fb98..6d38203 100644
--- a/app/static/portal/common/layout/app-shell.js
+++ b/app/static/portal/common/layout/app-shell.js
@@ -15,6 +15,7 @@ const PUBLIC_LINKS = [
const CUSTOMER_LINKS = [
['dashboard', '资产总览', '/portal/customer/dashboard/'],
+ ['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'],
['holdings', '我的持仓', '/portal/customer/holdings/'],
['profit-loss', '收益明细', '/portal/customer/profit-loss/'],
['orders', '交易记录', '/portal/customer/orders/'],
diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.css b/app/static/portal/customer/advisor-plans/advisor-plans.css
new file mode 100644
index 0000000..de0a82c
--- /dev/null
+++ b/app/static/portal/customer/advisor-plans/advisor-plans.css
@@ -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; }
+}
diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.js b/app/static/portal/customer/advisor-plans/advisor-plans.js
new file mode 100644
index 0000000..c4d07f1
--- /dev/null
+++ b/app/static/portal/customer/advisor-plans/advisor-plans.js
@@ -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 += ''
+ + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
+ + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
+ + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。
`;
+ }
+ } else {
+ // 方案书等结构不固定的内容:原样呈现,不臆造字段。
+ html += `${escapeHtml(JSON.stringify(plan, null, 2))}`;
+ }
+
+ (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
+ html += `${escapeHtml(String(text))}
`;
+ });
+ 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 ''
+ + `${escapeHtml(typeLabel(item.content_type))}`
+ + ` 编号 ${escapeHtml(item.content_id)}
`
+ + `发布于 ${escapeHtml(formatDateTime(item.published_at))}
`
+ + (names.length
+ ? `推荐产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}
`
+ : '')
+ + ``
+ + ''
+ + '';
+}
+
+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 ''
+ + `申报单 ${escapeHtml(item.request_no)}`
+ + ` ${escapeHtml(label)}
`
+ + `${escapeHtml(String(item.amount_wan))} 万元`
+ + ` · 期限 ${escapeHtml(item.horizon || '--')}`
+ + ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}`
+ + ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}
`
+ + (item.note ? `备注:${escapeHtml(item.note)}
` : '')
+ + (item.advisor_note
+ ? `投顾意见:${escapeHtml(item.advisor_note)}
`
+ : '')
+ + '';
+}
+
+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('')
+ : '还没有提交过申报。填好上面的表单点「提交申报」即可。
';
+ } catch (error) {
+ apiClient.reportError(error);
+ listNode.innerHTML = '申报记录加载失败,请稍后刷新。
';
+ }
+ }
+
+ 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();
diff --git a/app/static/portal/customer/advisor-plans/index.html b/app/static/portal/customer/advisor-plans/index.html
new file mode 100644
index 0000000..c833aab
--- /dev/null
+++ b/app/static/portal/customer/advisor-plans/index.html
@@ -0,0 +1 @@
+我的投顾方案 · 南方财富我的投顾方案
可在下方申报投顾方案(需已完成且在有效期内的风险测评);投顾受理并发送后,方案会出现在「已收到的方案」里。
diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js
index 44bb4e1..a5bcc71 100644
--- a/app/static/portal/employee-advisor/dashboard/actions-module.js
+++ b/app/static/portal/employee-advisor/dashboard/actions-module.js
@@ -13,6 +13,12 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
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 {
ACTION_DESCRIPTIONS,
ACTION_ENDPOINTS,
@@ -256,15 +262,9 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
'info',
);
} else {
- html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
- const evidence = product.recommendation_evidence_card || {};
- return [
- `${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}`,
- escapeHtml((evidence.suitability || {}).risk_level || '--'),
- escapeHtml(String(product.score ?? '--')),
- escapeHtml(product.reason || '--'),
- ];
- }));
+ // 可视化卡片:每只基金一张卡(走势图 + 净值/区间涨跌/基金经理/费率/评分 + 推荐依据),
+ // 外加组合构成条。行情与净值是**异步**补进去的(见 show() 里的 hydratePlanView)。
+ html += renderPlanProducts(products);
}
(data.disclosures || []).forEach((text) => { html += disclaimer(text); });
return html;
@@ -399,6 +399,8 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
animate(blockedIndex, () => {
output.innerHTML = markupFor(action, data, source);
bindRecommendReviewActions();
+ // 结果里若有推荐卡片骨架,异步补走势图、组合曲线与指标(失败只降级为"暂无数据")。
+ void hydratePlanView(output, apiClient, (data && data.products) || []);
});
}
diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css
index 4926872..f5b17ab 100644
--- a/app/static/portal/employee-advisor/dashboard/dashboard.css
+++ b/app/static/portal/employee-advisor/dashboard/dashboard.css
@@ -3,7 +3,7 @@
* 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层)
* → 本文件(只写投顾页自己的东西)。
*
- * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。
+ * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 历史方案记录)**。
* 约定:
* · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距;
* · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式;
@@ -169,12 +169,25 @@
.advisor-inline-form .form-field { flex: 1 1 180px; }
.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:last-child { border-bottom: 0; }
.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__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; }
diff --git a/app/static/portal/employee-advisor/dashboard/history-module.js b/app/static/portal/employee-advisor/dashboard/history-module.js
new file mode 100644
index 0000000..d582fc2
--- /dev/null
+++ b/app/static/portal/employee-advisor/dashboard/history-module.js
@@ -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 ''
+ + PLAN_ACTIONS.map(([action, label]) => {
+ const disabled = actionDisabled(action, row) ? ' disabled' : '';
+ return ``;
+ }).join('')
+ + '
';
+}
+
+//: 表格标记与 `actions-module.js` 的 `table()` 一致(同一页两套写法会漂移)。
+function table(headers, rows) {
+ if (!rows.length) return '';
+ return ''
+ + headers.map((head) => `| ${escapeHtml(head)} | `).join('')
+ + '
'
+ + rows.map((cells) => `${cells.map((cell) => `| ${cell} | `).join('')}
`).join('')
+ + '
';
+}
+
+// ---- 详情 ----
+
+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 `${facts
+ .map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}`)
+ .join('')}
`;
+}
+
+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 += '推荐产品
'
+ + table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
+ const evidence = product.recommendation_evidence_card || {};
+ return [
+ `${escapeHtml(product.product_name || '--')}`
+ + `${escapeHtml(product.product_code || '')}`
+ + ` · ${escapeHtml(product.product_category || '')}`,
+ escapeHtml((evidence.suitability || {}).risk_level || '--'),
+ escapeHtml(String(product.score ?? '--')),
+ escapeHtml(product.reason || '--'),
+ ];
+ }));
+ const summary = plan.selection_summary;
+ if (summary) {
+ html += ''
+ + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
+ + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
+ + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。
`;
+ }
+ } else {
+ // 方案书等非推荐类交付物:结构不固定,原样呈现,避免臆造字段。
+ html += '方案内容
'
+ + `${escapeHtml(JSON.stringify(plan, null, 2))}
`;
+ }
+
+ if (plan.review_comment) {
+ html += '审核意见
'
+ + `${escapeHtml(String(plan.review_comment))}
`;
+ }
+ (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
+ html += `${escapeHtml(String(text))}
`;
+ });
+ 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 `'
+ + `${escapeHtml(typeLabel(row.content_type))}`
+ + ` · 编号 ${escapeHtml(row.content_id)}`
+ + ` ${escapeHtml(statusLabel(row.review_status))}
`
+ + `客户 ${escapeHtml(row.customer_id || '--')}`
+ + ` · 生成于 ${escapeHtml(formatDateTime(row.created_at))}`
+ + ` · 最近更新 ${escapeHtml(formatDateTime(row.published_at || row.reviewed_at || row.created_at))}
`
+ + (names.length ? `产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}
` : '')
+ + (row.content_type === PLAN_CONTENT_TYPE ? actionButtons(row) : '')
+ + '点击卡片查看详情 ›
'
+ + '';
+ }).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 });
+}
diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py
index 17d64fc..ce3503d 100644
--- a/tests/unit/api/test_portal_frontend.py
+++ b/tests/unit/api/test_portal_frontend.py
@@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None:
"/portal/customer/orders/",
"/portal/customer/transactions/",
"/portal/customer/cash-ledger/",
+ "/portal/customer/advisor-plans/",
"/portal/customer/risk-questionnaire/",
"/portal/employee-console/login/",
"/portal/employee-console/workspace/",
@@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
encoding="utf-8"
)
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_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION",
+ "ADVISOR_DELETE_RECOMMENDATION",
):
assert f"{endpoint_id}:" in source
- for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"):
+ for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"):
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:
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
encoding="utf-8"
)
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(
encoding="utf-8"
)
diff --git a/tests/unit/service/test_advisor_reason_service.py b/tests/unit/service/test_advisor_reason_service.py
new file mode 100644
index 0000000..5bbb9e5
--- /dev/null
+++ b/tests/unit/service/test_advisor_reason_service.py
@@ -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"}]
+ ) == {}
diff --git a/启动后端.bat b/启动后端.bat
index 2fdf6e5..d4aefed 100644
--- a/启动后端.bat
+++ b/启动后端.bat
@@ -1,66 +1,115 @@
@echo off
chcp 936 >nul
-setlocal
-title ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ(API)
+setlocal EnableDelayedExpansion
+title ???? Agent ?? ?? ??????(API)
rem ============================================================
-rem ˫»÷±¾ÎļþÆô¶¯ºó¶Ë API£¨FastAPI + uvicorn£¬127.0.0.1:8000£©¡£
-rem ֻÆð½ӿÚÓëǰ¶ËҳÃ棻Agent Worker£¨¿ͷþ¶Ի°/֪ʶÏòÁ¿ͬ²½/
-rem ·ç¿ØɨÃ裩²»º¬ÔÚÄڣ¬ÐèҪʱÇëÁíÍâÆô¶¯¡£
-rem PROJ ×Զ¯ȡ±¾ÎļþËùÔÚĿ¼£¨ÏîĿ¸ù£©£¬²ֿ⸴ÖƵ½ÄͼÄÜÅܡ£
-rem ¿Éѡ²ÎÊý£ºÆô¶¯ºó¶Ë.bat 8100 ָ¶¨¶˿ڣ¨ĬÈÏ 8000£©
+rem ?????????????? API??FastAPI + uvicorn??127.0.0.1:8000????
+rem ????????????—±Agent Worker????????/?????????/
+rem ?????Ñh????????????????????????
+rem PROJ ????????????????????????????????????????
+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 ============================================================
set "PROJ=%~dp0"
set "HOST=127.0.0.1"
set "PORT=%~1"
if "%PORT%"=="" set "PORT=8000"
-set "PY=%PROJ%.venv\Scripts\python.exe"
+set "PY="
-echo ============================================================
-echo ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ
-echo ============================================================
-echo.
+rem ---------- 1) ????????? / conda ??????????? exe ¡¤????----------
+for %%C in (
+ "%PROJ%.venv\Scripts\python.exe"
+ "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%" (
- echo [´íÎó] ÕҲ»µ½Ðéľ³½âÊÍÆ÷£º
- echo %PY%
- echo.
- echo ÇëÏÈÔÚÏîĿ¸ùִÐÐһ´λ·¾³°²װ£º
- echo py -3.13 -m venv .venv
- echo .venv\Scripts\python.exe -m pip install -e .
- echo.
- pause
- exit /b 1
+rem ---------- 2) py -3.13 ??????????????? exe ¡¤????----------
+if not defined PY (
+ for /f "delims=" %%X in ('py -3.13 -c "import sys;print(sys.executable)" 2^>nul') do (
+ set "PY=%%X"
+ )
+ if defined PY (
+ "!PY!" -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
+ if not !errorlevel!==0 set "PY="
+ )
+)
+
+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" (
- echo [´íÎó] δÕҵ½ app\main.py£¬±¾ .bat ±ØÐë·ÅÔÚÏîĿ¸ùĿ¼¡£
- echo µ±ǰ PROJ = %PROJ%
- echo.
- pause
- exit /b 1
+ echo [????] ¦Ä??? app\main.py???? .bat ????????????????
+ echo ??? PROJ = %PROJ%
+ echo.
+ pause
+ exit /b 1
)
cd /d "%PROJ%"
-echo ÏîĿĿ¼ : %PROJ%
-echo ½âÊÍÆ÷ : %PY%
-echo ¼àÌýµØַ : http://%HOST%:%PORT%
-echo ǰ¶ËÈë¿Ú : http://%HOST%:%PORT%/portal/
-echo ½¡¿µ¼ì²é : http://%HOST%:%PORT%/health
+echo ============================================================
+echo ???? Agent ?? ?? ??????
+echo ============================================================
echo.
-echo Ìáʾ£ºÇëȷÈÏ MySQL(127.0.0.1:3306) ÒÑÆô¶¯¡£
-echo °´ Ctrl+C ¿Éֹͣ·þÎñ¡£
+echo ????? : %PROJ%
+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.
-"%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%"
echo.
echo ============================================================
-echo ·þÎñÒÑÍ˳ö£¨Í˳öÂë %RC%£©¡£
+echo ???????????????? %RC%????
echo ============================================================
pause
endlocal