diff --git a/AGENTS.md b/AGENTS.md index 69e9c59..a223c53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,20 @@ > 启动:`python -m uvicorn app.main:app --port 8000`(注意模块级变量是 **`app`**,不是 `application`)。 > `tools/portal.py`(8101)**只是跨角色联调工具,不是产品前端**,不要再往它加功能。 > +> **🚀 一键启动与演示(2026-09-13 起)**:根目录 **`start.ps1`** +> (`powershell -ExecutionPolicy Bypass -File start.ps1`)按序做四件事: +> 找解释器 → 检查 MySQL/Redis/Milvus → **刷新行情** → 起 **API 与 Agent Worker 两个窗口**。 +> 演示数据一键准备:`python tools/seed_demo_data.py`(10 步,顺序有依赖,见脚本内表格); +> **演示流程(8 个场景照读版 + 排障表 + 账号速查)见 `docs/44-演示流程.md`**; +> 交付自检:`python tools/e2e_smoke_test.py`(6 条线 40 项,`--read-only` 不动数据)。 +> ⚠️ **`start.ps1` 必须保存为 UTF-8 with BOM**:Windows PowerShell 5.1 在缺 BOM 时按系统 +> ANSI(简中为 GBK)解析,中文注释直接抛 `Unexpected token '[璀﹀憡]'` 这类语法错误。 +> 用 `edit`/`write` 类工具改完**务必补回 BOM**(只加字节、别重写换行: +> `d=open(p,'rb').read(); open(p,'wb').write(b'\xef\xbb\xbf'+d)`)。 +> ⚠️ **行情有效期只有 15 分钟**(`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`), +> 超时后**所有委托一律 503「行情已过期」**且无自动刷新 —— 这是演示最容易翻的一环。 +> 补刷用 `python tools/sync_market_prices.py`,**立即生效、无需重启服务**。 +> > **⚠️ 文档现状(2026-09-11 第二次修订)**:本文件原先声明"已删除 5 份编号文档", > 那条**已作废** —— 经评审,`docs/04`/`06`/`10`/`13`/`99` **全部保留**(架构师明确要求保留: > 删除收益为零,而保留成本同样为零)。它们的内容**未被核对过、可能过期**, diff --git a/app/api/controllers/public_platform.py b/app/api/controllers/public_platform.py index 9463599..88992d5 100644 --- a/app/api/controllers/public_platform.py +++ b/app/api/controllers/public_platform.py @@ -9,6 +9,7 @@ from app.api.schemas.admin import StrictPayload from app.api.schemas.conversations import HandoverRequest from app.core.contracts import RequestContext from app.service.public_platform_service import PublicPlatformService +from app.service.public_product_service import PublicProductService router = APIRouter(prefix="/api/v1", tags=["public-platform"], dependencies=[Depends(enforce_rate_limit)]) @@ -118,3 +119,18 @@ async def decide_memory_candidate( return await CustomerProfileCandidateService().decide_by_customer( candidate_id, payload.decision, context ) + + +@router.get("/products") +async def list_products( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + """公开产品列表:在售场内基金 + 各自最新行情(访客令牌即可访问)。 + + 这是访客三个页面(首页推荐 / 产品列表 / 产品详情)的数据源, + 替代原先前端手写的 `common/mock-data.js`。 + + 只要求**有效令牌**、不检查权限码:访客令牌的上下文只有 `roles=("visitor",)` + 且不带权限,与 `/api/v1/conversations`、`/api/v1/agent-runs` 的访客口径一致。 + """ + return await PublicProductService().list_products(context) diff --git a/app/infrastructure/fund_market_adapter.py b/app/infrastructure/fund_market_adapter.py index 54b7020..55e9c63 100644 --- a/app/infrastructure/fund_market_adapter.py +++ b/app/infrastructure/fund_market_adapter.py @@ -4,6 +4,7 @@ """ import asyncio +import json import re from datetime import date from decimal import Decimal, InvalidOperation @@ -21,6 +22,18 @@ DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" #: `FundArchivesDatas.aspx?type=jjfl` 实测返回空(正文就一句 `var apidata=`), #: 所以这里按 HTML 去标签后匹配字段名。 FEE_API = "https://fundf10.eastmoney.com/jbgk_{code}.html" +#: 日 K 线。**这是本环境唯一可用的行情源**:`QUOTE_API`(push2 实时快照)实测 +#: `Server disconnected`(连接被拒),而 push2his 稳定返回。它给的 +#: 开/收/高/低/成交量/成交额正好是 `fin_market_price` 这张**日行情**表需要的字段。 +KLINE_API = "https://push2his.eastmoney.com/api/qt/stock/kline/get" +#: 基金规模(亿份的"亿元"口径)与净值走势所在的 JS 文件,用于推算总份额。 +PINGZHONG_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" +#: 腾讯行情。**本环境唯一可用的行情源**:东财的两个行情域名(push2 / push2his) +#: 实测一律 `Server disconnected`,而它的净值域名(api.fund)与概况域名(fundf10)正常 —— +#: 即东财只有"行情类"接口不可达。腾讯一次请求可带多只,给 +#: 今开/最高/最低/现价/成交量(手)/成交额(万元)/总市值(亿元)。 +TENCENT_QUOTE_API = "https://qt.gtimg.cn/q=" +TENCENT_HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://gu.qq.com/"} HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"} #: 费率字段 → 概况页里的标签与取值模式。值形如「0.15」(百分号已剥离), @@ -43,6 +56,12 @@ class FundMarketAdapter(Protocol): async def fetch_fees(self, codes: list[str]) -> dict[str, dict[str, Any]]: ... + async def fetch_kline( + self, codes: list[str], *, limit: int = 5 + ) -> dict[str, list[dict[str, Any]]]: ... + + async def fetch_fund_scale(self, codes: list[str]) -> dict[str, Any]: ... + class EastmoneyFundAdapter: def __init__( @@ -156,6 +175,191 @@ class EastmoneyFundAdapter: fees["full_name"] = name.group(1) if name else None return fees + @staticmethod + def _secid(code: str) -> str: + """东财的 secid:沪市前缀 `1.`、深市 `0.`(与 `hq.py` 同一口径)。""" + return ("1." if code.startswith(("5", "6", "9")) else "0.") + code + + async def fetch_kline( + self, codes: list[str], *, limit: int = 5 + ) -> dict[str, list[dict[str, Any]]]: + """取每只基金最近若干交易日的日 K 线。 + + 返回 `{code: [{trade_date, open, close, high, low, volume, turnover}, ...]}` + (按日期升序,最后一条是最新交易日)。取不到的代码**不出现**在返回里。 + + 为什么用它而不是 `fetch_quotes`:`push2` 实时快照在本环境实测连接被拒, + 而 `push2his` 的日 K 线稳定可用;且这张表本来就是**日行情**, + 开/收/高/低/量/额比"实时快照"更贴合语义。 + """ + results: dict[str, list[dict[str, Any]]] = {} + for code in codes: + # ⚠️ 这条请求**刻意走同步 httpx**(`asyncio.to_thread` 包装):实测 + # `AsyncClient` 请求 push2his 会被对端直接断连 + # (`RemoteProtocolError: Server disconnected without sending a response`), + # 而同参数、同 header 的同步客户端稳定成功 —— 排查时一度以为是解析问题。 + try: + klines = await asyncio.to_thread(self._fetch_kline_sync, code, limit) + except Exception: # noqa: BLE001 - 单只失败不影响其余 + continue + rows = [self._parse_kline(line) for line in klines] + usable = [row for row in rows if row is not None] + if usable: + results[code] = usable + return results + + @staticmethod + def _fetch_kline_sync(code: str, limit: int) -> list[Any]: + """同步版 K 线请求(为什么不用 `AsyncClient` 见 `fetch_kline` 的注释)。""" + response = httpx.get( + KLINE_API, + params={ + "secid": EastmoneyFundAdapter._secid(code), + "fields1": "f1,f2,f3,f4,f5,f6", + # f51 日期 f52 开 f53 收 f54 高 f55 低 f56 成交量 f57 成交额 + "fields2": "f51,f52,f53,f54,f55,f56,f57", + "klt": "101", "fqt": "0", "end": "20500101", "lmt": str(limit), + }, + headers=HEADERS, + timeout=20.0, + ) + response.raise_for_status() + return list((response.json().get("data") or {}).get("klines") or []) + + @staticmethod + def _parse_kline(line: Any) -> dict[str, Any] | None: + """`"2026-09-11,4.592,4.579,4.592,4.532,9666652,4409407029.000"` → dict。""" + if not isinstance(line, str): + return None + parts = line.split(",") + if len(parts) < 7: + return None + try: + trade_date = date.fromisoformat(parts[0]) + except ValueError: + return None + return { + "trade_date": trade_date, + "open": EastmoneyFundAdapter._decimal(parts[1]), + "close": EastmoneyFundAdapter._decimal(parts[2]), + "high": EastmoneyFundAdapter._decimal(parts[3]), + "low": EastmoneyFundAdapter._decimal(parts[4]), + "volume": EastmoneyFundAdapter._decimal(parts[5]), + "turnover": EastmoneyFundAdapter._decimal(parts[6]), + } + + async def fetch_fund_scale(self, codes: list[str]) -> dict[str, Any]: + """取基金规模(**亿元**)与规模所属报告期,用于推算总份额。 + + ⚠️ 规模是**季度披露值**(`Data_fluctuationScale` 的最后一期),不是实时值, + 所以推算出的总份额只当量级用 —— 它服务的是"单一投资者持仓占比不超过 X%" + 这类校验,不是对外披露数据。 + """ + results: dict[str, Any] = {} + for code in codes: + try: + response = await self._request("GET", PINGZHONG_API.format(code=code)) + except RecoverableAgentError: + continue + scale = self._parse_scale(response.text) + if scale is not None: + results[code] = scale + return results + + @staticmethod + def _parse_scale(text: str) -> dict[str, Any] | None: + """从 pingzhongdata 取最后一期规模(亿元)。""" + match = re.search(r"var\s+Data_fluctuationScale\s*=\s*(\{.*?\});", text, re.S) + if match is None: + return None + try: + payload = json.loads(match.group(1)) + except (ValueError, TypeError): + return None + series = payload.get("series") or [] + categories = payload.get("categories") or [] + if not series: + return None + last = series[-1] + value = last.get("y") if isinstance(last, dict) else None + try: + scale_yi = Decimal(str(value)) + except (InvalidOperation, TypeError): + return None + return { + "scale_yi": scale_yi, + "scale_date": categories[-1] if categories else None, + } + + @staticmethod + def _tencent_symbol(code: str) -> str: + """腾讯的代码前缀:沪市 `sh`、深市 `sz`。""" + return ("sh" if code.startswith(("5", "6", "9")) else "sz") + code + + async def fetch_tencent_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]: + """腾讯当日行情快照(一次请求可带多只)。 + + **本环境唯一可用的行情源**:东财的 `push2`/`push2his` 实测连接被拒, + 而腾讯这个源稳定返回。它给的字段正好够写一行 `fin_market_price`: + 今开 / 最高 / 最低 / 现价 / 成交量(手) / 成交额(万元) / 总市值(亿元)。 + 取不到的代码不出现在返回里。 + """ + if not codes: + return {} + symbols = ",".join(self._tencent_symbol(code) for code in codes) + try: + text = await asyncio.to_thread(self._fetch_tencent_sync, symbols) + except Exception: # noqa: BLE001 - 整批失败就返回空,由调用方按"未同步"处理 + return {} + return self._parse_tencent(text) + + @staticmethod + def _fetch_tencent_sync(symbols: str) -> str: + response = httpx.get( + TENCENT_QUOTE_API + symbols, headers=TENCENT_HEADERS, timeout=20.0 + ) + response.raise_for_status() + return response.text + + @staticmethod + def _parse_tencent(text: str) -> dict[str, dict[str, Any]]: + """解析 `v_sh510300="1~名称~代码~现价~昨收~今开~成交量~…"` 形式的返回。 + + 字段是**位置约定**(腾讯没有自描述),所以这里只取前 46 个位置里语义明确的那些, + 并对结果做合理性校验(价格必须为正),拿不到就丢弃该只而不是给出错值。 + """ + results: dict[str, dict[str, Any]] = {} + for line in text.splitlines(): + if "=" not in line or "~" not in line: + continue + _, _, body = line.partition("=") + parts = body.strip().strip('";').split("~") + if len(parts) < 46: + continue + code = parts[2].strip() + if not code: + continue + close = EastmoneyFundAdapter._decimal(parts[3]) + if close is None or close <= 0: + continue + turnover_wan = EastmoneyFundAdapter._decimal(parts[37]) + results[code] = { + "code": code, + "name": parts[1] or None, + "open": EastmoneyFundAdapter._decimal(parts[5]), + "close": close, + "high": EastmoneyFundAdapter._decimal(parts[33]), + "low": EastmoneyFundAdapter._decimal(parts[34]), + # 成交量单位是**手**(与东财 K 线口径一致,实测同为 9666652)。 + "volume": EastmoneyFundAdapter._decimal(parts[6]), + # 成交额单位是**万元**,统一换算成元。 + "turnover": None if turnover_wan is None else turnover_wan * 10000, + # 总市值(亿元),用于推算 `total_fund_shares`。 + "total_market_value_yi": EastmoneyFundAdapter._decimal(parts[45]), + "quoted_at": parts[30] or None, + } + return results + async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: client = self._client or httpx.AsyncClient(headers=HEADERS) try: diff --git a/app/service/admin_service.py b/app/service/admin_service.py index 87b725d..161d1b4 100644 --- a/app/service/admin_service.py +++ b/app/service/admin_service.py @@ -375,9 +375,19 @@ class AdminService: expire_at = row.get("expire_at") if isinstance(expire_at, datetime) and expire_at <= now: raise InvalidStateError("意图配置已过期,不能激活") + # ⚠️ 过滤条件**必须带 `intent_code`**:唯一键是生成列 + # `active_key = concat(agent_type, ':', intent_code)`,即同一 `agent_type` 下 + # **不同意图码可以同时 active**(风控的 4 个意图本就并存)。 + # 只按 `agent_type` 过滤会把同一 Agent 的**其他意图一起归档** —— + # 曾因此让风控只剩 `general` 一条 active,另外三条被静默归档, + # "查看风险概览 / 查询预警证据"这类问法再也分不到意图,且没有任何报错。 for previous in await repo.rows( "agent_intent_config", - {"agent_type": row["agent_type"], "status": "active"}, + { + "agent_type": row["agent_type"], + "intent_code": row["intent_code"], + "status": "active", + }, limit=10, ): if previous["id"] != row["id"]: diff --git a/app/service/market_price_sync_service.py b/app/service/market_price_sync_service.py new file mode 100644 index 0000000..fed91f8 --- /dev/null +++ b/app/service/market_price_sync_service.py @@ -0,0 +1,325 @@ +"""场内日行情的编排与落库(`fin_market_price`)。 + +## 为什么需要它 + +`fin_market_price` 此前**只有演示种子脚本**(`tools/seed_sim_account_demo.py`)写, +而它是**下单的硬前置**:`TradeService._fetch_quote` 要求该产品在这张表里有 +`close_price > 0`、`total_fund_shares > 0`,且 `source_updated_at` 落在 `MAX_QUOTE_AGE` 内, +否则一律 `FundQuoteUnavailableError`。缺同步链路的后果,全流程验收时实测到了: + + · 20 只产品里只有种子写过的 2 只有行情,其余下单直接报"缺少场内行情"; + · 行情一旦过期就**没有任何机制刷新**,下单随时间静默失效(当时全线 503, + 而错误信息只说"行情已过期",看不出根因)。 + +## 数据源 + +**腾讯行情**(`qt.gtimg.cn`,见 `EastmoneyFundAdapter.fetch_tencent_quotes`)—— +本环境唯一可用的行情源:东财的 push2 / push2his 两个行情域名实测一律 +`Server disconnected`,而它的净值域名与概况域名正常,即东财只有行情类接口不可达。 + +腾讯给当日快照:今开 / 最高 / 最低 / 现价 / 成交量(手) / 成交额(万元) / **总市值(亿元)**, +正好够写一行日行情,`trade_date` 由返回里的行情时间戳解析。 + +## 总份额的口径 + +`total_fund_shares` 是下单校验的必填字段(服务端还会用它算"单一投资者持仓占比"), +按优先级取: + 1. **该产品已登记的最近值**(优先沿用,避免每次同步都改动它); + 2. 腾讯总市值 ÷ 收盘价(比季度规模实时,是主要推算来源); + 3. pingzhongdata 的季度规模 ÷ 收盘价(兜底,注明是季度口径)。 +三者都拿不到就**不写这只** —— 宁可缺行情,也不编一个份额出来。 +`source` 字段标明该行是否含推算成分。 + +## 与 `MarketQuoteSyncService` 的分工 + +那条线写的是 `advisor_product_market_quote_snapshot`(投顾侧快照与双源健康监控), +**不是**下单读的这张表。数据源、目标表、消费方都不同,故独立成服务。 +""" + +from collections.abc import Callable, Mapping, Sequence +from datetime import UTC, date, datetime +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.db import SessionFactory +from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter +from app.model.fund import FundMarketPrice, FundProduct + +#: 来源口径。带 `+shares_estimated` 表示 `total_fund_shares` 是推算的; +#: `eastmoney_nav_fallback` 表示腾讯行情没有这只是,改用东财净值构造(见 `_nav_fallback`)。 +SOURCE_QUOTE = "tencent_quote" +SOURCE_QUOTE_ESTIMATED = "tencent_quote+shares_estimated" +SOURCE_NAV_FALLBACK = "eastmoney_nav_fallback" + +PRICE_QUANT = Decimal("0.000001") +AMOUNT_QUANT = Decimal("0.01") +SHARES_QUANT = Decimal("0.0001") +#: 市值的单位是"亿元"。 +YI = Decimal("100000000") + + +class MarketPriceSyncService: + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + adapter: EastmoneyFundAdapter | None = None, + ) -> None: + self.session_factory = session_factory + self.adapter = adapter or EastmoneyFundAdapter() + + async def sync(self, *, product_codes: tuple[str, ...] | None = None) -> dict[str, object]: + """拉取并落库当日行情。返回摘要 —— 谁同步了、谁没同步上、为什么。""" + async with self.session_factory() as session: + products = [ + dict(row) + for row in ( + await session.execute( + select( + FundProduct.id, + FundProduct.product_code, + FundProduct.product_name, + ) + .where( + FundProduct.exchange_code.in_(("SSE", "SZSE")), + FundProduct.status == "上市", + ) + .order_by(FundProduct.id) + ) + ).mappings().all() + ] + existing_shares = await self._existing_shares(session) + + if product_codes is not None: + allowed = set(product_codes) + products = [row for row in products if str(row["product_code"]) in allowed] + if not products: + return {"requested": 0, "written": 0, "skipped": [], "message": "没有匹配的场内产品"} + + codes = [str(row["product_code"]) for row in products] + code_to_id = {str(row["product_code"]): int(row["id"]) for row in products} + quotes = await self.adapter.fetch_tencent_quotes(codes) + # 行情源没有的产品,**在事务外**用净值源补上(在事务里做网络请求会拉长事务)。 + fallbacks: dict[str, dict[str, Any]] = {} + for code in codes: + if quotes.get(code): + continue + fallback = await self._nav_fallback(code) + if fallback is not None: + fallbacks[code] = fallback + # 只为**既没有已登记份额、行情里也没有总市值**的产品去抓季度规模兜底。 + need_scale = [ + code + for code in codes + if code_to_id[code] not in existing_shares + and (quotes.get(code) or {}).get("total_market_value_yi") is None + ] + scales = await self.adapter.fetch_fund_scale(need_scale) if need_scale else {} + + written = 0 + skipped: list[dict[str, str]] = [] + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + for row in products: + code = str(row["product_code"]) + product_id = int(row["id"]) + quote = quotes.get(code) or fallbacks.get(code) + used_fallback = code in fallbacks + if not quote: + skipped.append({"product_code": code, "reason": "行情源与净值源都没有它"}) + continue + trade_date = self._trade_date(quote, now) + if trade_date is None: + skipped.append({"product_code": code, "reason": "行情时间戳无法解析"}) + continue + shares, estimated = self._resolve_shares( + existing_shares.get(product_id), quote, scales.get(code) + ) + if shares is None: + skipped.append({ + "product_code": code, + "reason": "无法确定总份额(既无已登记值、也无总市值/规模)—— 不写", + }) + continue + if await self._upsert( + session, product_id, trade_date, quote, shares, + self._source_of(used_fallback, estimated), now, + ): + written += 1 + + return { + "requested": len(products), + "written": written, + "synced_products": len(products) - len(skipped), + "skipped": skipped, + } + + # ---- 内部 ---- + + @staticmethod + async def _existing_shares(session: AsyncSession) -> dict[int, Decimal]: + """每只产品**最近一行**已登记的 `total_fund_shares`(按 trade_date 倒序取首个)。""" + rows = await session.execute( + select(FundMarketPrice.product_id, FundMarketPrice.total_fund_shares).order_by( + FundMarketPrice.product_id, FundMarketPrice.trade_date.desc() + ) + ) + shares: dict[int, Decimal] = {} + for product_id, value in rows: + shares.setdefault(int(product_id), Decimal(str(value))) + return shares + + @staticmethod + def _trade_date(quote: Mapping[str, Any], fallback: datetime) -> date | None: + """取交易日:优先腾讯行情时间戳(`YYYYMMDDHHMMSS`),其次净值日期,最后当天。""" + raw = str(quote.get("quoted_at") or "") + if len(raw) >= 8 and raw[:8].isdigit(): + try: + return date(int(raw[:4]), int(raw[4:6]), int(raw[6:8])) + except ValueError: + return fallback.date() + nav_date = quote.get("nav_date") + if isinstance(nav_date, date): + return nav_date + return fallback.date() + + @staticmethod + def _source_of(used_fallback: bool, estimated: bool) -> str: + """这一行的来源口径:净值降级 > 含估算份额 > 纯行情。""" + if used_fallback: + return SOURCE_NAV_FALLBACK + return SOURCE_QUOTE_ESTIMATED if estimated else SOURCE_QUOTE + + async def _nav_fallback(self, code: str) -> dict[str, Any] | None: + """行情源没有这只是时,用东财历史净值构造一条**降级**行情。 + + 为什么可以接受:`source` 会写成 `eastmoney_nav_fallback` 明确标注来源, + `volume`/`turnover` 留空(净值接口不提供量额),不编造任何数字; + 开高低用净值同值(该接口只给一个价格)。 + + ⚠️ **净值不等于市价**:若该产品确实在交易所交易,用它当收盘价会有折溢价偏差。 + 所以这条路径只应落在"行情源没有覆盖"的产品上,且值得业务侧复核 —— + 曾经触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情): + C 类份额只在场外销售、**不在交易所挂牌**,本就不该出现在场内产品列表里, + 2026-09-13 已替换为 `515450`(红利低波50ETF南方)。 + 所以这条降级**再次被触发时,先怀疑清单里混进了非上市份额**(C 类、场外份额), + 而不是行情源出了问题。 + """ + history = await self.adapter.fetch_history(code, date.today()) + nav = history.get("nav") + if nav is None or history.get("degraded"): + return None + return { + "code": code, + "open": nav, + "close": nav, + "high": nav, + "low": nav, + "volume": None, + "turnover": None, + "total_market_value_yi": None, + "quoted_at": None, + "nav_date": history.get("nav_date"), + } + + @staticmethod + def _resolve_shares( + existing: Decimal | None, + quote: Mapping[str, Any], + scale: Mapping[str, Any] | None, + ) -> tuple[Decimal | None, bool]: + """决定这行用哪个总份额:(值, 是否为推算值)。都拿不到时返回 (None, True)。""" + if existing is not None and existing > 0: + return existing, False + close = quote.get("close") + if close in (None, 0): + return None, True + # 优先腾讯总市值(当日实时),其次季度规模(口径较旧)。 + candidates = [quote.get("total_market_value_yi"), (scale or {}).get("scale_yi")] + for candidate in candidates: + if candidate is None: + continue + try: + raw = Decimal(str(candidate)) * YI / Decimal(str(close)) + except (InvalidOperation, ZeroDivisionError, TypeError): + continue + if raw > 0: + return raw.quantize(SHARES_QUANT, rounding=ROUND_HALF_UP), True + return None, True + + @staticmethod + async def _upsert( + session: AsyncSession, + product_id: int, + trade_date: date, + quote: Mapping[str, Any], + shares: Decimal, + source: str, + now: datetime, + ) -> bool: + """按 `(product_id, trade_date)` 覆盖写一行;返回是否真的写了。""" + close = quote.get("close") + if close is None or close <= 0: + return False + values: dict[str, Any] = { + "open_price": MarketPriceSyncService._q(quote.get("open") or close, PRICE_QUANT), + "high_price": MarketPriceSyncService._q(quote.get("high") or close, PRICE_QUANT), + "low_price": MarketPriceSyncService._q(quote.get("low") or close, PRICE_QUANT), + "close_price": MarketPriceSyncService._q(close, PRICE_QUANT), + "volume": MarketPriceSyncService._q_optional(quote.get("volume")), + "turnover_amount": MarketPriceSyncService._q_optional(quote.get("turnover")), + "total_fund_shares": shares, + "source": source, + "source_updated_at": now, + } + existing = await session.scalar( + select(FundMarketPrice.id).where( + FundMarketPrice.product_id == product_id, + FundMarketPrice.trade_date == trade_date, + ) + ) + if existing is not None: + await session.execute( + update(FundMarketPrice).where(FundMarketPrice.id == existing).values(**values) + ) + return True + # `fin_*` 系列表的 id 没有 AUTO_INCREMENT(只有 fin_knowledge_meta 有), + # 必须自己取下一个可用值 —— 与 `tools/seed_sim_account_demo.py` 同一口径。 + max_id = await session.scalar(select(func.coalesce(func.max(FundMarketPrice.id), 0))) + session.add( + FundMarketPrice( + id=int(max_id or 0) + 1, product_id=product_id, trade_date=trade_date, + created_at=now, **values, + ) + ) + await session.flush() + return True + + @staticmethod + def _q(value: Any, quant: Decimal) -> Decimal: + return Decimal(str(value)).quantize(quant, rounding=ROUND_HALF_UP) + + @staticmethod + def _q_optional(value: Any) -> Decimal | None: + if value is None: + return None + try: + return MarketPriceSyncService._q(value, AMOUNT_QUANT) + except (InvalidOperation, TypeError): + return None + + +def summarize(result: Mapping[str, Any]) -> str: + """把 `sync()` 的结果压成一行可读文本,供 CLI 打印。""" + parts = [ + f"请求 {result.get('requested')} 只", + f"落库 {result.get('written')} 行", + f"同步上 {result.get('synced_products')} 只", + ] + skipped: Sequence[Mapping[str, str]] = result.get("skipped") or [] + if skipped: + parts.append(f"跳过 {len(skipped)} 只") + return ",".join(parts) diff --git a/app/service/public_product_service.py b/app/service/public_product_service.py new file mode 100644 index 0000000..fc91c56 --- /dev/null +++ b/app/service/public_product_service.py @@ -0,0 +1,151 @@ +"""公开产品列表:在售的场内基金,访客无需登录即可浏览。 + +## 为什么需要它 + +访客的三个页面(首页推荐、产品列表、产品详情)此前只能读前端手写的 +`app/static/portal/common/mock-data.js` —— 那份数据只有 8 只,而且大半**不是本平台 +的产品**(例如把海富通的 `511360` 标成"南方短融ETF"、产品代码 `159915`/`512100`/ +`513100` 在 `fin_product` 里根本不存在),净值也是编的。 +`app/static/portal/README.md` 把它记为"公开产品 HTTP 接口尚未实现"的临时方案。 + +本服务提供真实数据:产品来自 `fin_product`(`status='上市'`),最新价来自 +`fin_market_price`(由 `tools/sync_market_prices.py` 从行情源同步)。 + +## 鉴权口径 + +**要求有效令牌,但不检查任何权限码。** 访客令牌的上下文只有 `roles=("visitor",)`、 +不带权限(见 `app/core/security.py`),这与 `/api/v1/conversations`、 +`/api/v1/agent-runs` 给访客用的方式一致 —— 产品信息本身是公开信息, +要求令牌只是为了复用统一的两段式入口与限流,而不是为了授权。 + +## 字段与前端的关系 + +返回的字段名**刻意与 `mock-data.js` 的 `MOCK_PRODUCTS` 对齐** +(`product_code`/`product_name`/`product_category`/`risk_level`/`current_nav`/ +`current_nav_at`/`exchange_code`),这样前端只需换数据源,渲染与筛选逻辑一行都不用改。 + +⚠️ **不返回涨跌幅**:`fin_market_price` 每只产品每个交易日只有一行,**没有昨收**, +算不出当日涨跌幅。mock 里那个 `MOCK_RANKING_CHANGE` 是编的,不能照搬成"真实涨跌幅"。 +""" + +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.model.fund import FundMarketPrice, FundProduct +from app.service.admin_service import public + +#: 只暴露在售产品(`fin_product.status`) +LISTED_STATUS = "上市" + + +class PublicProductService: + async def list_products(self, context: RequestContext) -> dict[str, Any]: + """返回全部在售场内产品,附带各自的最新行情(可能为空)。""" + async with SessionFactory() as session: + products = ( + ( + await session.execute( + select(FundProduct) + .where(FundProduct.status == LISTED_STATUS) + .order_by(FundProduct.product_code) + ) + ) + .scalars() + .all() + ) + quotes = await self._latest_quotes(session, [int(item.id) for item in products]) + + items = [self._view(product, quotes.get(int(product.id))) for product in products] + return { + "data": {"products": items, "count": len(items)}, + "meta": {"trace_id": context.trace_id}, + } + + @staticmethod + async def _latest_quotes( + session: AsyncSession, product_ids: list[int] + ) -> dict[int, list[FundMarketPrice]]: + """每只产品取 `trade_date` 最大的**两行**,**一次查询**拿回全部产品。 + + 为什么要两行:当日涨跌幅 = (最新收盘 − 上一交易日收盘) / 上一交易日收盘, + 没有昨收就算不出来。`fin_market_price` 按 `(product_id, trade_date)` upsert, + 每天跑一次同步就会自然累积出昨收。 + + 不按产品逐个查:20 只就是 20 次往返,而列表页每次打开都要调。 + """ + if not product_ids: + return {} + rows = ( + ( + await session.execute( + select(FundMarketPrice) + .where(FundMarketPrice.product_id.in_(product_ids)) + .order_by(FundMarketPrice.product_id, FundMarketPrice.trade_date.desc()) + ) + ) + .scalars() + .all() + ) + grouped: dict[int, list[FundMarketPrice]] = {} + for row in rows: + bucket = grouped.setdefault(int(row.product_id), []) + # 已按 trade_date 倒序,每只产品只留最新的两行 + if len(bucket) < 2: + bucket.append(row) + return grouped + + @staticmethod + def _view(product: FundProduct, quotes: list[FundMarketPrice] | None) -> dict[str, Any]: + latest = quotes[0] if quotes else None + previous = quotes[1] if quotes and len(quotes) > 1 else None + return { + "product_code": product.product_code, + "product_name": product.product_name, + "exchange_code": product.exchange_code, + "product_category": product.product_category, + "risk_level": product.risk_level, + "fund_manager": product.fund_manager, + "current_nav": public(product.current_nav, "current_nav"), + "current_nav_at": public(product.current_nav_at), + "status": product.status, + # 产品详情页要用的静态字段(原先由前端 mock 提供,现改为真实列) + "currency": product.currency, + "lot_size": public(product.lot_size, "lot_size"), + "price_tick": public(product.price_tick, "price_tick"), + "min_amount": public(product.min_amount, "min_amount"), + "management_fee_rate": public(product.management_fee_rate, "management_fee_rate"), + "custodian_fee_rate": public(product.custodian_fee_rate, "custodian_fee_rate"), + # 行情可能还没同步(新上架产品、或行情源没有覆盖),此时为 null, + # 前端要能显示"暂无行情"而不是显示 0。 + "latest_close": public(latest.close_price, "latest_close") if latest else None, + "latest_trade_date": _iso(latest.trade_date) if latest else None, + "quote_source": latest.source if latest else None, + # 只有一天的行情时**返回 null 而不是 0** —— 0 会被读成"平盘", + # 那是编出来的结论。前端对 null 显示"—"。 + "change_pct": _change_pct(latest, previous), + } + + +def _change_pct( + latest: FundMarketPrice | None, previous: FundMarketPrice | None +) -> float | None: + """当日涨跌幅(百分比)。缺任一日的收盘价就返回 None。""" + if latest is None or previous is None: + return None + try: + base = Decimal(previous.close_price) + if base <= 0: + return None + return float((Decimal(latest.close_price) - base) / base * 100) + except (InvalidOperation, TypeError, ValueError): + return None + + +def _iso(value: date | None) -> str | None: + return value.isoformat() if value is not None else None diff --git a/app/static/portal/README.md b/app/static/portal/README.md index dbed22e..5bab63c 100644 --- a/app/static/portal/README.md +++ b/app/static/portal/README.md @@ -4,9 +4,9 @@ | 路由 | 角色 | 数据源 | |---|---|---| -| `/portal/guest/home/` | 访客 / 全部 | 公开展示,产品摘要为集中 mock | -| `/portal/guest/products/` | 访客 / 全部 | `fin_product` 同字段集中 mock | -| `/portal/guest/product-detail/?code=` | 访客 / 全部 | `fin_product`、`fin_nav_history` 同字段集中 mock | +| `/portal/guest/home/` | 访客 / 全部 | P001(推荐位取前 3 只) | +| `/portal/guest/products/` | 访客 / 全部 | P001 | +| `/portal/guest/product-detail/?code=` | 访客 / 全部 | P001(**不含历史净值曲线**) | | `/portal/customer/login/` | 未登录客户 | A034 | | `/portal/customer/dashboard/` | customer / admin | T001、T002 | | `/portal/customer/holdings/` | customer / admin | T006 | @@ -32,7 +32,21 @@ `query_knowledge` 这个工具名;登录客户走 `search_knowledge`。两者都要出现在发布配置的 `agent_tools/customer_service:` 白名单里,缺哪一条,对应人群就一问即失败。 -公开产品 HTTP 接口尚未实现,因此相关页面使用 `common/mock-data.js`,不得与登录后的真实账户数据混用。 -**凡渲染这些 mock 数据的页面都必须挂 `data-source-notice` 并写入 `MOCK_SOURCE_NOTICE`** -(`guest/products/`、`guest/product-detail/`)—— 删掉声明不会让数据变真,只会让客户误以为看到的是真实净值。 +公开产品数据来自 **`GET /api/v1/products`(编号 P001,见 `docs/05` §19)**, +产品与净值取自 `fin_product`(`status='上市'`)、行情取自 `fin_market_price` 的最新一行。 +访客页面通过 `common/visitor-token.js` 取短期访客令牌后调用;该文件是访客令牌的**唯一实现** +(客服浮窗也用它),不要在页面里另写一份,否则存储 key 与过期判断迟早不一致。 +**公开产品数据不得与登录后的真实账户数据混用。** + +三点口径(改前端前先读): + +1. **`change_pct` 可能是 `null`**(行情只同步过一个交易日时算不出涨跌)。 + 调用方必须显示"暂无",**不得当成 `0`** —— `formatPercent` 收到 `null` 会渲染成 `+0.00%`, + 那等于告诉客户"今天平盘"。 +2. **历史净值曲线没有数据源**:`fin_nav_history` 目前 0 行,详情页因此**不画走势图**, + 并显式说明"尚未接入"。此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据。 +3. **产品级披露在 `common/product-notes.js`**(如 510300 的"同指数参考产品,非本公司发行")。 + `fin_product` 没有这个字段,所以它留在前端;新增需要披露的产品时改那一份。 + 凡渲染公开产品的页面都要挂 `data-source-notice` 说明数据来源。 + 客户页面均由 `common/auth.js` 执行入口守卫,接口路径只在 `common/api-client.js` 的端点表登记。 diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 0b4937f..a50e29c 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -1,8 +1,9 @@ -import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js'; +import { clearAuthSession, getAccessToken } from '/static/portal/common/auth.js?v=20260913'; const ENDPOINTS = Object.freeze({ A034: { method: 'POST', path: '/api/v1/auth/tokens', auth: false }, V001: { method: 'POST', path: '/api/v1/visitor-tokens', auth: false, raw: true }, + P001: { method: 'GET', path: '/api/v1/products' }, C001: { method: 'POST', path: '/api/v1/conversations', idempotent: true }, C002: { method: 'GET', path: '/api/v1/conversations/{sessionId}' }, C003: { method: 'GET', path: '/api/v1/conversations/{sessionId}/messages' }, @@ -54,6 +55,10 @@ const ENDPOINTS = Object.freeze({ ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' }, ADVISOR_GOAL: { method: 'GET', path: '/api/v1/advisor/investment-goals/current' }, ADVISOR_ANALYSIS: { method: 'POST', path: '/api/v1/advisor/portfolio-analysis' }, + ADVISOR_ALLOCATION: { method: 'POST', path: '/api/v1/advisor/asset-allocation' }, + ADVISOR_RECOMMEND: { method: 'POST', path: '/api/v1/advisor/recommendations', idempotent: true }, + ADVISOR_CREATE_GOAL: { method: 'POST', path: '/api/v1/advisor/investment-goals', idempotent: true }, + ADVISOR_CUSTOMER_GOAL: { method: 'GET', path: '/api/v1/advisor/customers/{customerId}/investment-goals/current' }, OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' }, OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' }, }); diff --git a/app/static/portal/common/customer-list-page.js b/app/static/portal/common/customer-list-page.js index 0d956c0..9b7c4a5 100644 --- a/app/static/portal/common/customer-list-page.js +++ b/app/static/portal/common/customer-list-page.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { requireCustomer } from '/static/portal/common/auth.js'; +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/customer-service-widget/widget.js b/app/static/portal/common/customer-service-widget/widget.js index 386d4db..905fe78 100644 --- a/app/static/portal/common/customer-service-widget/widget.js +++ b/app/static/portal/common/customer-service-widget/widget.js @@ -1,28 +1,7 @@ import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913'; -import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js'; +import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml } from '/static/portal/common/formatters.js'; - -const VISITOR_TOKEN_KEY = 'portalVisitorToken'; - -function visitorToken() { - try { return sessionStorage.getItem(VISITOR_TOKEN_KEY) || ''; } catch { return ''; } -} - -function saveVisitorToken(token) { - try { sessionStorage.setItem(VISITOR_TOKEN_KEY, token); } catch { /* storage may be blocked */ } -} - -function visitorTokenExpired(token) { - try { - const encoded = token.split('.')[1]; - if (!encoded) return true; - const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); - const payload = JSON.parse(atob(normalized)); - return !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now() + 15000; - } catch { - return true; - } -} +import { saveVisitorToken, visitorToken, visitorTokenExpired } from '/static/portal/common/visitor-token.js'; function mountMarkup(mode) { const isCustomer = mode === 'customer'; diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 3ad8897..af98dd0 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -3,7 +3,7 @@ import { getAuthContext, startAuthSync, switchAccount, -} from '/static/portal/common/auth.js'; +} from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml } from '/static/portal/common/formatters.js'; import { mountCustomerServiceWidget } from '/static/portal/common/customer-service-widget/widget.js'; diff --git a/app/static/portal/common/login-controller.js b/app/static/portal/common/login-controller.js index e0d935f..9e2de6e 100644 --- a/app/static/portal/common/login-controller.js +++ b/app/static/portal/common/login-controller.js @@ -4,7 +4,7 @@ import { setAuthSession, staffHomeForRoles, startAuthSync, -} from '/static/portal/common/auth.js'; +} from '/static/portal/common/auth.js?v=20260913'; const REASON_MESSAGES = Object.freeze({ 'account-switched': '当前浏览器中的账号已切换,请重新登录后继续。', diff --git a/app/static/portal/common/mock-data.js b/app/static/portal/common/mock-data.js deleted file mode 100644 index 0c20788..0000000 --- a/app/static/portal/common/mock-data.js +++ /dev/null @@ -1,75 +0,0 @@ -const PRODUCT_BASE = { - exchange_code: 'SSE', - product_category: 'ETF', - risk_level: 'R3', - fund_manager: '南方基金管理股份有限公司', - currency: 'CNY', - lot_size: '100.0000', - price_tick: '0.001000', - min_amount: '100.00', - open_start_at: null, - open_end_at: null, - open_period_start: null, - open_period_end: null, - transaction_fee_rate: '0.000500', - single_investor_max_holding_ratio: '10.0000', - management_fee_rate: '0.5000', - custodian_fee_rate: '0.1000', - //: 产品级说明,默认无。只有需要额外披露的条目才填,例如「同指数参考产品」。 - product_note: null, - risk_disclosure_required: 1, - second_confirmation_required: 0, - recording_required: 0, - status: '正常', - created_at: '2026-01-02T09:00:00', - updated_at: '2026-09-11T15:00:00', -}; - -export const MOCK_PRODUCTS = Object.freeze([ - { - ...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF', - current_nav: '4.579400', current_nav_at: '2026-09-11T15:00:00', - product_category: '宽基ETF', risk_level: 'R3', - management_fee_rate: '0.1500', custodian_fee_rate: '0.0500', - // 这只的代码 510300 在真实市场上对应的是**另一家管理人**的沪深300ETF, - // 所以必须显式披露:否则客户会以为它是本公司发行的产品。 - product_note: '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。', - }, - { ...PRODUCT_BASE, id: 2, product_code: '510500', product_name: '南方中证500ETF', current_nav: '6.742000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, - { ...PRODUCT_BASE, id: 3, product_code: '159915', product_name: '南方创业板ETF', current_nav: '2.184000', current_nav_at: '2026-09-11T15:00:00', product_category: '成长ETF', risk_level: 'R4', exchange_code: 'SZSE' }, - { ...PRODUCT_BASE, id: 4, product_code: '512100', product_name: '南方中证1000ETF', current_nav: '2.367000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' }, - { ...PRODUCT_BASE, id: 5, product_code: '513100', product_name: '南方纳指ETF', current_nav: '1.728000', current_nav_at: '2026-09-11T15:00:00', product_category: '跨境ETF', risk_level: 'R5' }, - { ...PRODUCT_BASE, id: 6, product_code: '511360', product_name: '南方短融ETF', current_nav: '110.126000', current_nav_at: '2026-09-11T15:00:00', product_category: '债券ETF', risk_level: 'R2' }, - { ...PRODUCT_BASE, id: 7, product_code: '159645', product_name: '南方新能源ETF', current_nav: '0.864000', current_nav_at: '2026-09-11T15:00:00', product_category: '行业ETF', risk_level: 'R5', exchange_code: 'SZSE' }, - { ...PRODUCT_BASE, id: 8, product_code: '159925', product_name: '南方沪深300成长ETF', current_nav: '1.462000', current_nav_at: '2026-09-11T15:00:00', product_category: '策略ETF', risk_level: 'R4', exchange_code: 'SZSE' }, -]); - -const CLOSE_SERIES = [4.02, 4.08, 4.05, 4.12, 4.16, 4.11, 4.19, 4.23, 4.18, 4.26, 4.21, 4.218]; - -export function getMockNavHistory(productId) { - const product = MOCK_PRODUCTS.find((item) => item.id === Number(productId)) || MOCK_PRODUCTS[0]; - const ratio = Number(product.current_nav) / CLOSE_SERIES.at(-1); - return CLOSE_SERIES.map((close, index) => ({ - id: product.id * 100 + index, - product_id: product.id, - nav_date: `2026-${String(index + 1).padStart(2, '0')}-11`, - nav: (close * ratio).toFixed(6), - created_at: `2026-${String(index + 1).padStart(2, '0')}-11T15:00:00`, - })); -} - -export const MOCK_RANKING_CHANGE = Object.freeze({ - '510300': 1.26, - '510500': 2.18, - '159915': -0.42, - '512100': 1.88, - '513100': 0.76, - '511360': 0.05, - '159645': -1.12, - '159925': 1.54, -}); - -//: 公开产品 HTTP 接口尚未实现,访客页面的产品、净值与涨跌**来自本文件而不是数据库**。 -//: 这是接口缺位时的显式降级,不是真实行情 —— 凡展示这些数据的页面都必须原样呈现这句话。 -//: 删掉它并不会让数据变真,只会让客户以为看到的是真实净值。 -export const MOCK_SOURCE_NOTICE = '公开产品接口尚未提供,本页使用与 fin_product、fin_nav_history 模型同字段的演示数据,非真实行情。'; diff --git a/app/static/portal/common/permission-guard.js b/app/static/portal/common/permission-guard.js index 3dba9ec..1a5055c 100644 --- a/app/static/portal/common/permission-guard.js +++ b/app/static/portal/common/permission-guard.js @@ -1,4 +1,4 @@ -import { getPermissions } from '/static/portal/common/auth.js'; +import { getPermissions } from '/static/portal/common/auth.js?v=20260913'; export function applyPermissionGuard(root = document) { const permissions = new Set(getPermissions()); diff --git a/app/static/portal/common/product-notes.js b/app/static/portal/common/product-notes.js new file mode 100644 index 0000000..bcf8cf0 --- /dev/null +++ b/app/static/portal/common/product-notes.js @@ -0,0 +1,22 @@ +/** + * 产品级披露文案。 + * + * ## 为什么留在前端 + * + * `fin_product` 没有这个字段,而它描述的是**平台对某只产品的说明**(例如"该代码在 + * 真实市场对应另一家管理人"),属于页面披露而不是产品要素。将来若要在后台维护, + * 再迁到接口字段。 + * + * ## ⚠️ 510300 这条必须保留 + * + * 它的代码在真实市场对应的是**另一家管理人**的沪深300ETF。页面其它地方显示的是 + * `fin_product.fund_manager`(本平台登记为"南方基金"),没有这条披露, + * 客户会以为它是本公司发行的产品 —— 这是演示底座上最容易越界的一处。 + */ +export const PRODUCT_NOTES = Object.freeze({ + '510300': '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。', +}); + +export function productNote(code) { + return PRODUCT_NOTES[code] || ''; +} diff --git a/app/static/portal/common/visitor-token.js b/app/static/portal/common/visitor-token.js new file mode 100644 index 0000000..2432791 --- /dev/null +++ b/app/static/portal/common/visitor-token.js @@ -0,0 +1,65 @@ +/** + * 访客令牌:公开页面(首页 / 产品列表 / 产品详情 / 客服浮窗)用它访问 + * 「需要令牌但不需要登录」的接口。 + * + * ## 为什么单独抽一个模块 + * + * 这段逻辑原先只写在客服浮窗里(`customer-service-widget/widget.js`)。产品页接入 + * 真实产品接口后,有四处需要同一份逻辑 —— 复制四份的话,存储 key 与过期判断迟早会 + * 不一致,而症状是"某个页面莫名其妙 401",很难查。 + * + * ## 与登录令牌的区别(重要) + * + * 访客令牌**不进** `auth.js` 那套存储:`apiClient` 只会自动附加**登录**令牌, + * 所以访客页面必须显式把 `visitorHeaders()` 的结果传进去: + * + * const headers = await visitorHeaders(); + * const { data } = await apiClient.get('P001', { headers }); + * + * 两者刻意不混用 —— `README.md` 也写明公开产品数据"不得与登录后的真实账户数据混用"。 + */ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; + +const VISITOR_TOKEN_KEY = 'portalVisitorToken'; + +//: 提前量:剩余寿命不足这个值就当作过期,避免"取到时有效、用的时候刚好失效" +const EXPIRY_MARGIN_MS = 15000; + +export function visitorToken() { + try { return sessionStorage.getItem(VISITOR_TOKEN_KEY) || ''; } catch { return ''; } +} + +export function saveVisitorToken(token) { + try { sessionStorage.setItem(VISITOR_TOKEN_KEY, token); } catch { /* storage may be blocked */ } +} + +export function visitorTokenExpired(token) { + try { + const encoded = token.split('.')[1]; + if (!encoded) return true; + const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '='); + const payload = JSON.parse(atob(normalized)); + return !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now() + EXPIRY_MARGIN_MS; + } catch { + return true; + } +} + +/** 返回一个可用的访客令牌;缓存里没有或快过期就换一个新的。 */ +export async function ensureVisitorToken() { + let token = visitorToken(); + if (token && !visitorTokenExpired(token)) return token; + const response = await apiClient.post('V001'); + token = response.data?.access_token || ''; + if (token) saveVisitorToken(token); + return token; +} + +/** + * 访客请求头。拿不到令牌时返回空对象 —— 让请求以 401 明确暴露问题, + * 而不是悄悄退回演示数据(那会让人以为页面是好的,其实全是假数据)。 + */ +export async function visitorHeaders() { + const token = await ensureVisitorToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/app/static/portal/customer/dashboard/dashboard.js b/app/static/portal/customer/dashboard/dashboard.js index 80ec15d..503e13f 100644 --- a/app/static/portal/customer/dashboard/dashboard.js +++ b/app/static/portal/customer/dashboard/dashboard.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { requireCustomer } from '/static/portal/common/auth.js'; +import { requireCustomer } from '/static/portal/common/auth.js?v=20260913'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { applyPermissionGuard } from '/static/portal/common/permission-guard.js'; import { renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260912'; @@ -50,6 +50,12 @@ if (requireCustomer()) { const productCode = new URLSearchParams(window.location.search).get('product_code'); const target = productCode ? document.querySelector(`[data-product-code="${CSS.escape(productCode)}"]`) : null; if (target) { target.classList.add('dashboard-holdings__highlight'); target.scrollIntoView({ block: 'center' }); window.setTimeout(() => target.classList.remove('dashboard-holdings__highlight'), 1500); } + if (new URLSearchParams(window.location.search).get('action') === 'trade') { + const orderForm = document.querySelector('[data-order-form]'); + const productInput = orderForm?.querySelector('[name="product_code"]'); + if (productInput && productCode) productInput.value = productCode; + if (dialog?.showModal && productCode && !dialog.open) dialog.showModal(); + } } async function load({ force = false } = {}) { if (!force && state.data && Date.now() - state.fetchedAt < 60000) { renderData(state.data, true); return; } diff --git a/app/static/portal/customer/dashboard/index.html b/app/static/portal/customer/dashboard/index.html index 91da390..3f5950f 100644 --- a/app/static/portal/customer/dashboard/index.html +++ b/app/static/portal/customer/dashboard/index.html @@ -19,6 +19,6 @@

提交模拟委托

首版仅支持市价委托,系统会立即全额成交。请确认基金代码与数量。

- + diff --git a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js index 15c6d39..0f63a7f 100644 --- a/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js +++ b/app/static/portal/customer/risk-questionnaire/risk-questionnaire.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { requireCustomerOnly } from '/static/portal/common/auth.js'; +import { requireCustomerOnly } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { showToast } from '/static/portal/common/notifications.js'; diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css index 4781765..675cc18 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.css +++ b/app/static/portal/employee-advisor/dashboard/dashboard.css @@ -1,11 +1,29 @@ .advisor-shell { --brand: #2d6f67; --brand-dark: #20574f; --brand-soft: #e1f0eb; --canvas: #f3f7f5; } .advisor-hero { background-image: linear-gradient(118deg, rgba(17, 50, 55, .96), rgba(39, 99, 88, .82) 64%, rgba(67, 126, 108, .68)), url('/static/portal/guest/home/assets/wealth_architecture_hero.jpg'); } .advisor-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: var(--space-4); } +.advisor-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--space-4); align-items: start; } .advisor-panel { min-height: 300px; } .advisor-panel--accent { background: linear-gradient(145deg, var(--surface), var(--surface-soft)); } +.advisor-actions { min-height: 0; } +.action-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.action-card { min-height: 112px; padding: var(--space-4); display: grid; align-content: center; gap: var(--space-2); color: var(--ink); text-align: left; background: linear-gradient(145deg, var(--surface), var(--surface-soft)); border: 1px solid var(--line); border-radius: var(--radius-md); cursor: pointer; transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; } +.action-card:hover, .action-card:focus-visible { border-color: var(--brand); box-shadow: 0 10px 24px rgba(34, 91, 79, .11); transform: translateY(-2px); outline: 0; } +.action-card strong { font-size: 15px; font-weight: 700; } +.action-card span { color: var(--muted); font-size: var(--fs-small); line-height: 1.55; } +.advisor-output { margin-top: var(--space-4); min-height: 148px; padding: var(--space-4); background: var(--canvas); border: 1px solid var(--line); border-radius: var(--radius-md); } +.advisor-output__placeholder, .advisor-output__loading { display: grid; min-height: 110px; place-items: center; color: var(--muted); text-align: center; } +.advisor-output__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); } +.advisor-output__message { margin: 0; color: var(--ink-soft); line-height: 1.7; } +.advisor-inline-form { display: flex; align-items: end; gap: var(--space-3); } +.advisor-inline-form .form-field { flex: 1; } +.advisor-form { display: grid; gap: var(--space-4); } +.advisor-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.advisor-form__wide { grid-column: 1 / -1; } +.advisor-form__textarea { height: auto; padding-top: var(--space-3); padding-bottom: var(--space-3); resize: vertical; } .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; } -@media (max-width: 760px) { .advisor-grid { grid-template-columns: 1fr; } } +@media (max-width: 900px) { .advisor-workspace { grid-template-columns: 1fr; } } +@media (max-width: 760px) { .advisor-grid { grid-template-columns: 1fr; } .action-grid, .advisor-form__grid { grid-template-columns: 1fr; } .advisor-form__wide { grid-column: auto; } .advisor-inline-form { align-items: stretch; flex-direction: column; } } diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index e938323..6ec2019 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js'; +import { getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; @@ -16,6 +16,8 @@ if (requireAdvisor()) { const context = getAuthContext(); const list = document.querySelector('[data-recommendations]'); const metrics = document.querySelector('[data-advisor-metrics]'); + const output = document.querySelector('[data-action-output]'); + const alert = document.querySelector('[data-action-alert]'); document.querySelector('[data-advisor-name]').textContent = context?.username || '投顾人员'; document.querySelector('[data-advisor-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`; async function load() { @@ -28,6 +30,53 @@ if (requireAdvisor()) { list.innerHTML = rows.map((row) => `

${escapeHtml(CONTENT_TYPE_LABELS[row.content_type] || '方案')} · 客户 ${escapeHtml(row.customer_id || '--')}

发布时间:${escapeHtml(formatDateTime(row.published_at))}

${escapeHtml(typeof row.plan === 'string' ? row.plan : JSON.stringify(row.plan || {}))}
`).join(''); } catch (error) { apiClient.reportError(error); renderError(list, error, load); } } + const actionLabels = { portfolio: '组合分析', allocation: '资产配置', recommend: '生成推荐草案', goal: '录入客户目标' }; + const resultMessages = { + profile_required: '客户尚未完成风险测评,请先完成测评后再分析。', + investment_goal_required: '暂无已确认投资目标,暂不能生成配置或推荐。', + investment_goal_invalid: '投资目标数据不完整,请检查客户目标。', + no_positions: '当前客户暂无可分析的持仓。', + valuation_required: '持仓缺少可用市值,暂不能计算集中度。', + }; + function showAlert(message, kind = 'error') { + alert.textContent = message; + alert.className = `form-alert form-alert--visible${kind === 'info' ? ' form-alert--info' : ''}`; + } + function clearAlert() { alert.textContent = ''; alert.className = 'form-alert'; } + function renderResult(title, data) { + const status = data?.status; + const message = resultMessages[status]; + output.innerHTML = `
${escapeHtml(title)}${escapeHtml(status || 'ready')}
${message ? `

${escapeHtml(message)}

` : `
${escapeHtml(JSON.stringify(data, null, 2))}
`}`; + } + function renderGoalForm() { + output.innerHTML = `
`; + output.querySelector('[data-goal-form]').addEventListener('submit', submitGoal); + } + async function submitGoal(event) { + event.preventDefault(); clearAlert(); + const form = event.currentTarget; const value = (name) => form.elements[name].value.trim(); + const lower = Number(value('annualized_return_lower_pct')); const upper = Number(value('annualized_return_upper_pct')); + if (lower > upper) { showAlert('年化收益下限不能高于上限。'); return; } + const body = { annualized_return_lower_pct: value('annualized_return_lower_pct'), annualized_return_upper_pct: value('annualized_return_upper_pct'), max_drawdown_pct: value('max_drawdown_pct'), liquidity_requirement: value('liquidity_requirement'), investment_horizon_months: Number(value('investment_horizon_months')), benchmark_name: value('benchmark_name'), notes: value('notes') || null }; + if (value('customer_id')) body.customer_id = Number(value('customer_id')); + await runAction('ADVISOR_CREATE_GOAL', body, '客户目标已提交,等待确认与审核'); + } + async function runAction(endpoint, body, title) { + clearAlert(); output.innerHTML = '
正在请求服务端分析…
'; + try { const response = await apiClient.post(endpoint, body); const data = response.data ?? response; renderResult(title, data); } + catch (error) { apiClient.reportError(error); showAlert(error.message || '请求未完成,请稍后重试。'); output.innerHTML = '
请求失败,请检查权限或稍后重试。
'; } + } + function openAction(action) { + clearAlert(); + if (action === 'goal') { renderGoalForm(); return; } + if (action === 'recommend') { + output.innerHTML = '
'; + output.querySelector('[data-submit-recommend]').addEventListener('click', () => runAction('ADVISOR_RECOMMEND', { limit: Number(output.querySelector('[data-recommend-limit]').value) }, '推荐方案草案')); + return; + } + runAction(action === 'portfolio' ? 'ADVISOR_ANALYSIS' : 'ADVISOR_ALLOCATION', {}, actionLabels[action]); + } + document.querySelectorAll('[data-action]').forEach((button) => button.addEventListener('click', () => openAction(button.dataset.action))); document.querySelector('[data-refresh]').addEventListener('click', load); load(); } diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index 18920e7..77b615d 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -1,3 +1,3 @@ 投顾工作台 · 南方财富 -

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
+

客户洞察与方案协作

投顾工作台

围绕客户目标、组合分析与已发布方案开展合规的投顾协作,所有建议均需经过审核流程。

客户目标组合分析方案发布
投顾人员权限加载中

工作操作

调用已授权的投顾分析能力,结果仅作为方案草案

选择一项操作,结果将在这里展示。

已发布方案

服务端已审核并发布的客户方案

工作边界

建议生成与审核留痕

  • 先读画像与目标仅访问分配范围内客户,不展示未授权信息。
  • 再做组合分析分析结果是工作草案,不代替人工判断。
  • 最后提交审核发布前保留审核、版本与审计记录。
diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index f70e9cc..08266b5 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js'; +import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { showToast } from '/static/portal/common/notifications.js'; diff --git a/app/static/portal/employee-operations/dashboard/dashboard.js b/app/static/portal/employee-operations/dashboard/dashboard.js index 83562cb..c239965 100644 --- a/app/static/portal/employee-operations/dashboard/dashboard.js +++ b/app/static/portal/employee-operations/dashboard/dashboard.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; -import { getAuthContext, requireOperator } from '/static/portal/common/auth.js'; +import { getAuthContext, requireOperator } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; diff --git a/app/static/portal/employee-risk/dashboard/dashboard.js b/app/static/portal/employee-risk/dashboard/dashboard.js index 776c267..40f4e3e 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.js +++ b/app/static/portal/employee-risk/dashboard/dashboard.js @@ -1,5 +1,5 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260913-8'; -import { getAuthContext, getPermissions, requireRiskStaff, updateAuthPermissions } from '/static/portal/common/auth.js'; +import { getAuthContext, getPermissions, requireRiskStaff, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { showToast } from '/static/portal/common/notifications.js'; diff --git a/app/static/portal/guest/home/home.js b/app/static/portal/guest/home/home.js index 1ca4f6c..11d0bf9 100644 --- a/app/static/portal/guest/home/home.js +++ b/app/static/portal/guest/home/home.js @@ -1,7 +1,8 @@ import { formatPercent, escapeHtml } from '/static/portal/common/formatters.js'; -import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE } from '/static/portal/common/mock-data.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; -import { getAuthContext, staffHomeForRoles } from '/static/portal/common/auth.js'; +import { getAuthContext, staffHomeForRoles } from '/static/portal/common/auth.js?v=20260913'; mountShell({ active: 'home' }); document.documentElement.classList.add('motion-ready'); @@ -20,12 +21,26 @@ if (authContext) { }); } -const featured = MOCK_PRODUCTS.slice(0, 3); -document.querySelector('[data-featured-products]').innerHTML = featured.map((product) => { - const change = MOCK_RANKING_CHANGE[product.product_code]; - const valueClass = change >= 0 ? 'value--positive' : 'value--negative'; - return `查看详情 `; -}).join(''); +// 首页推荐位取产品库前 3 只(来自 `GET /api/v1/products`,不再是前端手写的演示数据)。 +async function loadFeatured() { + const container = document.querySelector('[data-featured-products]'); + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + const featured = (data?.products || []).slice(0, 3); + container.innerHTML = featured.map((product) => { + const change = product.change_pct; + const quote = change === null || change === undefined + ? '暂无涨跌' + : `${formatPercent(change)}`; + return `查看详情 `; + }).join(''); + } catch { + // 首页是门面:产品取不到时给一句安静说明,不弹红色错误块 + container.innerHTML = '

产品列表暂时不可用,请稍后重试,或直接前往 基金产品库。

'; + } +} +loadFeatured(); const revealItems = document.querySelectorAll('[data-reveal]'); if ('IntersectionObserver' in window) { diff --git a/app/static/portal/guest/home/index.html b/app/static/portal/guest/home/index.html index 5f49d1d..c758ed6 100644 --- a/app/static/portal/guest/home/index.html +++ b/app/static/portal/guest/home/index.html @@ -69,6 +69,6 @@
开始筛选已有账户,直接登录
- + diff --git a/app/static/portal/guest/product-detail/index.html b/app/static/portal/guest/product-detail/index.html index 40e6a68..04163bb 100644 --- a/app/static/portal/guest/product-detail/index.html +++ b/app/static/portal/guest/product-detail/index.html @@ -5,11 +5,11 @@ 基金详情 · 南方财富 - +
- +
@@ -18,6 +18,6 @@

风险与交易说明

风险等级
最小金额
交易方式场内市价模拟成交

基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。

- + diff --git a/app/static/portal/guest/product-detail/product-detail.js b/app/static/portal/guest/product-detail/product-detail.js index f6890b2..6a841fa 100644 --- a/app/static/portal/guest/product-detail/product-detail.js +++ b/app/static/portal/guest/product-detail/product-detail.js @@ -1,51 +1,87 @@ +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { getAuthContext } from '/static/portal/common/auth.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; import { escapeHtml, formatCurrency, formatPercent } from '/static/portal/common/formatters.js'; -import { getMockNavHistory, MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; +import { productNote } from '/static/portal/common/product-notes.js'; +import { renderError } from '/static/portal/common/state-view.js'; mountShell({ active: 'products' }); -const code = new URLSearchParams(window.location.search).get('code') || MOCK_PRODUCTS[0].product_code; -const product = MOCK_PRODUCTS.find((item) => item.product_code === code) || MOCK_PRODUCTS[0]; -const history = getMockNavHistory(product.id); -const change = MOCK_RANKING_CHANGE[product.product_code]; -document.title = `${product.product_name} · 南方财富`; -document.querySelector('[data-product-name]').textContent = product.product_name; -document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`; -// 详情页含历史净值曲线,全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。 -document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE; -// 产品级披露:只有带 product_note 的条目才显示(如"同指数参考产品")。 -const noteNode = document.querySelector('[data-product-note]'); -if (product.product_note) { - noteNode.textContent = product.product_note; - noteNode.hidden = false; +const requestedCode = new URLSearchParams(window.location.search).get('code') || ''; +const root = document.querySelector('[data-detail-root]'); + +/** 费率/份额这类可空字段:没有值就显示 --,不要用 Number(null) 变成 0。 */ +function orDash(value, render) { + return value === null || value === undefined || value === '' ? '--' : render(value); } -document.querySelector('[data-current-nav]').textContent = product.current_nav; -const changeNode = document.querySelector('[data-change]'); -changeNode.textContent = formatPercent(change); -changeNode.className = change >= 0 ? 'value--positive' : 'value--negative'; -document.querySelector('[data-risk-level]').textContent = product.risk_level; -document.querySelector('[data-min-amount]').textContent = formatCurrency(product.min_amount); -document.querySelector('[data-start-date]').textContent = history[0].nav_date; -document.querySelector('[data-end-date]').textContent = history.at(-1).nav_date; -document.querySelector('[data-product-facts]').innerHTML = [ - ['基金代码', product.product_code], - ['交易市场', product.exchange_code], - ['产品类型', product.product_category], - ['基金管理人', product.fund_manager], - ['申报单位', `${Number(product.lot_size).toFixed(0)} 份`], - ['价格最小变动', product.price_tick], - ['管理费率', `${Number(product.management_fee_rate).toFixed(2)}%`], - ['托管费率', `${Number(product.custodian_fee_rate).toFixed(2)}%`], -].map(([label, value]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(''); +function render(product) { + document.title = `${product.product_name} · 南方财富`; + document.querySelector('[data-product-name]').textContent = product.product_name; + document.querySelector('[data-product-meta]').textContent = + `${product.product_code} · ${product.exchange_code} · ${product.product_category}`; + document.querySelector('[data-source-notice]').textContent = + '产品、净值与行情来自平台产品库与行情源同步结果,仅供公开研究参考,不构成投资建议。'; -const values = history.map((item) => Number(item.nav)); -const min = Math.min(...values); -const max = Math.max(...values); -const range = max - min || 1; -const points = values.map((value, index) => { - const x = (index / (values.length - 1)) * 740 + 10; - const y = 250 - ((value - min) / range) * 210; - return `${x.toFixed(1)},${y.toFixed(1)}`; -}).join(' '); -document.querySelector('[data-chart]').innerHTML = ``; + const note = productNote(product.product_code); + const noteNode = document.querySelector('[data-product-note]'); + if (note) { + noteNode.textContent = note; + noteNode.hidden = false; + } + + const tradeAction = document.querySelector('[data-trade-action]'); + if (tradeAction && getAuthContext()?.roles?.includes('customer')) { + tradeAction.textContent = '进入交易'; + tradeAction.href = `/portal/customer/dashboard/?product_code=${encodeURIComponent(product.product_code)}&action=trade`; + } + + document.querySelector('[data-current-nav]').textContent = + orDash(product.current_nav, (value) => String(value)); + const change = product.change_pct; + const changeNode = document.querySelector('[data-change]'); + if (change === null || change === undefined) { + changeNode.textContent = '暂无涨跌'; + changeNode.className = ''; + } else { + changeNode.textContent = formatPercent(change); + changeNode.className = change >= 0 ? 'value--positive' : 'value--negative'; + } + + document.querySelector('[data-risk-level]').textContent = product.risk_level || '--'; + document.querySelector('[data-min-amount]').textContent = + Number(product.min_amount) > 0 ? formatCurrency(product.min_amount) : '1 手(100 份)起'; + + document.querySelector('[data-product-facts]').innerHTML = [ + ['基金代码', product.product_code], + ['交易市场', product.exchange_code], + ['产品类型', product.product_category], + ['基金管理人', product.fund_manager], + ['申报单位', orDash(product.lot_size, (value) => `${Number(value).toFixed(0)} 份`)], + ['价格最小变动', orDash(product.price_tick, (value) => String(value))], + ['管理费率', orDash(product.management_fee_rate, (value) => `${Number(value).toFixed(2)}%`)], + ['托管费率', orDash(product.custodian_fee_rate, (value) => `${Number(value).toFixed(2)}%`)], + ].map(([label, value]) => + `
${escapeHtml(label)}${escapeHtml(value)}
`).join(''); + + // 没有真实历史净值时不绘制虚构曲线,避免把 mock 数据误认为行情。 + document.querySelector('[data-chart]').outerHTML = + '

历史净值数据尚未接入,暂不展示走势图。

'; + document.querySelector('[data-start-date]').textContent = ''; + document.querySelector('[data-end-date]').textContent = product.latest_trade_date || ''; +} + +async function load() { + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + const products = data?.products || []; + if (!products.length) throw new Error('产品库暂时没有可展示的产品'); + render(products.find((item) => item.product_code === requestedCode) || products[0]); + } catch (error) { + renderError(root, error, load); + } +} + +load(); diff --git a/app/static/portal/guest/products/index.html b/app/static/portal/guest/products/index.html index 06bc96e..aafb53e 100644 --- a/app/static/portal/guest/products/index.html +++ b/app/static/portal/guest/products/index.html @@ -5,7 +5,7 @@ 基金产品 · 南方财富 - +
@@ -22,6 +22,6 @@
- + diff --git a/app/static/portal/guest/products/products.js b/app/static/portal/guest/products/products.js index 26d289d..4345b74 100644 --- a/app/static/portal/guest/products/products.js +++ b/app/static/portal/guest/products/products.js @@ -1,7 +1,8 @@ import { escapeHtml, formatPercent } from '/static/portal/common/formatters.js'; -import { MOCK_PRODUCTS, MOCK_RANKING_CHANGE, MOCK_SOURCE_NOTICE } from '/static/portal/common/mock-data.js'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { visitorHeaders } from '/static/portal/common/visitor-token.js'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; -import { renderEmpty } from '/static/portal/common/state-view.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js'; const rankingView = new URLSearchParams(window.location.search).get('view') === 'ranking'; mountShell({ active: rankingView ? 'ranking' : 'products' }); @@ -14,34 +15,67 @@ const risk = document.querySelector('#risk'); if (rankingView) { document.title = '基金排行 · 南方财富'; document.querySelector('[data-page-title]').textContent = '基金排行'; - document.querySelector('[data-page-description]').textContent = '按阶段涨跌查看场内基金表现,作为公开研究参考。'; + document.querySelector('[data-page-description]').textContent = '按最近一个交易日的行情涨跌查看场内基金表现,作为公开研究参考。'; document.querySelector('[data-list-title]').textContent = '排行列表'; } -// 本页的产品、净值与涨跌全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。 -// 这句话必须留在页面上:排行视图的文案读起来像真实研究数据,去掉声明就越界了。 -document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE; -[...new Set(MOCK_PRODUCTS.map((item) => item.product_category))].forEach((value) => { - category.insertAdjacentHTML('beforeend', ``); -}); +// 数据来自后端公开产品接口 `GET /api/v1/products`(此前是前端手写的 common/mock-data.js, +// 那 8 只里有 6 只根本不在本平台的产品库中)。 +document.querySelector('[data-source-notice]').textContent = + '产品、净值与行情来自平台产品库与行情源同步结果,仅供公开研究参考,不构成投资建议。'; + +let products = []; function render() { const keyword = search.value.trim().toLowerCase(); - let products = MOCK_PRODUCTS.filter((product) => { - const hitKeyword = !keyword || product.product_name.toLowerCase().includes(keyword) || product.product_code.includes(keyword); - return hitKeyword && (!category.value || product.product_category === category.value) && (!risk.value || product.risk_level === risk.value); + let rows = products.filter((product) => { + const hitKeyword = !keyword + || String(product.product_name || '').toLowerCase().includes(keyword) + || String(product.product_code || '').includes(keyword); + return hitKeyword + && (!category.value || product.product_category === category.value) + && (!risk.value || product.risk_level === risk.value); }); - if (rankingView) products = products.toSorted((a, b) => MOCK_RANKING_CHANGE[b.product_code] - MOCK_RANKING_CHANGE[a.product_code]); - count.textContent = `共 ${products.length} 只`; - if (!products.length) { + // 涨跌幅缺数据(库里还只有一个交易日的行情)的产品排在最后, + // 而不是当成 0 混进涨榜 —— 那是编出来的结论。 + if (rankingView) rows = rows.toSorted((a, b) => (b.change_pct ?? -Infinity) - (a.change_pct ?? -Infinity)); + count.textContent = `共 ${rows.length} 只`; + if (!rows.length) { renderEmpty(list, '没有匹配的基金', '请调整名称、代码、类型或风险等级后重试。'); return; } - list.innerHTML = `
${products.map((product, index) => { - const change = MOCK_RANKING_CHANGE[product.product_code]; - return ``; + list.innerHTML = `
${rankingView ? '排名' : '基金'}产品类型风险等级最新净值演示涨跌状态操作
${rankingView ? `${index + 1}` : ''}${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.exchange_code)}${escapeHtml(product.product_category)}${escapeHtml(product.risk_level)}${escapeHtml(product.current_nav)}${formatPercent(change)}${escapeHtml(product.status)}详情
${rows.map((product, index) => { + const change = product.change_pct; + const changeCell = change === null || change === undefined + ? '' + : ``; + return `${changeCell}`; }).join('')}
${rankingView ? '排名' : '基金'}产品类型风险等级最新净值最近涨跌状态操作
暂无${formatPercent(change)}
${rankingView ? `${index + 1}` : ''}${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.exchange_code)}${escapeHtml(product.product_category)}${escapeHtml(product.risk_level)}${escapeHtml(product.current_nav || '--')}${escapeHtml(product.status)}详情
`; } [search, category, risk].forEach((control) => control.addEventListener('input', render)); -document.querySelector('[data-clear]').addEventListener('click', () => { search.value = ''; category.value = ''; risk.value = ''; render(); }); -render(); +document.querySelector('[data-clear]').addEventListener('click', () => { + search.value = ''; + category.value = ''; + risk.value = ''; + render(); +}); + +async function load() { + renderLoading(list); + try { + const headers = await visitorHeaders(); + const { data } = await apiClient.get('P001', { headers }); + products = data?.products || []; + // 重试时不能把类型选项越加越多:只保留第一个"全部"选项 + category.length = 1; + [...new Set(products.map((item) => item.product_category).filter(Boolean))] + .forEach((value) => { + category.insertAdjacentHTML('beforeend', ``); + }); + render(); + } catch (error) { + renderError(list, error, load); + } +} + +load(); diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index 87ff877..17937dd 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -1162,6 +1162,7 @@ GET /internal/metrics | T007 | `GET /api/v1/users/me/transactions` | `trade:txn:read`(已登录) | 否 | `200` | 成交记录列表 | | T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read`(资源所有者) | 否 | `200` | 成交详情 | | T009 | `GET /api/v1/users/me/cash-ledger` | `account:read:self`(已登录) | 否 | `200` | 资金账本(按 id 倒序游标分页) | +| P001 | `GET /api/v1/products` | 仅要求有效令牌(访客令牌即可,不校验权限码) | 否 | `200` | 否 | > **T001 – T009 的四点说明**: > @@ -1190,6 +1191,28 @@ GET /internal/metrics 业务域接口 `/customer-service/handover-tickets/**`、`/advisory-plans/**`、`/sim-orders/**`、`/risk-scans/**` 和 `/risk-alerts/**` 的具体方法、请求体、领域状态机和错误码分别由对应业务文档登记;它们仍必须遵守本文第 3-5、11 和 12 节。 +> **P001(公开产品列表)的四点说明**: +> +> - **鉴权口径:要求令牌但不校验权限码。** 访客令牌的角色是 `visitor`、**不带任何权限** +> (见 §4.2 与 `app/core/security.py`),因此这里不能用权限码把关,否则访客永远 401。 +> 这与 `/api/v1/agent-runs`、`/api/v1/conversations` 面向访客的做法一致 —— +> 产品信息本身是公开信息,要求令牌只为复用统一入口、限流与追踪,不是为了授权。 +> 数据面只暴露 `fin_product`(`status='上市'`)与 `fin_market_price` 的最新一行, +> **不含任何账户、持仓或客户字段**。 +> - **载荷**:`data.products[]` + `data.count`。产品字段与 `fin_product` 同名 +> (`product_code`/`product_name`/`exchange_code`/`product_category`/`risk_level`/ +> `fund_manager`/`current_nav`/`current_nav_at`/`lot_size`/`price_tick`/`min_amount`/ +> `management_fee_rate`/`custodian_fee_rate`/`status`), +> 另加 `latest_close`/`latest_trade_date`/`quote_source`(来自 `fin_market_price`) +> 与 `change_pct`。金额与费率一律为**字符串**(与既有接口口径一致)。 +> - **`change_pct` 可能为 `null`,调用方必须显示"暂无"而不得当成 `0`。** +> 当日涨跌幅需要**两个交易日**的收盘价,而行情可能只同步过一天。 +> 把 `null` 读成 `0` 等于对客户说"今天平盘",那是编出来的结论。 +> - **首版不提供历史净值/走势曲线**:`fin_nav_history` 目前为空,走势图数据源尚未接入。 +> 首页推荐位、产品列表页、产品详情页共用本端点;此前它们读的是前端手写的 +> `app/static/portal/common/mock-data.js`(8 只演示数据,其中 6 只不在 +> `fin_product` 里),该文件已随本次接入删除。 + ## 20. 变更流程 任何新增或修改接口必须同时更新: diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md index d00c7d9..24d3926 100644 --- a/docs/40-前端验收清单.md +++ b/docs/40-前端验收清单.md @@ -67,9 +67,10 @@ python -m app.worker | **投顾** | `employee-advisor/dashboard/`(2449 B) | ✅ 本轮新增 | | **运营** | `employee-operations/dashboard/`(1968 B) | ✅ 本轮新增 | -**12 个关键静态资源全部 200**:`api-client.js` / `auth.js` / `login-controller.js` / `permission-guard.js` / -`layout/app-shell.js` / `mock-data.js` / `customer-service-widget/widget.js` / `widget.css` / +**13 个关键静态资源全部 200**:`api-client.js` / `auth.js` / `login-controller.js` / `permission-guard.js` / +`layout/app-shell.js` / `visitor-token.js` / `product-notes.js` / `customer-service-widget/widget.js` / `widget.css` / `base.css` / `tokens.css` / `operations.css` / `auth-layout.css` ✅ +(`common/mock-data.js` 已于 2026-09-13 随公开产品接口接入**删除**,实测返回 404 ✅) --- @@ -77,13 +78,22 @@ python -m app.worker | # | 操作 | 预期 | 证据 | |---|---|---|---| -| 2-1 | 打开 `/portal/guest/home/` | 首屏含品牌与产品摘要 | ⚠️待点验 | -| 2-2 | **看页面的数据来源提示条** | 产品列表页与详情页顶部都有浅色提示条,写明"公开产品接口尚未提供…演示数据,**非真实行情**" | ✅ 页面 HTML 与三个 JS 资源实测都带 `data-source-notice` / `MOCK_SOURCE_NOTICE`
⚠️ 渲染位置待点验 | -| 2-3 | 进产品列表 / 详情 | 能筛选、能按排名排序(`?view=ranking`)、详情有净值走势 | ⚠️待点验(数据来自 `common/mock-data.js`,**非真实接口**) | +| 2-1 | 打开 `/portal/guest/home/` | 首屏含品牌与产品摘要 | ⚠️待点验(推荐位取接口返回的前 3 只) | +| 2-2 | **看页面的数据来源提示条** | 产品列表页与详情页顶部都有浅色提示条,写明数据来自平台产品库与行情源同步结果 | ✅ 三个页面都写 `[data-source-notice]`;接口侧由 `tests/integration/test_public_products_endpoint_mysql.py` 守着
⚠️ 渲染位置待点验 | +| 2-3 | 进产品列表 / 详情 | 列表显示**产品库全部 20 只**(不再是 8 只);能筛选、能按 `?view=ranking` 排序 | ✅ 接口实测 `count=20`;`?view=ranking` 按 `change_pct` 排序、缺数据的排最后
⚠️ 渲染待点验 | | 2-4 | 点登录入口 | 进 `/portal/customer/login/` | ⚠️待点验 | -> **关于 mock**:访客三页用 `common/mock-data.js`,因为**公开产品 HTTP 接口尚未实现**。 -> 页面显著标注了来源 —— 这个标注**不能删**:删掉不会让数据变真,只会让客户以为看到的是真实净值。 +> **公开产品数据已接真实接口**(2026-09-13):`GET /api/v1/products`(编号 **P001**,见 `docs/05` §19), +> 产品与净值取自 `fin_product`、行情取自 `fin_market_price`。 +> 此前三页读的是 `common/mock-data.js` —— 那份数据只有 8 只,且**其中 6 只根本不在产品库里** +> (如把海富通的 `511360` 标成"南方短融ETF"),净值也是编的。该文件已删除。 +> +> **两条不能想当然的口径**(改前端前先读): +> 1. `change_pct` **可能是 `null`**(行情只同步过一个交易日时算不出涨跌)。 +> 页面必须显示"暂无"—— `formatPercent` 收到 `null` 会渲染成 `+0.00%`, +> 那等于告诉客户"今天平盘"。 +> 2. **历史净值走势图没有数据源**:`fin_nav_history` 目前 0 行,详情页**不画曲线**并显式说明。 +> 此前那条曲线是 mock 里 12 个编造点位 —— 走势图最容易被当成真数据。 ### 2.5 访客智能客服浮窗 ⭐ 本轮重点 @@ -230,10 +240,6 @@ python -m app.worker | 8-5 | 多标签 | 各页面独立。**注意**:`auth.js` 现在以 cookie 为权威(跨标签同步),所以**同一浏览器两个账号不能并存** ✅实测代码逻辑 | | 8-6 | 排版 | 文字不重叠、内容不溢出、表格行高稳定 ⚠️待点验 | | 8-7 | 缓存 | 产品列表/详情页的 CSS/JS 已带 `?v=20260913-4`;若改了资源仍看不到效果,请硬刷新 ⚠️待点验 | -| 8-8 | **`GET /api/v1/knowledge/list` 不套 `data` 信封** | 它直接返回 `{"items": [...], "count": N}`,而多数端点返回 `{"code":…, "data": {…}}`。按 `data.items` 解包会得到 0 条、看着像"库里没数据" ✅实测(联调时踩过一次,误判成接口故障) | -| 8-9 | 知识治理端点 | 上传是 `POST /api/v1/knowledge/upload`(**不是** `/documents`,用后者会 405);删除是 `DELETE /api/v1/knowledge/{id}`,语义是标记 `expired` + 投向量删除事件,**不是**物理删除 | -| 8-10 | **各端点的 `data` 形状不统一**(写联调脚本时别猜) | 实测:账户看板 `data.account` + `data.summary`、持仓 `data.holdings`、成交明细 `data.transactions`、资金流水 **`data.entries`**、角色列表 `data`(裸数组)、知识列表 `items`(**不套 `data`**)。我写全流程脚本时连续猜错三个字段名 ✅实测 | -| 8-11 | 创建类接口返回 **201** 而非 200 | 下单 `T002`、创建会话 `C001`、访客令牌 `V001` 都是 201。判"成功"要用 `status in (200,201)` ✅实测 | --- @@ -303,6 +309,8 @@ MIN_GAP = 0.07 # top1 领先次优的最小间隙 (保留 `search_knowledge` 给登录客户)。 6. **访客页的"演示数据"声明被删掉、但假数据还在渲染** —— 已恢复 `MOCK_SOURCE_NOTICE`、 两个页面的提示条与样式,并给 `link`/`script` 加版本参数(此前会被浏览器缓存)。 + **2026-09-13 已彻底解决**:公开产品接口(P001)落地后 `common/mock-data.js` 整体删除, + 页面改读真实数据 —— 只要数据是假的就总有人想删那条声明,接口接上才是根治。 7. **发布脚本会静默丢提示词** —— `publish_customer_service_config.py` 只继承 `platform_config_item`,把 `customer_service_chitchat` 提示词漏在了旧版本里。 已改用 `effective_snapshot()` 读全三张受管表、补提示词搬运与条数硬校验;丢失的提示词已按原文恢复。 diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 5cd45cf..39251a5 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -88,11 +88,11 @@ | 510500 | 中证500ETF南方 | SSE | ETF | R4 | 7.6027 | -1.79% | 2026-09-11 | | 511070 | 公司债ETF南方 | SSE | ETF | R2 | 103.0145 | +0.01% | 2026-09-11 | | 511810 | 货币ETF南方 | SSE | ETF | R1 | 0.2332 ⚠️ | 0.00% | 2026-09-13 | +| 515450 | 红利低波50ETF南方 | SSE | ETF | R3 | 1.4027 | -0.43% | 2026-09-11 | | 588890 | 科创芯片ETF南方 | SSE | ETF | R4 | 1.2327 | -1.36% | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | SZSE | LOF | R3 | 1.2514 | -1.34% | 2026-09-11 | | 160127 | 南方新兴消费增长股票(LOF)A | SZSE | LOF | R4 | 0.8364 | -0.64% | 2026-09-11 | | 160128 | 南方金利定开债券A | SZSE | LOF | R2 | 1.0260 | 0.00% | 2026-09-11 | -| 160129 | 南方金利定开债券C | SZSE | LOF | R2 | 1.0240 | 0.00% | 2026-09-11 | | 160142 | 南方优势产业(LOF) | SZSE | LOF | R3 | 1.0623 | -2.09% | 2026-09-11 | | 160143 | 南方创业板2年定期开放混合 | SZSE | LOF | R3 | 1.6105 | -1.14% | 2026-09-11 | | 501018 | 南方原油A | SSE | LOF | R5 | 2.0750 | +3.86% | 2026-09-10 | @@ -106,6 +106,30 @@ > (库里存 6 位小数,如 `0.916700`,数据源给 4 位 `0.9167`,是同一笔数), > **2 只不一致** —— `510300` 与 `511810`,见下方 3.1 与第四节。 +> **2026-09-13 变更:`160129` → `515450`** +> +> 原 `160129`(南方金利定开债券C)已从清单移除,替换为 **`515450` 红利低波50ETF南方** +> (SSE / ETF / R3,管理费 0.50%、托管费 0.10%,净值 1.4027)。 +> +> **为什么必须换**:`160129` 是 `160128`(金利定开债券 **A** 类)的 **C 类份额**, +> 而 C 类份额只在场外销售、**不在交易所挂牌** —— 行情源对它永远返回空,下单只能走 +> 净值降级(`source=eastmoney_nav_fallback`)。这既掩盖了"该产品并不交易"这一事实, +> 又让成交价带上折溢价偏差。**它本就不该出现在场内清单里。** +> +> **选型依据**:把南方基金全部 **882 个代码**逐个问过腾讯行情源(该源只对交易所上市 +> 证券返回数据),确认真实上市交易的只有 **109 个**;其中 R1/R2 的场内产品 +> (159700、160128、511070、511810)**原先已在清单内**,所以替换品只能来自 R3 及以上。 +> 选 `515450` 是因为它**仍属南方基金**、成交额约 1.3 亿元流动性充足、红利低波定位偏 +> 稳健,与它替换掉的债券 LOF 定位最接近;客户风险等级已覆盖 R3(原有 `510300` 即 R3)。 +> +> **处理方式**:复用 `fin_product.id = 9100005` 而非删除重建,使 `fin_holding` / +> `fin_sim_order` / `fin_transaction` / `fin_market_price` 的既有引用自动跟随, +> 不产生孤儿数据。那笔历史成交保留 `quote_source='eastmoney_nav_fallback'` —— +> 它确实是当时的事实,不该被粉饰。 +> +> 同时移出的还有 `hq.py` 的 `FUND_TYPE_GROUPS`(`160129` 原在"债券型"组) +> 与 `tools/import_hq_test_products.py` 的场内映射,两处都留了注释防止被加回来。 + 问:平台上有哪些场内基金?产品代码是什么? 答:本平台模拟交易范围内共有 20 只场内基金,包含 13 只 ETF 和 7 只 LOF, @@ -241,7 +265,6 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | @@ -250,6 +273,7 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | **0.25** | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | **规律**:宽基/债券 ETF 多为 0.15% 管理费,行业主题 ETF 多为 0.50%, diff --git a/docs/43-场内基金产品手册(知识库入库版).md b/docs/43-场内基金产品手册(知识库入库版).md index 7d06c09..29c50fc 100644 --- a/docs/43-场内基金产品手册(知识库入库版).md +++ b/docs/43-场内基金产品手册(知识库入库版).md @@ -86,11 +86,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 上交所 | ETF | R4 | 7.6027 | 2026-09-11 | | 511070 | 公司债ETF南方 | 上交所 | ETF | R2 | 103.0145 | 2026-09-11 | | 511810 | 货币ETF南方 | 上交所 | ETF | R1 | 0.2661 | 2026-09-11 | +| 515450 | 红利低波50ETF南方 | 上交所 | ETF | R3 | 1.4027 | 2026-09-11 | | 588890 | 科创芯片ETF南方 | 上交所 | ETF | R4 | 1.2327 | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | 深交所 | LOF | R3 | 1.2514 | 2026-09-11 | | 160127 | 南方新兴消费增长股票(LOF)A | 深交所 | LOF | R4 | 0.8364 | 2026-09-11 | | 160128 | 南方金利定开债券A | 深交所 | LOF | R2 | 1.0260 | 2026-09-11 | -| 160129 | 南方金利定开债券C | 深交所 | LOF | R2 | 1.0240 | 2026-09-11 | | 160142 | 南方优势产业(LOF) | 深交所 | LOF | R3 | 1.0623 | 2026-09-11 | | 160143 | 南方创业板2年定期开放混合 | 深交所 | LOF | R3 | 1.6105 | 2026-09-11 | | 501018 | 南方原油A | 上交所 | LOF | R5 | 2.0750 | 2026-09-10 | @@ -139,11 +139,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | 0.25 | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md new file mode 100644 index 0000000..f0f73e6 --- /dev/null +++ b/docs/44-演示流程.md @@ -0,0 +1,305 @@ +# 演示流程(照着走) + +> **读者**:负责演示的人。本文按"操作 → 看到什么 → 这体现什么"三段式写, +> 可以直接照着念。 +> **主线时长**:约 8 分钟;含讲解约 15 分钟。 +> **全部内容均已实测**(2026-09-13);标 ⚠️ 的地方是容易翻车的点。 + +--- + +## 0. 演示前准备 + +### 0.1 准备演示数据(首次、或换了机器才需要) + +```powershell +python tools/seed_demo_data.py +``` + +10 步:账号权限 → 演示口令 → 客户账户 → **场内行情** → 风控预警样本 → 投顾数据 → +三类发布配置 → 知识库素材。脚本会逐步打印完成/失败。 + +> ⚠️ 第 2 步**不是幂等的**:重跑等于重设密码(bcrypt 每次加盐不同)。 + +### 0.2 一条必须知道的硬规则:**行情有效期只有 15 分钟** + +下单要求行情快照落在 **15 分钟内**(`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`), +**系统没有任何自动刷新机制** —— 超时后**所有委托直接返回 `503 行情已过期`**。 + +演示时踩过这个坑:明明上午刷过行情,下午演示第一单就 503。 + +**怎么办**:`start.ps1` **已经会替你刷新一次**(见 0.3),所以正常不用管。 +万一演示中途下单报 503 —— 在新窗口跑一次就行,**不用重启任何服务**(已实测): + +```powershell +python tools/sync_market_prices.py +``` + +它按产品 upsert(幂等),几秒内把 20 只产品行情全部刷新,15 分钟窗口重新计时。 + +> 不想让启动脚本联网刷行情:`start.ps1 -SkipPriceSync` + +### 0.3 启动平台 + +```powershell +powershell -ExecutionPolicy Bypass -File start.ps1 +``` + +它按顺序做四件事:**找解释器 → 检查 MySQL/Redis/Milvus → 刷新行情 → 起两个窗口**,然后打印访问入口与账号。 +它会**开两个窗口**: + +| 窗口 | 作用 | 少了它会怎样 | +|---|---|---| +| API | 所有接口与页面 | 什么都没有 | +| **Worker** | Agent 对话、知识向量同步、记忆抽取、风控扫描 | 客服对话一直"超时";新知识不进 Milvus 且**无任何报错** | + +### 0.4 开场前自检(务必做) + +```powershell +python tools/e2e_smoke_test.py --read-only +``` + +看最后一行:**40/40 通过**才开始演示。有 FAIL 就按 §4 排查。 + +### 0.5 打开第一个页面 + + + +--- + +## 1. 主线场景 + +### 场景 1 · 访客咨询与知识边界(1.5 min) + +| | | +|---|---| +| **打开** | | +| **操作** | 点右下角客服浮窗 → 输入「场内基金的管理费率是多少」→ 发送 | +| **预期** | **约 3–4 秒**返回:本平台 20 只场内基金的管理费率 0.15%~1.20%/年,并列出 ETF 与 LOF 的差异 | + +**这体现什么**:客服不是"模型随便聊",而是**检索已发布知识后作答**,答案可追溯; +延迟稳定在 3–4 秒(一次意图分类 + 一次向量检索)。 + +**接着再问一句(体现安全边界)**:「我的账户里有多少钱?」 +→ 预期:回复"该服务需要登录后才能查询您的个人信息",**不编造账户数字**。 + +> 产品页的数据来自**真实接口**(`GET /api/v1/products`):产品与净值取自 `fin_product`, +> 行情由 `tools/sync_market_prices.py` 从行情源同步,页面底部有来源声明。 +> +> ⚠️ 两个**可能被问到、但其实不是故障**的地方: +> - 产品列表的「最近涨跌」列当前显示 **"暂无"** —— 当日涨跌要**两个交易日**的收盘价才算得出, +> 而行情库目前只有一个交易日。跑过第二次行情同步后自然会显示。我们**不编这个数**。 +> - 产品详情的**历史净值走势图显示"尚未接入"** —— `fin_nav_history` 目前是空的。 +> 此前那条曲线是演示数据里编的 12 个点位,已随接口接入一并去掉。 + +### 场景 2 · 客户资产(1 min) + +| | | +|---|---| +| **打开** | | +| **账号** | `cust_t` / `123456` | +| **预期** | 自动进资产总览:总资产约 10 万、可用资金、持仓市值(7 只持仓) | + +**这体现什么**:持仓按**真实行情**计价(不是写死的假数);页面底部有数据来源说明。 + +点左侧「我的持仓」「资金流水」「成交明细」各看一眼即可。 + +### 场景 3 · 客户下单(1.5 min)⭐ 重点 + +| | | +|---|---| +| **打开** | 客户页 → 交易记录 → 或直接用接口 | +| **说明** | 前端下单表单待完善,本场用接口演示更直观(见下) | + +```powershell +# 演示用:买入 100 份沪深300ETF +$body = '{"product_code":"510300","order_side":"buy","quantity":100}' +$r = Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/auth/tokens -Method Post ` + -Body '{"username":"cust_t","password":"123456"}' -ContentType application/json +Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/users/me/orders -Method Post ` + -Headers @{ Authorization = "Bearer $($r.data.access_token)"; "Idempotency-Key" = [guid]::NewGuid().ToString("N") } ` + -Body $body -ContentType application/json | ConvertTo-Json -Depth 4 +``` + +**预期**:`status=已成交`、`executed_price=4.579`(**真实行情价**)、 +成交金额 457.90、手续费 0.05、实际扣款 457.95(`net_amount`)。 + +**这体现什么**: +- **成交价来自外部行情**,不是模拟随机数; +- 下单前会做**适当性校验**(风险等级匹配),不匹配直接拒绝; +- 成交后持仓、资金流水、成交明细**三处同时落账**(可当场刷新场景 2 的页面看变化)。 + +> ⚠️ 如果返回 `503 FUND_QUOTE_UNAVAILABLE` → 跑 §0.2 的行情同步。 + +### 场景 4 · 客服对话与转人工(1 min) + +| | | +|---|---| +| **操作** | 客户页右下角浮窗 → 问「基金定投是什么」→ 点「转人工客服」 | +| **预期** | 先得到知识库答案;转人工返回 `202`,工单进入管理员队列 | + +**这体现什么**:客户**主动**请求人工与"答不上来"是两条不同路径 —— 前者建工单, +后者按既定口径引导拨打客服热线(**不建工单**,符合我们"答不了就转人工、不硬答"的原则)。 + +### 场景 5 · 风控预警处置(2 min)⭐ 重点 + +| | | +|---|---| +| **打开** | → `risk_t` / `666666` | +| **预期** | 概览:未闭环预警数、高风险数、待处理、已超时 | + +**操作顺序(这是核心闭环)**: + +1. 点预警队列里的某条 → 打开详情; +2. 详情里有**八类证据**入口:客户、产品、交易、资金流水、持仓、登录记录、预警、通知; +3. 点「确认接收」→ 状态变为已确认; +4. 点「进入调查」→ 状态变为调查中; +5. 点「完成结案」→ 状态变为**已结案**,并且**按风险等级扣减客户行为分**。 + +**这体现什么**:预警处置是**有状态机、有留痕、有连带影响**的 —— +不是改个字段。每一步都记审计(谁、何时、做了什么)。 + +> 若队列里没有「待处理」的预警(都已被处置过),跑 +> `python tools/e2e_smoke_test.py` —— 它会自动造一条待处理预警并跑完整闭环。 + +**补一步「风控助手」(30 秒,很值得演)**:点上方「风控助手」标签 → 点预设的 +「当前风险概览」按钮 → Agent 调 `get_risk_overview` **只读工具**返回未闭环预警概览, +结尾并声明"以上仅为查询与复核线索,不构成任何已确认、误报、关闭或升级的处置结论, +最终请由风控专员人工复核并留痕"。 + +**这体现什么**:Agent 只做**只读查询与研判草案**,处置动作一律留给人工。 +实测这条问法命中的是 `risk_overview` 意图、置信度 **1.0000** —— +是配置好的意图分派,不是让模型自由发挥。 + +### 场景 6 · 风控日报(1 min) + +| | | +|---|---| +| **操作** | 风控工作台 → 日报 → 生成 | +| **预期** | 文本**流式**逐段出现(SSE),完成后可填多个邮箱发送 | + +**这体现什么**:长任务走 SSE 流式,而不是让页面转圈等 30 秒。 + +### 场景 7 · 投顾工作台(1 min) + +| | | +|---|---| +| **打开** | | +| **账号** | `advisor_t` / `abc12345` | +| **预期** | 一张「投资目标方案书 · 客户 9001」卡片,含发布时间与方案内容 | + +**这体现什么**:投顾看到的是**自己服务的客户**的已发布交付物。 +数据范围按 `sys_customer_assignment` 归属关系判定(不是按 `data_scope` —— +那会把 all 级权限放大成"看全部客户")。 + +> 方案书的**审核与发布都要求管理员**(`admin=True`),投顾自己发不出来。 +> 这是有意的复核环节,不是缺陷。 + +### 场景 8 · 管理员治理(1.5 min) + +| | | +|---|---| +| **打开** | | +| **账号** | `admin_t` / `88888888` | +| **预期** | 四个指标卡 + 六个标签页 | + +**挨个点一遍,每处一句话**: + +| 标签页 | 讲解要点 | +|---|---| +| 角色权限 | 五个角色,权限数分别是 admin 59 / advisor 28 / customer 26 / risk_operator 10 / operator 2 | +| 配置发布 | 工具白名单、提示词**走发布状态机**(校验→审核→激活),不是改配置文件 | +| 审计 | 每一次权限判定、工具调用都有记录 | +| 转人工工单 | 场景 4 建的工单在这里;**不返回客户标识与原始对话**(脱敏) | +| 模型端点 | 当前用 `deepseek-flash`(意图分类)+ `qwen` 向量(embedding) | + +**这体现什么**:**权限变更必须过审核流程并留痕** —— 这是金融场景的硬要求, +所以平台刻意**不提供**"直接改权限"的接口。 + +--- + +## 2. 备选场景(时间充足时) + +| 场景 | 怎么演 | 体现什么 | +|---|---|---| +| 配置发布四态 | 管理员工作台建一个草稿版本 → 提交校验 → 审核 → 激活 | 改配置是走流程的,可回滚 | +| 五角色权限对比 | 用五个账号分别登录,看入口守卫各跳哪里 | 越权在**前端就被挡**,服务端还有一层 | +| 知识入库 | `python tools/seed_knowledge_demo.py` 后复测场景 1 | 知识是**可运营**的,不是写死的 | +| 平台体检 | `python tools/e2e_smoke_test.py` | 40 项全绿,交付质量可自证 | + +--- + +## 3. 可能被问到的问题(建议话术) + +**Q:这是真实行情吗?** +A:行情取自公开数据源(腾讯行情),成交价用的就是它。但**交易本身是模拟的** —— +平台是"场内基金模拟交易",不下真实单。 + +**Q:Agent 会不会乱答?** +A:不会。客服只回答**检索到的已发布知识**,且有置信度门槛: +命中分数 ≥0.75 直接答;0.55~0.75 之间还要求领先次优 ≥0.07; +不满足就引导人工。**宁可转人工也不答错** —— 这是刻意的取舍。 + +**Q:为什么有些问题答不上来?** +A:知识库是人工审核入库的,目前覆盖交易规则、20 只产品、费率与常见问答。 +问不到的地方会转人工,**这是设计而不是故障**。 + +**Q:数据安全怎么保证?** +A:三层 —— 前端按权限隐藏入口;服务端每次请求**重新解析权限与数据范围**(令牌里不带权限); +工单等管理面接口**不返回客户标识与原始对话**。所有判定都有审计。 + +**Q:为什么不让管理员直接改权限?** +A:金融场景要求权限变更留痕可追溯,所以统一走配置发布流程(草稿→校验→审核→激活)。 + +--- + +## 4. 出问题怎么办 + +| 现象 | 原因 | 怎么办 | +|---|---|---| +| 客户下单返回 `503 行情已过期` | 行情快照过期(**有效期仅 15 分钟**) | `python tools/sync_market_prices.py` —— **立即生效,无需重启服务** | +| 客服一直"繁忙/超时" | **Worker 没在跑** | 看 `start.ps1` 起的第二个窗口;或 `python -m app.worker` | +| 改了页面看不到效果 | 浏览器缓存 | `Ctrl+F5` 硬刷新 | +| 知识库问答答不上 | 向量没同步 | 确认 Worker 在跑,然后跑 `tools/seed_knowledge_demo.py` | +| 平台起不来 | MySQL 没起 | 看 `start.ps1` 的依赖检查输出 | +| 风控队列没有"待处理"预警 | 演示样本都被处置过了 | `python tools/e2e_smoke_test.py`(自动造一条) | + +**一条命令定位**: + +```powershell +python tools/e2e_smoke_test.py +``` + +它逐条打印 6 条线 40 项的结果,FAIL 的那条就是问题所在。 + +--- + +## 5. 速查 + +**账号** + +| 角色 | 用户名 | 密码 | 登录入口 | +|---|---|---|---| +| 客户 | `cust_t` | `123456` | `/portal/customer/login/` | +| 风控专员 | `risk_t` | `666666` | `/portal/employee-console/login/` | +| 管理员 | `admin_t` | `88888888` | 同上 | +| 投顾 | `advisor_t` | `abc12345` | 同上(→ 投顾工作台) | +| 运营 | `offsite_t` | `offsite123` | 同上(→ 运营工作台) | + +**常用命令** + +```powershell +python tools/seed_demo_data.py # 准备演示数据(10 步,首次/换机器) +python tools/sync_market_prices.py # 刷行情(15 分钟有效期,下单 503 时补跑) +powershell -ExecutionPolicy Bypass -File start.ps1 # 起 API + Worker(自动刷行情) +python tools/e2e_smoke_test.py # 全量体检(40 项) +python tools/e2e_smoke_test.py --read-only # 只读体检,不动数据 +``` + +**已知的空数据**(演示时别点进去尴尬) + +| 位置 | 现状 | +|---|---| +| 运营工作台 | 场外邮件 0 条、邮箱未初始化(需要真实邮件源) | +| 管理员 · 画像候选 | 0 条(需要客户对话触发画像抽取) | + +**端口**:API `8000` / MySQL `3306` / Redis `6379` / Milvus `19530` diff --git a/hq.py b/hq.py index ee371c9..a5b97e4 100644 --- a/hq.py +++ b/hq.py @@ -33,12 +33,15 @@ EXCHANGE_HISTORY_MIN_INTERVAL_SECONDS = 1.0 MAX_FUNDS_PER_CALL = 1000 FUND_TYPE_GROUPS = { "货币型": ("202308", "020480", "511810"), - "债券型": ("007161", "003776", "020281", "511070", "159700", "160128", "160129"), + # ⚠️ 此处曾含 `160129`(南方金利定开债券C),已移除:C 类份额只在场外销售、 + # **不在交易所挂牌**,行情源对它永远返回空(下单因此走净值降级)。 + # 它的 A 类 `160128` 是上市交易的,已在本组内。 + "债券型": ("007161", "003776", "020281", "511070", "159700", "160128"), "混合型": ("018019", "014189", "018020", "160105", "160142", "160143", "501062"), "股票型": ( "020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", - "001421", "510500", + "001421", "510500", "515450", ), "QDII": ("501018", "159329", "159615", "159687"), } diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..0f6d981 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,167 @@ +<# +.SYNOPSIS + 启动金融 Agent 平台:依赖检查 → API → Agent Worker。 + +.NOTES + ⚠️ 本文件必须保存为 **UTF-8 with BOM**。 + Windows PowerShell 5.1 在缺少 BOM 时按系统 ANSI 解析脚本(简体中文下是 GBK), + 中文注释与输出会变成乱码并直接抛语法错误(实测报 `Unexpected token '[璀﹀憡]'`)。 + 用编辑器改完本文件后,务必另存为「UTF-8 带 BOM」。 + +.DESCRIPTION + 平台需要**两个进程**才能完整工作,本脚本把它们一起起在独立窗口里: + + · API —— 所有 HTTP 接口与前端页面(/portal/) + · Worker —— 消费队列:Agent 对话、知识向量同步、记忆抽取、风控扫描 + + **没有 Worker 的后果**(这也是最容易踩的坑): + · 客服对话会一直停在 queued,前端显示"客服繁忙/超时"; + · 新灌的知识写不进 Milvus,客服照旧答不上,且**没有任何报错**。 + +.PARAMETER Port + API 监听端口,默认 8000。 + +.PARAMETER SkipChecks + 跳过依赖端口检查(MySQL / Redis / Milvus)。 + +.PARAMETER ApiOnly + 只起 API,不起 Worker(仅在明确不需要 Agent 能力时使用)。 + +.PARAMETER SkipPriceSync + 跳过启动时的行情刷新。仅在外部数据源不可用、或不想联网时使用。 + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File start.ps1 + powershell -ExecutionPolicy Bypass -File start.ps1 -Port 8100 +#> +param( + [int]$Port = 8000, + [switch]$SkipChecks, + [switch]$ApiOnly, + [switch]$SkipPriceSync +) + +$ErrorActionPreference = "Stop" +$Root = $PSScriptRoot +Set-Location $Root + +Write-Host "=== 金融 Agent 平台 · 启动 ===" -ForegroundColor Cyan +Write-Host "工作目录:$Root" + +# ---------------------------------------------------------------- 解释器 +# 逐个尝试:项目虚拟环境 → 常见 conda 环境 → PATH 上的 python。 +# 不写死任何一个,因为各人机器的环境不同(.venv 被 gitignore,不进仓库)。 +$candidates = @( + (Join-Path $Root ".venv\Scripts\python.exe"), + "D:\conda\envs\jr_py313\python.exe", + "python" +) +$Python = $null +foreach ($candidate in $candidates) { + try { + if ($candidate -eq "python") { + $version = & python --version 2>&1 + } else { + if (-not (Test-Path $candidate)) { continue } + $version = & $candidate --version 2>&1 + } + if ($LASTEXITCODE -eq 0) { + $Python = $candidate + Write-Host "解释器:$candidate($version)" -ForegroundColor Green + break + } + } catch { continue } +} +if (-not $Python) { + Write-Host "[失败] 找不到可用的 Python。请先建虚拟环境,或把解释器路径加进本脚本的候选列表。" -ForegroundColor Red + exit 1 +} + +# ---------------------------------------------------------------- 依赖检查 +if (-not $SkipChecks) { + Write-Host "`n--- 依赖服务检查 ---" -ForegroundColor Cyan + $services = @( + @{ Name = "MySQL"; HostName = "127.0.0.1"; Port = 3306; Required = $true }, + @{ Name = "Redis"; HostName = "127.0.0.1"; Port = 6379; Required = $false }, + @{ Name = "Milvus"; HostName = "127.0.0.1"; Port = 19530; Required = $false } + ) + foreach ($service in $services) { + $reachable = $false + try { + $reachable = (Test-NetConnection -ComputerName $service.HostName -Port $service.Port ` + -InformationLevel Quiet -WarningAction SilentlyContinue) + } catch { $reachable = $false } + + if ($reachable) { + Write-Host (" [OK] {0,-7} {1}:{2}" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Green + } elseif ($service.Required) { + Write-Host (" [缺失] {0,-7} {1}:{2} —— 平台起不来,请先启动它" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Red + exit 1 + } else { + Write-Host (" [警告] {0,-7} {1}:{2} 不可达:相关知识检索会降级、Docker 未运行时常见" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Yellow + } + } +} + +# ---------------------------------------------------------------- 刷新行情 +# ⚠️ 为什么放在启动流程里:下单要求行情快照落在 **15 分钟**有效期内 +# (`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`),超时后**所有委托直接 503 +# 「行情已过期」**,而系统**没有任何自动刷新机制** —— 演示时讲到一半下单就会失败。 +# 所以默认在启动时刷一次:15 分钟窗口从此刻重新计时。 +# 同步是按产品 upsert 的幂等操作;失败只警告不阻断(仍可手动重跑)。 +if (-not $SkipPriceSync) { + Write-Host "`n--- 刷新行情(下单前置,有效期 15 分钟)---" -ForegroundColor Cyan + $syncOutput = & $Python "tools\sync_market_prices.py" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " [OK] 行情已刷新,15 分钟内可正常下单" -ForegroundColor Green + } else { + Write-Host " [警告] 行情刷新失败(退出码 $LASTEXITCODE):下单可能返回 503" -ForegroundColor Yellow + Write-Host " 可手动重跑:$Python tools\sync_market_prices.py" -ForegroundColor Yellow + $syncOutput | Select-Object -Last 8 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } +} + +# ---------------------------------------------------------------- 启动进程 +# 用独立窗口起,方便分别看两边的日志(Worker 的日志是排查问题的第一现场)。 +function Start-PlatformWindow { + param([string]$Title, [string[]]$Arguments) + $shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" } + $command = "`$host.UI.RawUI.WindowTitle = '$Title'; & '$Python' " + ($Arguments -join " ") + Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command | Out-Null +} + +Write-Host "`n--- 启动进程 ---" -ForegroundColor Cyan +Start-PlatformWindow -Title "平台 API :$Port" -Arguments @( + "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$Port" +) +Write-Host " [已启动] API 窗口(uvicorn,端口 $Port)" -ForegroundColor Green + +if (-not $ApiOnly) { + Start-PlatformWindow -Title "平台 Agent Worker" -Arguments @("-m", "app.worker") + Write-Host " [已启动] Worker 窗口(Agent 对话 / 知识向量 / 记忆抽取)" -ForegroundColor Green +} else { + Write-Host " [跳过] Worker(-ApiOnly):客服对话会一直 queued、新知识不会进 Milvus" -ForegroundColor Yellow +} + +# ---------------------------------------------------------------- 访问入口 +Start-Sleep -Seconds 3 +Write-Host "`n=== 访问入口 ===" -ForegroundColor Cyan +Write-Host " 门户首页 http://127.0.0.1:$Port/portal/" +Write-Host " 访客页 http://127.0.0.1:$Port/portal/guest/home/" +Write-Host " 客户登录 http://127.0.0.1:$Port/portal/customer/login/ (cust_t / 123456)" +Write-Host " 员工登录 http://127.0.0.1:$Port/portal/employee-console/login/(见下)" +Write-Host " 接口文档 http://127.0.0.1:$Port/docs" + +Write-Host "`n=== 演示账号 ===" -ForegroundColor Cyan +Write-Host " 客户 cust_t / 123456" +Write-Host " 风控专员 risk_t / 666666" +Write-Host " 管理员 admin_t / 88888888" +Write-Host " 投顾 advisor_t / abc12345" +Write-Host " 运营 offsite_t / offsite123" + +Write-Host "`n提示:" -ForegroundColor Yellow +Write-Host " · 首次使用先准备数据:python tools/seed_demo_data.py" +Write-Host " · 行情有效期只有 15 分钟:演示中途下单若报 503,在新窗口重跑" +Write-Host " python tools/sync_market_prices.py (立即生效,无需重启服务)" +Write-Host " · 体检:python tools/e2e_smoke_test.py" +Write-Host " · 演示流程见 docs/44-演示流程.md" diff --git a/tests/contract/test_public_products_endpoint_contract.py b/tests/contract/test_public_products_endpoint_contract.py new file mode 100644 index 0000000..c82d704 --- /dev/null +++ b/tests/contract/test_public_products_endpoint_contract.py @@ -0,0 +1,65 @@ +"""P001(公开产品列表)Controller 路由契约测试。 + +不连数据库。覆盖: + +- 路由**已注册**且鉴权生效:缺 token 时是 `401`(鉴权层拦下)而不是 `404`(漏注册); +- 路径与 `docs/05` §19 的 P001 一致,避免注册漂移。 + +⚠️ **"访客令牌能读到数据"不在本文件测** —— 那要连数据库,放在 +`tests/integration/test_public_products_endpoint_mysql.py`。这一条必须有人守: +访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`), +如果谁给 P001 加上权限码校验,整个访客产品页会 401,而前端只会显示 +"数据暂时不可用",很难联想到是权限口径问题。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from app.main import create_app + +PRODUCTS_PATH = "/api/v1/products" + + +async def send(method: str, path: str) -> httpx.Response: + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.request(method, path) + + +async def test_products_endpoint_is_registered_and_protected() -> None: + """路由必须存在;缺 token 时 401 而不是 404。""" + response = await send("GET", PRODUCTS_PATH) + assert response.status_code == 401, ( + f"GET {PRODUCTS_PATH} 未授权应 401,实际 {response.status_code}(路由可能漏注册)" + ) + + +async def test_products_endpoint_uses_documented_error_envelope() -> None: + """未授权响应要用统一错误信封,调用方能按同一套结构解析。""" + response = await send("GET", PRODUCTS_PATH) + payload = response.json() + assert payload["error"]["code"] == "AUTHENTICATION_REQUIRED" + assert "meta" in payload + + +async def test_invalid_token_is_rejected() -> None: + """伪造令牌同样是 401,不能因为"只要求令牌"就放行。""" + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + PRODUCTS_PATH, headers={"Authorization": "Bearer not-a-real-token"} + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"]) +async def test_products_endpoint_is_read_only(method: str) -> None: + """只读端点:写方法不应落到同一个路径上(产品数据只能由同步/种子流程写入)。""" + response = await send(method, PRODUCTS_PATH) + assert response.status_code in (401, 405), ( + f"{method} {PRODUCTS_PATH} -> {response.status_code},只读端点不应接受写方法" + ) diff --git a/tests/integration/test_intent_config_runtime_mysql.py b/tests/integration/test_intent_config_runtime_mysql.py index 91d8901..6020df9 100644 --- a/tests/integration/test_intent_config_runtime_mysql.py +++ b/tests/integration/test_intent_config_runtime_mysql.py @@ -114,3 +114,62 @@ async def test_activation_state_machine_feeds_runtime_intent_config() -> None: {"agent": agent_type}, ) await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor}) + + +async def test_activating_one_intent_does_not_archive_sibling_intents() -> None: + """激活一个意图**不得**归档同 Agent 的其他意图。 + + 唯一键是生成列 `active_key = concat(agent_type, ':', intent_code)`, + 即同一 `agent_type` 下**不同意图码本就允许并存**(风控的 4 个意图就是并存的)。 + + 这里曾经按 `agent_type` 过滤旧 active 版本去归档,于是激活 `general` 会把 + `risk_overview` / `risk_search` / `risk_evidence` 一并归档:风控运行期只剩 1 条 + active 意图,问"查看当前风险概览"被分到 `general`,**且没有任何报错**。 + + 上一个用例恰好盖不住这个缺陷 —— 它建的第二个意图始终停留在 draft、从未激活过。 + """ + agent_type = f"it_intent_multi_{uuid.uuid4().hex[:12]}" + actor = uuid.uuid4().int % 10**12 + 10**15 + async with SessionFactory() as session, session.begin(): + await session.execute(text(""" + INSERT INTO sys_user + (id,user_no,username,password_hash,user_type,professional_investor_status, + fund_account_status,status,created_at,updated_at) + VALUES (:id,:name,:name,'test-only','员工','未申请','未开户','正常', + UTC_TIMESTAMP(),UTC_TIMESTAMP()) + """), {"id": actor, "name": f"it-intent-multi-{actor}"}) + try: + context = RequestContext( + user_id=str(actor), trace_id="it-intent-multi", roles=("admin",) + ) + service = AdminService() + # 两个**不同意图码**依次走完 draft -> approved -> active + for code in ("alpha", "beta"): + async with SessionFactory() as session: + repo = PlatformRepository(session) + created = await repo.create("agent_intent_config", { + "agent_type": agent_type, "intent_code": code, + "intent_name": f"{code} 意图", "description": f"{code} 描述", + "examples": [f"{code} 示例"], + "confidence_threshold": Decimal("0.6500"), "version": 1, + "created_by": actor, + }) + approved = await service._transition( + repo, "agent-intent-configs", created, "reviews", + {"decision": "approved"}, context + ) + await service._transition( + repo, "agent-intent-configs", approved, "activations", {}, context + ) + await session.commit() + + # 关键断言:激活 beta 之后 alpha **仍然 active**,两个都要能被运行期读到 + entries = await load_active_intent_configs(agent_type) + assert sorted(entry.intent_code for entry in entries) == ["alpha", "beta"] + finally: + async with SessionFactory() as session, session.begin(): + await session.execute( + text("DELETE FROM agent_intent_config WHERE agent_type=:agent"), + {"agent": agent_type}, + ) + await session.execute(text("DELETE FROM sys_user WHERE id=:id"), {"id": actor}) diff --git a/tests/integration/test_public_products_endpoint_mysql.py b/tests/integration/test_public_products_endpoint_mysql.py new file mode 100644 index 0000000..0b1c2cc --- /dev/null +++ b/tests/integration/test_public_products_endpoint_mysql.py @@ -0,0 +1,75 @@ +"""真实 MySQL:访客令牌必须能读到公开产品列表。 + +## 为什么单独守这一条 + +访客令牌**不带任何权限码**(`app/core/security.py` 只给它 `roles=("visitor",)`), +P001 靠的是"要求令牌但不校验权限"的口径。若有人照管理面接口的样子给它加上权限码, +访客产品页(首页推荐、产品列表、产品详情)会**整体 401**, +而前端只会显示"数据暂时不可用",极难联想到是权限口径问题。 + +本文件同时守住**载荷边界**:公开端点不得混入任何账户/客户字段。 +""" + +from __future__ import annotations + +import httpx +import pytest + +from app.main import create_app + +pytestmark = pytest.mark.integration + +#: 公开端点绝不能出现的字段(账户与客户数据) +FORBIDDEN_FIELDS = { + "customer_id", "account_id", "trade_account", "available_cash", + "total_asset", "total_quantity", "average_cost", "cost_amount", +} + +#: 前端三个访客页面直接消费的字段 +REQUIRED_FIELDS = { + "product_code", "product_name", "exchange_code", "product_category", "risk_level", + "fund_manager", "current_nav", "current_nav_at", "status", "lot_size", "price_tick", + "management_fee_rate", "custodian_fee_rate", "latest_close", "latest_trade_date", + "quote_source", "change_pct", +} + + +async def test_visitor_token_can_read_listed_products() -> None: + app = create_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", timeout=30 + ) as client: + issued = await client.post("/api/v1/visitor-tokens") + # 该端点按创建语义返回 201(本平台创建类端点的统一口径) + assert issued.status_code in (200, 201), issued.text + # 访客令牌端点是 `raw` 形状:令牌直接在顶层,没有 data 信封 + token = issued.json()["access_token"] + + response = await client.get( + "/api/v1/products", headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 200, response.text + data = response.json()["data"] + products = data["products"] + + assert products, "产品列表为空:访客页面会显示不出任何基金" + assert data["count"] == len(products) + + # 只暴露在售产品 + assert all(item["status"] == "上市" for item in products) + + first = products[0] + assert REQUIRED_FIELDS <= set(first), f"缺字段:{REQUIRED_FIELDS - set(first)}" + assert not (FORBIDDEN_FIELDS & set(first)), "公开端点混入了账户/客户字段" + + # change_pct 只能是数字或 None。None 表示行情不足两个交易日、算不出涨跌, + # **不是 0** —— 前端对 None 显示"暂无",对 0 会显示 "+0.00%"(等于说今天平盘)。 + for item in products: + assert item["change_pct"] is None or isinstance(item["change_pct"], (int, float)), ( + f"{item['product_code']} 的 change_pct 类型异常:{item['change_pct']!r}" + ) + + # 价格类字段一律是字符串(与既有接口口径一致) + assert isinstance(first["current_nav"], str) diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 8b93847..fd956bf 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -75,6 +75,21 @@ def test_api_client_registers_all_trading_endpoint_ids() -> None: assert f"{endpoint_id}:" in source +def test_product_detail_preserves_customer_session_for_trade_entry() -> None: + html = (PORTAL / "guest" / "product-detail" / "index.html").read_text(encoding="utf-8") + source = (PORTAL / "guest" / "product-detail" / "product-detail.js").read_text( + encoding="utf-8" + ) + dashboard = (PORTAL / "customer" / "dashboard" / "dashboard.js").read_text( + encoding="utf-8" + ) + assert 'data-trade-action' in html + assert "getAuthContext" in source + assert "textContent = '进入交易'" in source + assert "action=trade" in source + assert "productInput.value = productCode" in dashboard + + def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") for endpoint_id in ( @@ -86,6 +101,20 @@ def test_api_client_registers_onboarding_risk_and_admin_endpoints() -> None: assert f"{endpoint_id}:" in source +def test_advisor_workspace_registers_documented_operation_endpoints() -> None: + source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") + dashboard = (PORTAL / "employee-advisor" / "dashboard" / "index.html").read_text( + encoding="utf-8" + ) + for endpoint_id in ( + "ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS", + "ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL", + ): + assert f"{endpoint_id}:" in source + for label in ("组合分析", "资产配置", "生成推荐草案", "录入客户目标"): + assert label in dashboard + + def test_risk_scan_endpoint_uses_extended_timeout() -> None: source = (PORTAL / "common" / "api-client.js").read_text(encoding="utf-8") assert ( diff --git a/tools/check_rbac_seed_consistency.py b/tools/check_rbac_seed_consistency.py index d3092c0..ab16b03 100644 --- a/tools/check_rbac_seed_consistency.py +++ b/tools/check_rbac_seed_consistency.py @@ -18,14 +18,15 @@ DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self` 变成 `investment-goal:write:self`),全程不报错。 -本脚本把这条约束变成可自动检查的四条: +本脚本把这条约束变成可自动检查的五条: 1. 种子内 id 不重复; 2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条; 3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在; -4. 各 `grant_*.py` 之间不抢同一个 id。 +4. 各 `grant_*.py` 之间不抢同一个 id; +5. 每行 `PERMISSIONS` 的字段数与 `data_scope` 取值合法(见 `_scope_findings`)。 -`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这四条是纳入门禁的。 +`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这五条是纳入门禁的。 用法:python tools/check_rbac_seed_consistency.py """ @@ -117,9 +118,51 @@ def collect_findings() -> list[str]: + ", ".join(sorted(missing_risk_codes)) ) + problems.extend(_scope_findings(seed)) + return problems +#: `IdentityRepository.load_context` 只收集这三种 `data_scope` 的权限,其余**整条丢弃**。 +VALID_DATA_SCOPES = frozenset({"self", "own_customers", "all"}) + + +def _scope_findings(seed: ModuleType) -> list[str]: + """第 5 条:每行 `PERMISSIONS` 的字段数与 `data_scope` 取值都必须合法。 + + ## 为什么值得单独守 + + `PERMISSIONS` 的行形状是 `(id, 权限码, resource, action, data_scope)`。 + 2026-09-13 出过一次事故:交易那 4 条写成了 `(9061, "trade:order:create", + "trade", "order", "create")` —— 「resource, action」多写了一段, + 于是 action 落进了 `data_scope`。 + + 它**不报错、也不放宽**,而是**静默失效**:`load_context` 里 + `if row["permission_code"] and row["data_scope"] in rank` 会把整条权限丢掉, + 客户在库里"明明有"这四个权限,下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`; + 而同一批里 `account:read:self`、`holding:read:self` 的 scope 是 `self`, + 照常 200 —— 所以现象特别像"只有交易坏了",几乎不会有人去怀疑权限行本身写错。 + + 因为 `seed_test_rbac.py` 是 DELETE 重建语义,这个错误**每跑一次种子就重现一次**。 + """ + findings: list[str] = [] + for index, row in enumerate(seed.PERMISSIONS, 1): + if len(row) != 5: + findings.append( + f"种子 PERMISSIONS 第 {index} 行有 {len(row)} 个字段,应为 5 个" + f"(id, 权限码, resource, action, data_scope):{row!r}" + ) + continue + permission_id, code, scope = int(row[0]), str(row[1]), str(row[4]) + if scope not in VALID_DATA_SCOPES: + findings.append( + f"种子 id {permission_id}({code})的 data_scope={scope!r} 非法:" + f"只能是 {sorted(VALID_DATA_SCOPES)} 之一 —— " + "否则该权限会被 IdentityRepository.load_context 静默丢弃,接口一律 403" + ) + return findings + + def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] diff --git a/tools/e2e_smoke_test.py b/tools/e2e_smoke_test.py new file mode 100644 index 0000000..475ddf8 --- /dev/null +++ b/tools/e2e_smoke_test.py @@ -0,0 +1,439 @@ +"""平台端到端冒烟:按角色分线跑一遍真实请求,报告哪一环坏了。 + +## 为什么要有它 + +`docs/40` 的验收清单是**人工步骤 + 预期值**;本脚本把这些步骤里"能用接口判定"的部分 +自动化,用于**部署后、演示前**快速确认底座是活的。它不做断言式测试(那是 pytest 的活), +而是**打真实 HTTP、打印每条的结果**,让人一眼看到哪条线断了。 + +## 覆盖范围(6 条线) + + A 访客 令牌 → 提问 → 收到回答 + B 客户 登录 → 看板/持仓/流水 → **真实下单成交** → 会话与转人工 + C 风控 概览/队列/详情/**八类证据**/通知/扫描/日报 → **处置闭环**(确认→调查→结案) + D 投顾 已发布方案 + E 运营 场外邮件与邮箱状态 + F 管理员 角色/权限/身份/配置发布/模型端点/审计/转人工工单/画像候选 + +## 前置(缺一项就有整条线是红的) + + python tools/seed_test_rbac.py # 账号与权限 + python tools/set_user_password.py # 演示口令(非幂等) + python -m tools.seed_sim_account_demo # 虚拟资金账户 + python tools/publish_risk_agent_config.py # 风控工具白名单 + python tools/sync_market_prices.py # **下单前置**:没有行情会全线 503 + python -m app.worker # **客服对话前置**:没有它 run 永远 queued + +## 用法 + + python tools/e2e_smoke_test.py # 全跑(含写操作:下单、风控处置) + python tools/e2e_smoke_test.py --read-only # 跳过所有写操作,只看读链路 + +写操作都会带唯一 `Idempotency-Key`;风控处置会在需要时**自动造一条待处理预警** +(否则该环节只能跳过 —— 库里的预警常常都已被处置过)。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.error +import urllib.request +import uuid +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from sqlalchemy import func, select # noqa: E402 + +from app.infrastructure.db import SessionFactory # noqa: E402 +from app.model.fund import FundRiskAlert # noqa: E402 + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +BASE = "http://127.0.0.1:8000" +DEMO_CUSTOMER = ("cust_t", "123456") +DEMO_RISK = ("risk_t", "666666") +DEMO_ADVISOR = ("advisor_t", "abc12345") +DEMO_OPERATOR = ("offsite_t", "offsite123") +DEMO_ADMIN = ("admin_t", "88888888") + +RESULTS: list[tuple[str, bool, str]] = [] +READ_ONLY = False + + +def call( + method: str, + path: str, + token: str | None = None, + body: dict[str, Any] | None = None, + *, + idem: bool = False, + timeout: int = 90, +) -> tuple[int, dict[str, Any]]: + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request(BASE + path, data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/json") + if token: + request.add_header("Authorization", "Bearer " + token) + if idem: + request.add_header("Idempotency-Key", uuid.uuid4().hex) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + return response.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + return exc.code, json.loads(raw) + except ValueError: + return exc.code, {"_raw": raw[:250]} + except Exception as exc: # noqa: BLE001 - 网络层失败也要报出来,而不是崩掉整轮 + return 0, {"_err": repr(exc)} + + +def check(label: str, ok: bool, detail: str = "") -> bool: + RESULTS.append((label, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {label}" + (f" —— {detail}" if detail else "")) + return ok + + +def info(label: str, detail: str) -> None: + print(f"INFO {label} —— {detail}") + + +def section(title: str) -> None: + print() + print("=" * 96) + print(title) + print("=" * 96) + + +def body_of(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get("data") + return data if isinstance(data, dict) else {} + + +def listed(payload: dict[str, Any], *keys: str) -> list[Any]: + """从响应里取列表。 + + ⚠️ 本平台各端点的 `data` 形状**不统一**:角色列表是裸数组、持仓在 `holdings`、 + 成交明细在 `transactions`、**资金流水在 `entries`**。所以这里按候选键依次尝试 —— + 写联调脚本时别猜字段名,会连续猜错(实测踩过三次)。 + """ + data = payload.get("data") + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if isinstance(value, list): + return value + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + +def ok_code(payload: dict[str, Any]) -> bool: + return payload.get("code") in (0, None) + + +def login(credentials: tuple[str, str]) -> str | None: + status, payload = call( + "POST", "/api/v1/auth/tokens", + body={"username": credentials[0], "password": credentials[1]}, + ) + return body_of(payload).get("access_token") if status == 200 else None + + +def create_pending_alert() -> str | None: + """造一条「待处理」预警,供处置闭环验证(库里的常常都已被处置过)。""" + + async def run() -> str: + # 前缀**刻意避开 `ALDEMO`**:那是 `seed_risk_alert_demo_data.py` 的演示样本编号 + # (ALDEMO0001-0003)。撞号过一次 —— 本脚本"已存在则跳过"的检查会把创建挡掉, + # 随后处置全 409,看起来像"结案没写 closed_at",其实是操作了别人的样本。 + alert_no = f"E2E{datetime.now(UTC).strftime('%H%M%S')}" + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session: + async with session.begin(): + max_id = await session.scalar( + select(func.coalesce(func.max(FundRiskAlert.id), 0)) + ) + session.add(FundRiskAlert( + id=int(max_id or 0) + 1, alert_no=alert_no, customer_id=9001, + alert_type="频繁交易", alert_level="高", + trigger_rule_codes=["RW-DEMO-01"], + evidence_summary="端到端冒烟脚本创建的演示预警,可安全处置。", + evidence_snapshot={"demo": True}, + priority_score=90, event_status="待处理", status="待处理", + ack_status="未确认", is_escalated=0, + due_at=now + timedelta(hours=24), created_at=now, updated_at=now, + )) + return alert_no + + try: + import asyncio + + return asyncio.run(run()) + except Exception as exc: # noqa: BLE001 + info("造预警失败", f"{type(exc).__name__}: {exc}") + return None + + +# ---------------------------------------------------------------- A 访客 + +def section_guest() -> None: + section("A. 访客线") + status, payload = call("POST", "/api/v1/visitor-tokens") + data = payload if isinstance(payload.get("access_token"), str) else payload.get("data") + token = (data or {}).get("access_token") if isinstance(data, dict) else None + check("A1 访客令牌 V001", bool(token), f"HTTP={status}") + if not token: + return + started = time.perf_counter() + status, accepted = call("POST", "/api/v1/agent-runs", token, { + "agent_type": "customer_service", "session_id": str(uuid.uuid4()), + "message": "场内基金的管理费率是多少", "idempotency_key": uuid.uuid4().hex, + }) + run_id = body_of(accepted).get("run_id") + check("A2 提问受理 R001", status == 202 and bool(run_id), f"HTTP={status}") + if not run_id: + return + state, snapshot = None, {} + for attempt in range(40): + time.sleep(0.5 if attempt else 0.3) + _, snapshot = call("GET", f"/api/v1/agent-runs/{run_id}", token) + state = body_of(snapshot).get("status") + if state in {"succeeded", "failed", "cancelled"}: + break + elapsed = time.perf_counter() - started + check("A3 问答完成", state == "succeeded", f"status={state} 用时 {elapsed:.2f}s") + if state != "succeeded": + info("A3 提示", "若 status=queued:Agent Worker 没在跑(python -m app.worker)") + return + result = body_of(snapshot).get("result") or {} + check("A4 知识库覆盖(未转人工)", result.get("transfer_required") is False, + f"transfer_required={result.get('transfer_required')}") + + +# ---------------------------------------------------------------- B 客户 + +def section_customer() -> None: + section("B. 客户线") + token = login(DEMO_CUSTOMER) + check("B1 客户登录 A034", bool(token)) + if not token: + return + + status, payload = call("GET", "/api/v1/onboarding/risk-questionnaire", token) + check("B2 风险测评问卷 ONB001", status == 200 and ok_code(payload), f"HTTP={status}") + + status, payload = call("GET", "/api/v1/users/me/account/dashboard", token) + data = body_of(payload) + account, summary = data.get("account") or {}, data.get("summary") or {} + check("B3 账户看板 T001", status == 200 and bool(account), f"HTTP={status}") + if account: + info("B3 账户", json.dumps({ + "可用资金": account.get("available_cash"), "总资产": summary.get("total_asset"), + "持仓市值": summary.get("total_market_value")}, ensure_ascii=False)) + + status, payload = call("GET", "/api/v1/users/me/holdings", token) + holdings = listed(payload, "holdings") + check("B4 持仓 T006", status == 200 and ok_code(payload), f"条数={len(holdings)}") + + status, payload = call("GET", "/api/v1/users/me/cash-ledger", token) + ledger = listed(payload, "entries", "cash_ledger") + check("B5 资金流水 T009", status == 200 and ok_code(payload), f"条数={len(ledger)}") + + print() + print("--- 下单链路 ---") + if READ_ONLY: + info("B6 下单", "跳过(--read-only)") + else: + status, payload = call("POST", "/api/v1/users/me/orders", token, + {"product_code": "510300", "order_side": "buy", "quantity": 100}, + idem=True) + order = body_of(payload) + check("B6 买入下单 T002", status in (200, 201) and ok_code(payload), + f"HTTP={status} {payload.get('message') or ''}") + if not order: + info("B6 提示", "503 通常是行情过期:跑 python tools/sync_market_prices.py") + else: + info("B6 成交", json.dumps({ + "委托号": order.get("order_no"), "状态": order.get("status"), + "成交价": order.get("executed_price"), "金额": order.get("gross_amount")}, + ensure_ascii=False)) + status, _ = call("GET", f"/api/v1/users/me/orders/{order.get('order_no')}", token) + check("B7 委托详情 T004", status == 200, f"HTTP={status}") + + status, payload = call("GET", "/api/v1/users/me/transactions", token) + check("B8 成交明细 T007", status == 200 and ok_code(payload), + f"条数={len(listed(payload, 'transactions'))}") + + print() + print("--- 客服会话与转人工 ---") + status, payload = call("POST", "/api/v1/conversations", token, + {"agent_type": "customer_service"}, idem=True) + session_id = body_of(payload).get("session_id") + check("B9 创建会话 C001", status in (200, 201) and bool(session_id), + f"HTTP={status} session_id={session_id}") + if not session_id: + return + status, _ = call("GET", f"/api/v1/conversations/{session_id}", token) + check("B10 会话详情 C002", status == 200, f"HTTP={status}") + status, _ = call("GET", f"/api/v1/conversations/{session_id}/messages", token) + check("B11 会话消息 C003", status == 200, f"HTTP={status}") + if READ_ONLY: + info("B12 转人工", "跳过(--read-only)") + else: + status, payload = call( + "POST", f"/api/v1/conversations/{session_id}/handover-requests", token, + {"reason_code": "user_requested", "reason_detail": "端到端冒烟:客户主动请求人工"}, + idem=True) + check("B12 转人工 C005", status in (200, 201, 202) and ok_code(payload), + f"HTTP={status}") + + +# ---------------------------------------------------------------- C 风控 + +def section_risk() -> None: + section("C. 风控线") + token = login(DEMO_RISK) + check("C1 风控登录", bool(token)) + if not token: + return + + status, payload = call("GET", "/api/v1/risk/overview", token) + overview = body_of(payload) + check("C2 风险概览 RK001", status == 200 and ok_code(payload), + f"total={overview.get('total')} pending={overview.get('pending')} " + f"levels={overview.get('levels')}") + + status, payload = call("GET", "/api/v1/risk/alerts", token) + alerts = listed(payload, "items", "alerts") + check("C3 预警队列 RK002", status == 200 and ok_code(payload), f"条数={len(alerts)}") + + sources = ("customers", "products", "transactions", "capital_flows", + "holdings", "login_records", "alerts", "notifications") + passed = sum( + 1 for source in sources + if ok_code(call("GET", f"/api/v1/risk/evidence/{source}", token)[1]) + ) + check("C4 八类证据 RK004", passed == len(sources), f"{passed}/{len(sources)} 通过") + + status, payload = call("GET", "/api/v1/risk/notifications", token) + check("C5 通知记录 RK005", status == 200 and ok_code(payload), f"HTTP={status}") + + if READ_ONLY: + info("C6 手动扫描 RK006", "跳过(--read-only)") + info("C7 处置闭环", "跳过(--read-only)") + return + + status, payload = call("POST", "/api/v1/risk/alerts/scan", token, {}, idem=True) + check("C6 手动扫描 RK006", status == 200 and ok_code(payload), + f"新建 {body_of(payload).get('created_count')} 条") + + print() + print("--- 风控处置闭环 ---") + target = next( + (item.get("alert_no") for item in alerts if item.get("status") == "待处理"), None + ) + if target is None: + target = create_pending_alert() + info("造预警", f"{target}(库里没有待处理预警,现造一条)") + if target is None: + info("C7 处置闭环", "无法获得待处理预警,跳过") + return + for label, path, body in ( + ("C7 确认接收 RK007", f"/api/v1/risk/alerts/{target}/acknowledgements", {}), + ("C8 进入调查 RK008", f"/api/v1/risk/alerts/{target}/investigations", {}), + ("C9 完成结案 RK010", f"/api/v1/risk/alerts/{target}/resolutions", + {"resolution": "端到端冒烟:已核实为演示数据并完成处置"}), + ): + status, payload = call("POST", path, token, body, idem=True) + check(label, status == 200 and ok_code(payload), + f"{target} HTTP={status} {payload.get('message') or ''}") + + +# ---------------------------------------------------------------- D/E/F + +def section_advisor_operator_admin() -> None: + section("D. 投顾线") + advisor = login(DEMO_ADVISOR) + check("D1 投顾登录", bool(advisor)) + if advisor: + status, payload = call("GET", "/api/v1/advisor/recommendations/published", advisor) + check("D2 已发布方案", status == 200 and ok_code(payload), + f"条数={len(listed(payload))}") + + section("E. 运营线") + operator = login(DEMO_OPERATOR) + check("E1 运营登录", bool(operator)) + if operator: + status, payload = call("GET", "/api/v1/offsite-fund/mails?page=1&page_size=8", operator) + check("E2 场外邮件", status == 200 and ok_code(payload), + f"total={body_of(payload).get('total')}") + status, payload = call("GET", "/api/v1/offsite-fund/mailbox-status", operator) + check("E3 邮箱状态", status == 200 and ok_code(payload), + f"status={body_of(payload).get('status')}") + + section("F. 管理员线") + admin = login(DEMO_ADMIN) + check("F1 管理员登录", bool(admin)) + if not admin: + return + for label, path in ( + ("F2 角色列表 A035", "/api/v1/admin/roles"), + ("F3 角色详情 A036", "/api/v1/admin/roles/customer"), + ("F4 角色权限 A037", "/api/v1/admin/roles/customer/permissions"), + ("F5 用户身份 A038", "/api/v1/admin/users/9001/roles"), + ("F6 配置发布 A002", "/api/v1/admin/config-releases"), + ("F7 模型端点 A012", "/api/v1/admin/model-endpoints"), + ("F8 审计记录 A033", "/api/v1/admin/audit-records"), + ("F9 转人工工单", "/api/v1/admin/customer-service/handover-tickets"), + ("F10 画像候选 A039", "/api/v1/admin/customer-profile-candidates"), + ): + status, payload = call("GET", path, admin) + data = payload.get("data") + shape = (f"list[{len(data)}]" if isinstance(data, list) + else f"dict[{len(data)}]" if isinstance(data, dict) else "?") + check(label, status == 200 and ok_code(payload), f"HTTP={status} {shape}") + + +def main() -> int: + global READ_ONLY # noqa: PLW0603 - 单进程脚本,命令行开关就设一次 + parser = argparse.ArgumentParser(description="平台端到端冒烟") + parser.add_argument("--read-only", action="store_true", + help="跳过所有写操作(下单、转人工、风控扫描与处置)") + args = parser.parse_args() + READ_ONLY = args.read_only + + print(f"目标 {BASE}" + ("(只读模式)" if READ_ONLY else "(含写操作)")) + section_guest() + section_customer() + section_risk() + section_advisor_operator_admin() + + passed = sum(1 for _, ok, _ in RESULTS if ok) + section(f"汇总:{passed}/{len(RESULTS)} 通过") + for label, ok, detail in RESULTS: + if not ok: + print(f" FAIL {label} {detail}") + if passed == len(RESULTS): + print("全部通过。") + return 0 if passed == len(RESULTS) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/import_hq_test_products.py b/tools/import_hq_test_products.py index 9dedf9b..990da10 100644 --- a/tools/import_hq_test_products.py +++ b/tools/import_hq_test_products.py @@ -34,7 +34,9 @@ EXCHANGE_PRODUCT_REFERENCE: dict[str, tuple[str, str, str]] = { "511070": ("SSE", "ETF", "R2"), "159700": ("SZSE", "ETF", "R2"), "160128": ("SZSE", "LOF", "R2"), - "160129": ("SZSE", "LOF", "R2"), + # 原为 `160129`(南方金利定开债券C):C 类份额只在场外销售、不在交易所挂牌, + # 行情源对它永远返回空。换成南方旗下**真实上市且行情可取**的红利低波 50ETF。 + "515450": ("SSE", "ETF", "R3"), "160105": ("SZSE", "LOF", "R3"), "160142": ("SZSE", "LOF", "R3"), "160143": ("SZSE", "LOF", "R3"), diff --git a/tools/publish_risk_agent_config.py b/tools/publish_risk_agent_config.py index 2d2c649..414be8a 100644 --- a/tools/publish_risk_agent_config.py +++ b/tools/publish_risk_agent_config.py @@ -170,22 +170,40 @@ async def ensure_risk_intents(client: httpx.AsyncClient, auth: dict[str, str]) - path = "/api/v1/admin/agent-intent-configs" listed = await client.get(f"{path}?limit=100", headers=auth) rows = listed.json().get("data", []) if listed.status_code == 200 else [] - existing = { - str(row.get("intent_code")): row - for row in rows - if row.get("agent_type") == AGENT_TYPE - } + # ⚠️ 必须按 `intent_code` **聚合全部版本**,不能一个码只留一行: + # 同一意图码会有多个版本(历史版本 `status='archived'`),而列表是**按 id 倒序**返回的, + # 用单键 dict 会让**旧版本覆盖新版本**(v1 的 id 更小、排在更后面)。 + # 取到 v1 之后再"版本 +1",算出来的正是已被占用的 v2,创建必然 + # 409 IDEMPOTENCY_CONFLICT(撞唯一键 `uk_intent_config_version`)—— + # 现象是 4 条意图全部"创建失败",而库里其实都有。 + by_code: dict[str, list[dict[str, Any]]] = {} + for row in rows: + if row.get("agent_type") == AGENT_TYPE: + by_code.setdefault(str(row.get("intent_code")), []).append(row) + failures = 0 for spec in INTENT_SPECS: code = str(spec["intent_code"]) - row = existing.get(code) - if row is not None and str(row.get("status")) == "active": - print(f"[意图] {code} 已生效(id={row['id']}),跳过") + versions = by_code.get(code, []) + active = next((r for r in versions if str(r.get("status")) == "active"), None) + if active is not None: + print(f"[意图] {code} 已生效(id={active['id']}, v{active.get('version')}),跳过") continue - if row is not None and str(row.get("status")) in {"draft", "approved"}: - config_id = int(row["id"]) + # 未生效但可推进的版本(草稿/已审)优先复用,省一次新建 + reusable = next( + ( + r + for r in sorted(versions, key=lambda r: int(r.get("version", 0)), reverse=True) + if str(r.get("status")) in {"draft", "approved"} + ), + None, + ) + if reusable is not None: + config_id = int(reusable["id"]) + print(f"[意图] {code} 复用未生效版本(id={config_id}, v{reusable.get('version')})") else: - version = int(row.get("version", 0)) + 1 if row else 1 + # 新版本号取**全部版本的最大值 +1**:归档版本仍然占用着版本号 + version = max((int(r.get("version", 0)) for r in versions), default=0) + 1 created = await post(client, path, auth=auth, payload=_intent_payload(spec, version)) if created.status_code != 201: diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py new file mode 100644 index 0000000..d8116d0 --- /dev/null +++ b/tools/seed_demo_data.py @@ -0,0 +1,138 @@ +"""一键准备演示数据:按依赖顺序跑齐所有 seed 脚本。 + +## 为什么需要它 + +演示数据此前散在 10 个脚本里,**顺序有讲究**(账号 → 口令 → 账户 → 行情 → 配置 → 知识), +而且有一个环节(知识库素材)根本没有脚本、靠手工调接口。换台机器接手时没人知道该跑哪些、 +按什么顺序跑。本脚本是唯一入口。 + +## 顺序与依赖 + +| # | 步骤 | 为什么在这个位置 | +|---|---|---| +| 1 | 账号与权限 | 后面所有步骤都要用到这些账号 | +| 2 | 演示口令 | 依赖 1 建出的账号 | +| 3 | 客户账户与持仓 | 客户页面与下单的前提 | +| 4 | 场内行情 | **下单的硬前置**;必须在演示前跑,行情会过期 | +| 5 | 风控预警样本 | 风控页面要有东西可看 | +| 6 | 投顾演示数据 | 投顾工作台要有方案可看 | +| 7-9 | 各类发布配置 | Agent 工具白名单与提示词,缺了客服/风控会"失败关闭" | +| 10 | 知识库素材 | 客服答得出问题的前提 | + +## ⚠️ 两个必须知道的点 + +1. **最后一步与行情都需要 Agent Worker 才会真正生效**:知识入库只写 MySQL + 投 outbox 事件, + 向量由 Worker 消费事件后写 Milvus;没有 Worker 时现象是"客服照旧答不上",且**没有报错**。 + 所以跑完本脚本**必须**再起 Worker(`start.ps1` 会一起起)。 +2. **第 2 步不是幂等的**:`set_user_password.py` 重跑等于**重设密码**(bcrypt 每次加盐不同)。 + 这是有意的(改密就该覆盖),但要知道它不是"已存在就跳过"。 + +## 用法 + + python tools/seed_demo_data.py # 全跑 + python tools/seed_demo_data.py --from 4 # 从第 4 步开始(重跑行情等) + python tools/seed_demo_data.py --only 4,10 # 只跑指定步骤 + python tools/seed_demo_data.py --list # 只列步骤 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +#: (标题, 脚本相对路径, 备注) +STEPS: tuple[tuple[str, str, str], ...] = ( + ("账号与权限", "tools/seed_test_rbac.py", "五个演示角色 + 权限号段 9001-9046"), + ("演示口令", "tools/set_user_password.py", "⚠️ 非幂等:重跑等于重设密码"), + ("客户账户与持仓", "tools/seed_sim_account_demo.py", "客户 9001 开 10 万虚拟资金 + 持仓"), + ("场内行情", "tools/sync_market_prices.py", "⚠️ 下单硬前置;行情会过期,演示前必跑"), + ("风控预警样本", "tools/seed_risk_alert_demo_data.py", "三条不同状态的演示预警"), + ("投顾演示数据", "tools/seed_advisor_demo.py", "投顾工作台要展示的方案与归属"), + ("风控 Agent 白名单", "tools/publish_risk_agent_config.py", "缺了风控助手工具会失败关闭"), + ("客服配置白名单", "tools/publish_customer_service_config.py", "缺了客服工具会失败关闭"), + ("客服闲聊提示词", "tools/publish_chitchat_prompt.py", "话术走发布配置,不硬编码"), + ("知识库素材", "tools/seed_knowledge_demo.py", "⚠️ 需要 Worker 才会写进 Milvus"), +) + + +def run_step(index: int, title: str, script: str, note: str, *, dry_run: bool) -> bool: + path = PROJECT_ROOT / script + print() + print("=" * 88) + print(f"[{index}/{len(STEPS)}] {title} —— {note}") + print(f" {script}") + print("=" * 88) + if not path.exists(): + print(f"[跳过] 脚本不存在:{path}") + return False + if dry_run: + print("[dry-run] 未执行") + return True + started = time.perf_counter() + # 刻意**不捕获输出**(继承 stdio):既不吞掉子脚本的报错, + # 也避免在受限环境里因 piped stdio 失败。 + result = subprocess.run([sys.executable, str(path)], check=False, cwd=str(PROJECT_ROOT)) + elapsed = time.perf_counter() - started + ok = result.returncode == 0 + print(f"[{'完成' if ok else '失败'}] {title} 用时 {elapsed:.1f}s 退出码 {result.returncode}") + return ok + + +def main() -> int: + parser = argparse.ArgumentParser(description="一键准备演示数据") + parser.add_argument("--list", action="store_true", help="只列步骤") + parser.add_argument("--from", dest="start", type=int, default=1, help="从第几步开始") + parser.add_argument("--only", default="", help="只跑这些步骤(逗号分隔,如 4,10)") + parser.add_argument("--dry-run", action="store_true", help="只打印将执行什么") + args = parser.parse_args() + + if args.list: + print(f"共 {len(STEPS)} 步:") + for index, (title, script, note) in enumerate(STEPS, 1): + print(f" {index:>2}. {title:<20} {script:<48} {note}") + return 0 + + only = {int(part) for part in args.only.split(",") if part.strip()} if args.only else None + + def wanted(index: int) -> bool: + return index in only if only is not None else index >= args.start + + selected = [(index, *step) for index, step in enumerate(STEPS, 1) if wanted(index)] + if not selected: + print("没有匹配的步骤。") + return 1 + + print(f"准备演示数据:{len(selected)} 步" + ("(dry-run)" if args.dry_run else "")) + failed: list[str] = [] + for index, title, script, note in selected: + if not run_step(index, title, script, note, dry_run=args.dry_run): + failed.append(title) + + print() + print("=" * 88) + if failed: + print(f"有 {len(failed)} 步失败:{'、'.join(failed)}") + print("先看上面的原始报错;多数失败是外部依赖没起来(MySQL / Redis / Docker)。") + return 1 + print("全部完成。") + if not args.dry_run: + print( + "\n下一步:\n" + " 1. 起服务:powershell -ExecutionPolicy Bypass -File start.ps1\n" + " (它会把 API 与 **Agent Worker** 一起起 —— 没有 Worker,知识检索不到、\n" + " 客服对话会一直显示'超时')\n" + " 2. 验证:python tools/e2e_smoke_test.py" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/seed_knowledge_demo.py b/tools/seed_knowledge_demo.py new file mode 100644 index 0000000..dda9890 --- /dev/null +++ b/tools/seed_knowledge_demo.py @@ -0,0 +1,157 @@ +"""把演示用的场内基金知识灌进知识库(幂等:先清同源旧数据再上传)。 + +## 为什么需要它 + +知识库是客服能答对问题的前提,但它是**演示数据里唯一没有脚本化的一环** —— +之前是手工调 `POST /api/v1/knowledge/upload` 灌的,换台机器没人知道该灌什么、 +灌到哪个集合。本脚本把 `docs/43-场内基金产品手册(知识库入库版).md` 定为唯一素材源。 + +## 三个必须讲清的点 + +1. **上传后不会立刻可检索**:入库只写 MySQL 元数据 + 投 outbox 事件,向量由 + **Agent Worker** 消费事件后写 Milvus。所以**必须先起 Worker** + (`python -m app.worker`),否则知识永远检索不到 —— 而且现象是"客服照旧答不上", + 没有任何报错。 +2. **素材必须是"客户可见版"**:用 `docs/43`,**不要**用 `docs/42` —— 后者含内部决策 + 备注("待你确认""库里 vs 真实"),灌进去可能被客户问题检索出来。 +3. **走进程内调用**(ASGITransport),与 `tools/publish_*.py` 一致,因此**不需要先起 API**。 + +## 用法 + + python tools/seed_knowledge_demo.py # 幂等重灌 + python tools/seed_knowledge_demo.py --dry-run # 只看会做什么,不调写接口 +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import sys +import uuid +from pathlib import Path +from typing import Any + +import httpx +import jwt + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.main import create_app # noqa: E402 + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +ADMIN = "9003" +SOURCE = PROJECT_ROOT / "docs" / "43-场内基金产品手册(知识库入库版).md" +#: 上传后的文件名,也是**幂等清理的判据**:同名的旧行会被先删掉。 +UPLOAD_FILENAME = "场内基金产品手册.md" +KNOWLEDGE_TYPE = "product" + + +def token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + import datetime as dt + + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def list_existing(client: httpx.AsyncClient, auth: dict[str, str]) -> list[dict[str, Any]]: + """列出当前知识。 + + ⚠️ 这个端点**不套 `data` 信封**(直接返回 `{"items": [...], "count": N}`), + 按 `data.items` 解包会得到空列表、看着像"库里没数据" —— 实测踩过。 + """ + response = await client.get("/api/v1/knowledge/list?limit=100", headers=auth) + if response.status_code != 200: + print(f"[警告] 列表接口返回 {response.status_code},按『无现存知识』继续") + return [] + body = response.json() + items = body.get("items") + if items is None and isinstance(body.get("data"), dict): + items = body["data"].get("items") + return items or [] + + +async def main() -> int: + parser = argparse.ArgumentParser(description="灌入演示用的场内基金知识") + parser.add_argument("--dry-run", action="store_true", help="只看会做什么,不调写接口") + args = parser.parse_args() + + if not SOURCE.exists(): + print(f"[失败] 素材不存在:{SOURCE}") + return 1 + text = SOURCE.read_text(encoding="utf-8") + print(f"素材:{SOURCE.name}({len(text)} 字符)") + print(f"目标:knowledge_type={KNOWLEDGE_TYPE} → fin_product_collection") + print(f"文件名:{UPLOAD_FILENAME}(同名旧行会被先删除,以保证幂等)\n") + + app = create_app() + auth = {"Authorization": f"Bearer {token(ADMIN)}"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=120 + ) as client: + existing = await list_existing(client, auth) + stale = [row for row in existing if row.get("source_file") == UPLOAD_FILENAME] + print(f"现存知识 {len(existing)} 块,其中本素材的旧版 {len(stale)} 块") + + if args.dry_run: + print("\n[dry-run] 将会:") + print(f" 1. 删除 {len(stale)} 块旧版(id: {[r.get('knowledge_id') for r in stale]})") + print(f" 2. 上传 {SOURCE.name} 并切块入库") + print(" 未调用任何写接口。") + return 0 + + deleted = 0 + for row in stale: + knowledge_id = row.get("knowledge_id") + # 写接口必须带幂等键:平台对缺失键的写请求按失败关闭处理。 + response = await client.delete( + f"/api/v1/knowledge/{knowledge_id}", + headers={**auth, "Idempotency-Key": uuid.uuid4().hex}, + ) + if response.status_code in (200, 204): + deleted += 1 + else: + print(f" [警告] 删除 {knowledge_id} 返回 {response.status_code}") + if stale: + print(f"已清理旧版 {deleted}/{len(stale)} 块") + + response = await client.post( + "/api/v1/knowledge/upload", + headers={**auth, "Idempotency-Key": uuid.uuid4().hex}, + json={ + "filename": UPLOAD_FILENAME, + "knowledge_type": KNOWLEDGE_TYPE, + "content_base64": base64.b64encode(text.encode("utf-8")).decode("ascii"), + }, + ) + if response.status_code not in (200, 201): + print(f"[失败] 上传返回 {response.status_code}:{response.text[:300]}") + return 1 + print(f"上传成功(HTTP {response.status_code})") + + print( + "\n完成。⚠️ 接下来必须:\n" + " 1. 确认 Agent Worker 在跑(python -m app.worker)—— 向量由它写进 Milvus;\n" + " 2. 等几秒让向量同步完成;\n" + " 3. 用 python tools/e2e_smoke_test.py 验证 A 线(访客问答)不再转人工。" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tools/seed_risk_alert_demo_data.py b/tools/seed_risk_alert_demo_data.py index 4d301ba..5418787 100644 --- a/tools/seed_risk_alert_demo_data.py +++ b/tools/seed_risk_alert_demo_data.py @@ -115,11 +115,18 @@ ALERTS: tuple[dict[str, Any], ...] = ( }, "priority_score": 20, "event_status": "盘后预警", - "status": "已关闭", + # ⚠️ 状态必须是**代码能产出**的那几个(待处理 / 调查中 / 已排除 / 已结案)。 + # 这里原写作「已关闭」,而 `risk_action_service` 里没有任何动作会产生它 —— + # 演示时会被当成系统行为、排查时又查不到出处。按 close_reason 的语义 + # (低风险 + 已核验为正常交易),它其实就是「关闭误报」。 + "status": "已排除", "ack_status": "已确认", "due_at_offset_minutes": None, "is_escalated": 0, "close_reason": "已核验为本人自动定投计划,属正常交易。", + # 终态样本必须带关闭时间:只有 close_reason 却没有 closed_at 是自相矛盾的, + # 而且 `risk_repository` 按 closed_at 统计"当日结案",缺了它这条会被漏掉。 + "closed_at_offset_minutes": -120, }, ) @@ -146,13 +153,14 @@ async def main() -> int: print(f"[跳过] {spec['alert_no']} 已存在") continue offset = spec["due_at_offset_minutes"] + closed_offset = spec.get("closed_at_offset_minutes") await cursor.execute( "INSERT INTO fin_risk_alert (" " id, alert_no, customer_id, alert_type, alert_level, trigger_rule_codes," " evidence_summary, evidence_snapshot, priority_score, event_status, status," - " ack_status, handler_id, due_at, is_escalated, close_reason," + " ack_status, handler_id, due_at, closed_at, is_escalated, close_reason," " created_at, updated_at" - ") VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ") VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", ( spec["id"], spec["alert_no"], CUSTOMER_ID, spec["alert_type"], spec["alert_level"], json.dumps(spec["trigger_rule_codes"], ensure_ascii=False), @@ -161,6 +169,7 @@ async def main() -> int: spec["priority_score"], spec["event_status"], spec["status"], spec["ack_status"], HANDLER_ID, (now + dt.timedelta(minutes=offset)) if offset else None, + (now + dt.timedelta(minutes=closed_offset)) if closed_offset else None, spec["is_escalated"], spec.get("close_reason"), now, now, ), diff --git a/tools/seed_sim_account_demo.py b/tools/seed_sim_account_demo.py index 7a51003..bc365e9 100644 --- a/tools/seed_sim_account_demo.py +++ b/tools/seed_sim_account_demo.py @@ -118,38 +118,37 @@ async def _upsert_product(session: Session, spec: dict) -> int: async def _upsert_market_price(session: Session, product_id: int, spec: dict) -> None: - today = datetime.now(UTC).date() - now = datetime.now(UTC).replace(tzinfo=None) + """只在**该产品一行行情都没有**时补一条种子行情,供"不跑行情同步也能下单"兜底。 + + ⚠️ 这里**刻意不再按"今天"去 upsert**(原实现如此),因为那是一个真实踩过的坑: + + seed 写的 `trade_date` 是**当天**,而真实行情来自行情源、日期是**最近交易日**。 + 下单取行情时按 `trade_date DESC` 排序,于是这条种子行**永远排在真实行情前面**; + 它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让 + 整个产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。 + 周末尤其明显:`trade_date` 落在**非交易日**,价格还是编的(4.50 对不上真实的 4.579)。 + + 所以:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争); + 一条都没有时,`trade_date` 也要退到最近交易日,避免再次盖住后续同步的真实行情。 + """ existing = ( await session.execute( - select(FundMarketPrice.id).where( - FundMarketPrice.product_id == product_id, - FundMarketPrice.trade_date == today, - ) + select(FundMarketPrice.id) + .where(FundMarketPrice.product_id == product_id) + .limit(1) ) ).scalar_one_or_none() if existing is not None: - await session.execute( - update(FundMarketPrice) - .where(FundMarketPrice.id == existing) - .values( - open_price=spec["close_price"], - high_price=spec["close_price"] + Decimal("0.05"), - low_price=spec["close_price"] - Decimal("0.05"), - close_price=spec["close_price"], - volume=Decimal("1000000"), - turnover_amount=spec["close_price"] * Decimal("1000000"), - total_fund_shares=spec["total_fund_shares"], - source="eastmoney_demo_seed", - source_updated_at=now, - ) - ) return + today = datetime.now(UTC).date() + # 周末退到最近的周五,别把 trade_date 写进非交易日 + trade_date = today if today.weekday() < 5 else today - timedelta(days=today.weekday() - 4) + now = datetime.now(UTC).replace(tzinfo=None) next_id = await _next_id(session, FundMarketPrice) stmt = insert(FundMarketPrice).values( id=next_id, product_id=product_id, - trade_date=today, + trade_date=trade_date, open_price=spec["close_price"], high_price=spec["close_price"] + Decimal("0.05"), low_price=spec["close_price"] - Decimal("0.05"), diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index b06a30b..da49891 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -144,11 +144,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( # `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。 # 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。 (9060, "account:read:self", "account", "read", "self"), - (9061, "trade:order:create", "trade", "order", "create"), - (9062, "trade:order:read", "trade", "order", "read"), - (9063, "trade:order:cancel", "trade", "order", "cancel"), + # ⚠️ 第 5 个字段是 **data_scope**,只能取 `self` / `own_customers` / `all`。 + # 这四条原先误写成 `trade, order, create` 这样的「resource, action, action」三段, + # 于是 action 落进了 data_scope 位置(值为 'create'/'read'/'cancel')。 + # 后果不是"宽松"而是**静默失效**:`IdentityRepository.load_context` 只收集 + # data_scope 合法的权限,这四条会被整条丢掉 ⇒ 客户在**库里明明有**这四个权限, + # 下单/委托/成交却全部 `403 AGENT_PERMISSION_DENIED`, + # 而 T001/T006(`account:read:self`/`holding:read:self` 是 'self')照常 200, + # 所以现象特别像"只有交易坏了",很难联想到权限码本身写错。 + (9061, "trade:order:create", "trade", "order", "self"), + (9062, "trade:order:read", "trade", "order", "self"), + (9063, "trade:order:cancel", "trade", "order", "self"), (9064, "holding:read:self", "holding", "read", "self"), - (9065, "trade:txn:read", "trade", "txn", "read"), + (9065, "trade:txn:read", "trade", "txn", "self"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 diff --git a/tools/sync_market_prices.py b/tools/sync_market_prices.py new file mode 100644 index 0000000..00dc554 --- /dev/null +++ b/tools/sync_market_prices.py @@ -0,0 +1,70 @@ +"""把真实场内日行情同步进 `fin_market_price`(下单的硬前置)。 + +## 什么时候要跑 + +`fin_market_price` 是下单的硬前置:`TradeService` 要求产品在这张表里有 +`close_price > 0`、`total_fund_shares > 0`,且 `source_updated_at` 落在 `MAX_QUOTE_AGE` 内。 +**行情过期后没有任何自动刷新机制**,表现是下单全线 `503 FUND_QUOTE_UNAVAILABLE`, +而错误信息只说"行情已过期",看不出根因。 + +所以:**每次演示/验收之前跑一次本脚本**。它同时解决两件事: + · 把只有 2 只产品的行情补齐到全部场内产品(`seed_sim_account_demo` 只写 2 只); + · 把已有行情的时间戳刷新到当前,让新鲜度校验通过。 + +## 数据源 + +腾讯行情(`qt.gtimg.cn`)—— 本环境唯一可用的行情源:东财的 push2 / push2his +两个行情域名实测一律连接被拒(`Server disconnected`),而它的净值/概况域名正常。 +总份额按"已有值 → 腾讯总市值推算 → 季度规模兜底"的优先级确定,详见 +`app/service/market_price_sync_service.py`。 + +## 用法 + + python tools/sync_market_prices.py # 全部场内产品 + python tools/sync_market_prices.py --codes 510300,510500 +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.service.market_price_sync_service import ( # noqa: E402 + MarketPriceSyncService, + summarize, +) + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + + +async def main() -> int: + parser = argparse.ArgumentParser(description="同步场内日行情到 fin_market_price") + parser.add_argument("--codes", default="", help="只同步这些产品代码(逗号分隔)") + args = parser.parse_args() + + codes = tuple(code.strip() for code in args.codes.split(",") if code.strip()) + service = MarketPriceSyncService() + result = await service.sync(product_codes=codes or None) + + print(summarize(result)) + skipped = result.get("skipped") or [] + if skipped: + print("\n未同步的产品(以及原因):") + for item in skipped: + print(f" · {item['product_code']} {item['reason']}") + if not result.get("written"): + print("\n[警告] 一行都没写 —— 下单仍然会失败。") + return 1 + print("\n完成。下单应已可用(过期校验看的是 source_updated_at)。") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))