From bfd622b59255262e1eb3f9ee366c1a9cdeef1832 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 15:21:34 +0800
Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E7=99=BB?=
=?UTF-8?q?=E5=BD=95=E9=97=A8=E6=88=B7=EF=BC=88=E6=8C=89=E8=A7=92=E8=89=B2?=
=?UTF-8?q?=E5=88=86=E6=B5=81=E4=BA=94=E5=A5=97=E5=B7=A5=E4=BD=9C=E5=8F=B0?=
=?UTF-8?q?=EF=BC=89+=20=E8=A1=A5=E9=BD=90=E6=9D=83=E9=99=90=E8=A6=86?=
=?UTF-8?q?=E7=9B=96=E7=BC=BA=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- tools/portal.py:客户/风控/运营/管理员/投顾五个工作台,走真实登录与真实接口
- 新增 6 个此前从未建过的权限码(promotion:* 4 个 / financial:nl2sql:read / probe:read),
它们让推广材料与金融 NL2SQL 两条线对所有角色都是 403
- 投顾权限 10 → 25 项:补投资目标创建确认、agent:run、行情、知识检索、客户画像、推广材料
- 运营补 financial:nl2sql:read;create_test_user.py 不再硬编码角色 id(改按 role_code 查库)
- portal 的写请求补 Idempotency-Key 头(漏了会被 AGENT_INPUT_INVALID 拒)
- 新增 tools/check_permission_coverage.py 做权限对账
---
tools/check_permission_coverage.py | 149 ++++
tools/create_test_user.py | 33 +-
tools/grant_advisor_role.py | 23 +-
tools/grant_operator_role.py | 155 ++++
tools/portal.py | 1120 ++++++++++++++++++++++++++++
tools/seed_test_rbac.py | 15 +
6 files changed, 1479 insertions(+), 16 deletions(-)
create mode 100644 tools/check_permission_coverage.py
create mode 100644 tools/grant_operator_role.py
create mode 100644 tools/portal.py
diff --git a/tools/check_permission_coverage.py b/tools/check_permission_coverage.py
new file mode 100644
index 0000000..36d96dc
--- /dev/null
+++ b/tools/check_permission_coverage.py
@@ -0,0 +1,149 @@
+"""对账:代码声明的权限码 vs 库里存在的 vs 各角色实际拥有的(只读)。
+
+## 为什么需要它
+
+权限缺失的表现一律是 `AGENT_PERMISSION_DENIED: 缺少操作权限`,但底下有**三种完全不同的原因**,
+报错区分不出来,排查时极易往错的方向找:
+
+1. **权限码在库里根本不存在**(代码写了、没人建)→ 任何角色都过不去,
+ 看起来却像"这个角色没授权";
+2. 权限码存在,但**没授给这个角色**(例如投顾缺 `investment-goal:write:self`);
+3. 角色本身没有对应的**接口范围或界面**(例如运营只有 `offsite:write`,
+ 而它要用的 `financial:nl2sql:read` 从没建过)。
+
+这类问题**服务层测试测不出来** —— 那层直接构造 `RequestContext`,权限是测试自己塞的。
+只有把"代码声明"和"库里数据"放一起比才看得见。2026-09-12 用它在主干上查出
+6 个不存在的权限码(`promotion:*` 四个导致推广材料整条线 403、`financial:nl2sql:read`、
+`probe:read`)。
+
+只读:不连业务逻辑、不改任何数据。
+
+用法:
+
+ python tools/check_permission_coverage.py
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from app.core.config import get_settings # noqa: E402
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+
+#: 代码里声明权限码的几种写法。
+PATTERNS: tuple[str, ...] = (
+ r'require\(\s*context\s*,\s*"([^"]+)"',
+ r'^\s*permission\s*[:=]\s*"([^"]+)"',
+ r'^[A-Z_]*PERMISSION[A-Z_]*\s*[:=]\s*"([^"]+)"',
+ r'required_permission\s*=\s*"([^"]+)"',
+)
+
+CODE_SHAPE = re.compile(r"^[a-z][a-z0-9_-]*(?::[a-z0-9_-]+)+$")
+
+
+def collect_required(root: Path = PROJECT_ROOT) -> dict[str, set[str]]:
+ found: dict[str, set[str]] = {}
+ for path in (root / "app").rglob("*.py"):
+ text = path.read_text(encoding="utf-8", errors="replace")
+ for pattern in PATTERNS:
+ for match in re.finditer(pattern, text, re.MULTILINE):
+ code = match.group(1)
+ if CODE_SHAPE.match(code):
+ found.setdefault(code, set()).add(path.relative_to(root).as_posix())
+ return found
+
+
+def load_from_db() -> tuple[dict[str, int], dict[str, set[str]]]:
+ """返回 `(权限码 → id, 角色 → 权限码集合)`。"""
+ settings = get_settings()
+ dsn = settings.mysql_dsn.split("://", 1)[1]
+ credentials, location = dsn.split("@", 1)
+ user, password = credentials.split(":", 1)
+ host_port, database = location.split("/", 1)
+ host, _, port = host_port.partition(":")
+ return _query(host, int(port or 3306), user, password, database)
+
+
+def _query(host: str, port: int, user: str, password: str, database: str):
+ import asyncio
+
+ import asyncmy
+
+ async def run():
+ connection = await asyncmy.connect(
+ host=host, port=port, user=user, password=password, db=database
+ )
+ try:
+ cursor = connection.cursor()
+ await cursor.execute("SELECT id, permission_code FROM sys_permission")
+ permissions = {str(code): int(pid) for pid, code in await cursor.fetchall()}
+
+ await cursor.execute(
+ """
+ SELECT r.role_code, p.permission_code
+ FROM sys_role r
+ LEFT JOIN sys_role_permission rp ON rp.role_id = r.id
+ LEFT JOIN sys_permission p ON p.id = rp.permission_id
+ """
+ )
+ roles: dict[str, set[str]] = {}
+ for role_code, permission_code in await cursor.fetchall():
+ roles.setdefault(str(role_code), set())
+ if permission_code:
+ roles[str(role_code)].add(str(permission_code))
+ return permissions, roles
+ finally:
+ connection.close()
+
+ return asyncio.run(run())
+
+
+def main() -> int:
+ required = collect_required()
+ permissions, roles = load_from_db()
+
+ print(f"代码里声明的权限码:{len(required)} 个")
+ print(f"库里已有的权限码:{len(permissions)} 个")
+
+ missing = sorted(code for code in required if code not in permissions)
+ print()
+ print(f"【一】代码要求、但库里不存在(任何角色都过不去):{len(missing)} 个")
+ for code in missing:
+ where = sorted(required[code])[:3]
+ print(f" ✗ {code:<44} 用于 {', '.join(where)}")
+
+ unused = sorted(code for code in permissions if code not in required)
+ if unused:
+ print()
+ print(f"【二】库里有、代码里没直接搜到(可能是变量拼接或历史遗留):{len(unused)} 个")
+ for code in unused:
+ holders = sorted(r for r, s in roles.items() if code in s)
+ print(f" · {code:<44} 持有角色 {holders or '无'}")
+
+ print()
+ print("【三】各角色的权限数,以及「代码要求却没拿到」的码:")
+ for role, codes in sorted(roles.items()):
+ lacks = sorted(c for c in required if c in permissions and c not in codes)
+ print(f" {role:<16} 共 {len(codes):>2} 项;代码要求但未授予 {len(lacks)} 个")
+ for code in lacks[:12]:
+ print(f" - {code}")
+ if len(lacks) > 12:
+ print(f" … 另有 {len(lacks) - 12} 个")
+
+ if missing:
+ print()
+ print("处置:把这几个码并进 `tools/seed_test_rbac.py` 的 `PERMISSIONS`(那是唯一定义源),")
+ print(" 再按角色在对应的 `grant_*.py` 里授权。")
+ return 1
+ print()
+ print("结论:代码要求的权限码在库里都存在。")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/create_test_user.py b/tools/create_test_user.py
index e6b385e..c5a5970 100644
--- a/tools/create_test_user.py
+++ b/tools/create_test_user.py
@@ -45,15 +45,14 @@ from app.service.identity_service import IdentityService # noqa: E402
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
-#: 库里现成的角色。新用户复用它们。
-#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`)——
-#: `advisor` 就是由 `tools/grant_advisor_role.py` 建立的。
-ROLE_IDS: dict[str, int] = {
- "customer": 9001,
- "risk_operator": 9002,
- "admin": 9003,
- "advisor": 9004,
-}
+#: 可创建的角色。**不再硬编码角色 id** —— 角色 id 是环境数据
+#: (`operator` 就是自增出来的 `1986594485028610`,各环境不同),硬编码会让脚本换个
+#: 环境就失效。现在运行时按 `role_code` 查库。
+#: 新角色得先有权限集合(`sys_role_permission`):`advisor` 由
+#: `tools/grant_advisor_role.py` 建,`operator` 由 `tools/grant_operator_role.py` 补。
+KNOWN_ROLES: tuple[str, ...] = (
+ "customer", "risk_operator", "admin", "advisor", "operator",
+)
#: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。
ROLE_USER_TYPE: dict[str, str] = {
@@ -61,6 +60,7 @@ ROLE_USER_TYPE: dict[str, str] = {
"risk_operator": "employee",
"admin": "employee",
"advisor": "employee",
+ "operator": "employee",
}
#: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。
@@ -107,18 +107,21 @@ async def upsert_user(
*, user_id: int, username: str, role: str, password: str
) -> int:
"""建/更新账号并绑定角色,最后验证权限能解析出来。"""
- role_id = ROLE_IDS[role]
user_type = ROLE_USER_TYPE[role]
now = datetime.now(UTC).replace(tzinfo=None)
assigned_at = now - timedelta(seconds=ASSIGN_BACKDATE_SECONDS)
async with SessionFactory() as session, session.begin():
- role_exists = await session.scalar(
- text("SELECT id FROM sys_role WHERE id = :role_id"), {"role_id": role_id}
+ role_id = await session.scalar(
+ text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": role}
)
- if role_exists is None:
- print(f"[失败] 角色 {role}(id={role_id})不存在,先跑 tools/seed_test_rbac.py")
+ if role_id is None:
+ available = (
+ await session.scalars(text("SELECT role_code FROM sys_role ORDER BY id"))
+ ).all()
+ print(f"[失败] 库里没有角色 {role}。现有角色:{list(available)}")
return 1
+ role_id = int(role_id)
# 覆盖语义:同一个 id 重跑不会堆出第二行。
await session.execute(
@@ -192,7 +195,7 @@ async def main() -> int:
parser.add_argument("--list", action="store_true", help="列出所有账号与角色")
parser.add_argument("--id", type=int, help="用户 id(9001-9003 已被演示账号占用)")
parser.add_argument("--username", help="登录用户名")
- parser.add_argument("--role", choices=sorted(ROLE_IDS), help="角色")
+ parser.add_argument("--role", choices=KNOWN_ROLES, help="角色(库里需已存在该角色)")
parser.add_argument("--password", help="登录密码(仅限演示环境)")
args = parser.parse_args()
diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py
index 56001e8..aa0fd13 100644
--- a/tools/grant_advisor_role.py
+++ b/tools/grant_advisor_role.py
@@ -71,10 +71,15 @@ ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9043, "product-governance:sync", "product-governance", "sync", "all"),
)
-#: 投顾拿哪些 —— 工作流那 10 个;治理类 6 个只给 admin。
+#: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了**
+#: 投资目标创建/确认、跑不了 Agent、查不了行情与知识、看不了所服务客户的画像 ——
+#: 表现出来就是一片 `AGENT_PERMISSION_DENIED`。下面每一项都对应代码里真实用到的地方。
ADVISOR_GRANTED_CODES: tuple[str, ...] = (
+ # 投顾工作流(定义在种子的 9020-9034)
"asset-allocation:generate:self",
"investment-goal:read:self",
+ "investment-goal:write:self", # 新建投资目标
+ "investment-goal:confirm:self", # 与客户确认目标
"investment-goal:review",
"investment-goal:publish",
"portfolio-analysis:read:self",
@@ -83,6 +88,22 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = (
"product-recommendation:generate:self",
"product-recommendation:review",
"product-recommendation:publish",
+ # 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像
+ "agent:run",
+ "suitability:read",
+ "fund:quote:read",
+ "knowledge:query",
+ "knowledge:reference:read",
+ "memory:read:customer",
+ "conversation:create",
+ "conversation:close",
+ "conversation:feedback",
+ # 推广材料(`promotion_material_service.py:164` 专门判 `advisor`)
+ "promotion:read",
+ "promotion:write",
+ "promotion:deliver",
+ # 金融数据(`financial_nl2sql_service.py` 的角色白名单含 advisor)
+ "financial:nl2sql:read",
)
#: admin 角色 id(`seed_test_rbac.py` 建的)。
diff --git a/tools/grant_operator_role.py b/tools/grant_operator_role.py
new file mode 100644
index 0000000..ce2223e
--- /dev/null
+++ b/tools/grant_operator_role.py
@@ -0,0 +1,155 @@
+"""补齐运营(`operator`)角色的权限,并按需创建该角色。
+
+## 为什么需要它
+
+`operator` 是场外/推广线建的角色,**不在 `seed_test_rbac.py` 的 9001-9003 里** ——
+所以种子既不会创建它,也不会清理它的绑定。库里这个角色长期**只有 1 项权限**
+(`offsite:write`),但两条线实际要求的并不一样:
+
+| 功能 | 门槛类型 | 位置 |
+|---|---|---|
+| 场外基金运营(邮件、单据、确认、通知、结算) | **角色门槛** `{"operator","risk_operator","admin","super_admin"}` | `offsite_fund_service.py:2600` |
+| 金融 NL2SQL | **权限码** `financial:nl2sql:read`(角色白名单含 `operator`) | `financial_nl2sql_service.py:272` |
+
+也就是说:场外主体功能**本来就该能用**(靠角色),运营真正缺的是 NL2SQL 那一个码;
+而"看不到运营界面"是前端没做,不是权限问题。
+
+## 给哪些 —— 按代码真实要求,不多给
+
+运营不做治理、不看审计、不发布配置,因此**不给** `audit:read` / `config:*` /
+`product-governance:*` / `knowledge:manage`。需要排查权限缺口时跑
+`python tools/check_permission_coverage.py`。
+
+本脚本**只增不删**,可重复执行。
+
+用法:
+
+ python tools/grant_operator_role.py --dry-run
+ python tools/grant_operator_role.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+from datetime import UTC, datetime
+
+from sqlalchemy import text
+
+from app.infrastructure.db import SessionFactory
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
+
+OPERATOR_ROLE_CODE = "operator"
+OPERATOR_ROLE_NAME = "运营专员"
+
+#: 运营该有的权限码。`offsite:write` 由场外线创建;其余在本种子的 9051-9056 号段里定义。
+OPERATOR_GRANTED_CODES: tuple[str, ...] = (
+ # 场外运营(角色门槛之外,这个码是场外线自己声明的)
+ "offsite:write",
+ # 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator
+ "financial:nl2sql:read",
+)
+
+
+async def apply(*, dry_run: bool) -> int:
+ now = datetime.now(UTC).replace(tzinfo=None)
+ async with SessionFactory() as session, session.begin():
+ role_id = await session.scalar(
+ text("SELECT id FROM sys_role WHERE role_code = :code"), {"code": OPERATOR_ROLE_CODE}
+ )
+ print(f"角色 {OPERATOR_ROLE_CODE}:{'已存在 id=' + str(role_id) if role_id else '将新建(自动分配 id)'}")
+
+ permission_ids = dict(
+ (await session.execute(text("SELECT permission_code, id FROM sys_permission"))).all()
+ )
+ missing = [code for code in OPERATOR_GRANTED_CODES if code not in permission_ids]
+ print(f"权限码:库里已有 {len(permission_ids)} 个;本脚本需要的 {len(OPERATOR_GRANTED_CODES)} 个中缺 {len(missing)} 个")
+ for code in missing:
+ print(f" ✗ 缺失:{code}(应先跑 tools/seed_test_rbac.py)")
+
+ if dry_run:
+ print("\n[dry-run] 未写入任何数据。")
+ return 0
+
+ if role_id is None:
+ await session.execute(
+ text(
+ "INSERT INTO sys_role (role_code, role_name, status, created_at, updated_at)"
+ " VALUES (:code, :name, 'active', :now, :now)"
+ ),
+ {"code": OPERATOR_ROLE_CODE, "name": OPERATOR_ROLE_NAME, "now": now},
+ )
+ role_id = await session.scalar(
+ text("SELECT id FROM sys_role WHERE role_code = :code"),
+ {"code": OPERATOR_ROLE_CODE},
+ )
+ role_id = int(role_id)
+
+ have = set(
+ (await session.scalars(
+ text("SELECT permission_id FROM sys_role_permission WHERE role_id = :r"),
+ {"r": role_id},
+ )).all()
+ )
+ added = 0
+ for code in OPERATOR_GRANTED_CODES:
+ permission_id = permission_ids.get(code)
+ if permission_id is None or int(permission_id) in have:
+ continue
+ await session.execute(
+ text(
+ "INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
+ " VALUES (:r, :p, :now)"
+ ),
+ {"r": role_id, "p": int(permission_id), "now": now},
+ )
+ added += 1
+ print(f"授权:{OPERATOR_ROLE_CODE} 新增 {added} 项(目标共 {len(OPERATOR_GRANTED_CODES)} 项)")
+
+ await verify()
+ return 0
+
+
+async def verify() -> None:
+ """用真实链路验证:按权限码列出该角色最终拥有什么。"""
+ async with SessionFactory() as session:
+ rows = (
+ await session.execute(
+ text(
+ """
+ SELECT p.permission_code
+ FROM sys_role r
+ JOIN sys_role_permission rp ON rp.role_id = r.id
+ JOIN sys_permission p ON p.id = rp.permission_id
+ WHERE r.role_code = :code
+ ORDER BY p.permission_code
+ """
+ ),
+ {"code": OPERATOR_ROLE_CODE},
+ )
+ ).all()
+ codes = [str(row[0]) for row in rows]
+ print(f"\n{OPERATOR_ROLE_CODE} 实测权限 {len(codes)} 项:{codes}")
+ lacked = [c for c in OPERATOR_GRANTED_CODES if c not in codes]
+ if lacked:
+ print(f"[失败] 仍未绑定的码:{lacked}")
+ raise SystemExit(1)
+ print(
+ "\n下一步:给运营账号绑这个角色 ——\n"
+ " python tools/create_test_user.py --id 9006 --username offsite_t "
+ "--role operator --password offsite123"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="补齐运营角色的权限")
+ parser.add_argument("--dry-run", action="store_true", help="只打印将写入什么")
+ args = parser.parse_args()
+ return asyncio.run(apply(dry_run=args.dry_run))
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/portal.py b/tools/portal.py
new file mode 100644
index 0000000..c15c7ae
--- /dev/null
+++ b/tools/portal.py
@@ -0,0 +1,1120 @@
+"""统一登录门户:一个登录入口,按角色分流到不同的工作台。
+
+**和 `tools/chat_console.py` 的区别**:那个控制台没有登录、身份写死在命令行里;
+本门户**走真实的 `POST /api/v1/auth/tokens`**,令牌只存活在本进程,浏览器拿不到,
+然后按登录者的**真实角色**决定进哪个界面 —— 也就是照着"客户 / 员工 / 管理员"三种
+使用者的实际路径走一遍,而不是逐个接口点着测。
+
+| 登录者的角色 | 进入的界面 | 主要能力 |
+|---|---|---|
+| `customer` | **客服** | 与客服 Agent 对话(真实受理 + 进程内驱动 Worker)、转人工、查看并确认自己的画像候选 |
+| `risk_operator` / `operator` | **员工工作台** | 风控总览、预警列表与详情、预警扫描、确认/升级/解决等处置、生成日报 |
+| `admin` / `super_admin` | **权限界面** | 角色清单、每个角色的权限明细、按用户查角色、审计流水、客服转人工工单、知识库清单 |
+| `advisor` | **投顾工作台** | 当前投资目标、已发布方案、组合分析 |
+
+角色从 `IdentityService.resolve()` 现查(不是从令牌里读)—— 所以改了库里角色,重登即生效。
+
+跑法:
+
+```powershell
+D:\\conda\\envs\\jr_py313\\python.exe tools\\portal.py
+# 浏览器打开 http://127.0.0.1:8101
+```
+
+**前置**:
+
+1. MySQL / Redis 可用(平台启动时会连);
+2. 演示账号能登录:先 `python tools/seed_test_rbac.py`,再 `python tools/set_user_password.py`;
+3. **客服对话前请停掉常驻 Worker**(`python -m app.worker`)—— 本门户自己驱动这一条 run,
+ 常驻 Worker 会与它抢 `agent_run` 队列,表现为页面一直转圈。
+
+**权限问题的表现是设计如此**:某个按钮点了返回 403,说明这个角色的权限**本来就不够**
+(例如客户点风控、员工点权限管理)。平台一律 fail closed,门户把 403 原样显示出来,
+不做任何前端隐藏式"代为授权"。
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import uuid
+from pathlib import Path
+from typing import Any
+
+import httpx
+import uvicorn
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+
+sys.stdout.reconfigure(errors="replace")
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+CUSTOMER_AGENT = "customer_service"
+
+DEMO_ACCOUNTS: tuple[tuple[str, str, str], ...] = (
+ ("cust_t", "123456", "客户"),
+ ("risk_t", "666666", "员工(风控专员)"),
+ ("admin_t", "88888888", "管理员"),
+ ("advisor_t", "abc12345", "投资顾问"),
+ ("offsite_t", "offsite123", "员工(运营专员)"),
+)
+
+#: 角色 → 界面。按优先级从高到低匹配,多角色者进权限最高的那个界面。
+VIEW_BY_ROLE: tuple[tuple[str, str], ...] = (
+ ("super_admin", "admin"),
+ ("admin", "admin"),
+ ("risk_operator", "staff"),
+ ("operator", "offsite"), # 运营走场外基金这条线,不是风控那个台
+ ("advisor", "advisor"),
+ ("customer", "customer"),
+)
+
+VIEW_LABELS = {
+ "customer": "客服",
+ "staff": "风控工作台",
+ "offsite": "运营工作台",
+ "admin": "权限管理",
+ "advisor": "投顾工作台",
+ "unknown": "未分配界面",
+}
+
+
+def view_for(roles: tuple[str, ...] | list[str]) -> str:
+ for role, view in VIEW_BY_ROLE:
+ if role in roles:
+ return view
+ return "unknown"
+
+
+class Platform:
+ """共享的平台连接:一个 app、一个 client、一个 lifespan,所有会话共用。
+
+ 分开的理由:每个会话各自 `create_app()` 会把平台实例化好几遍(连接池、后台任务都翻倍);
+ 而"多个浏览器标签用不同身份"又是演示时的刚需 —— 于是把**连接**共享、把**令牌**按会话分开。
+ """
+
+ def __init__(self, base_url: str | None) -> None:
+ self.base_url = base_url
+ self.application: FastAPI | None = None
+ self._client: httpx.AsyncClient | None = None
+ self._lifespan: Any = None
+ self.environment: dict[str, Any] = {}
+
+ async def client(self) -> httpx.AsyncClient:
+ if self._client is not None:
+ return self._client
+ if self.base_url:
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0)
+ self.environment = {"mode": "外部服务", "target": self.base_url}
+ return self._client
+
+ from app.core.config import get_settings
+ from app.main import create_app
+
+ application = create_app()
+ self.application = application
+ self._lifespan = application.router.lifespan_context(application)
+ await self._lifespan.__aenter__()
+ self._client = httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=application), base_url="http://platform", timeout=120.0
+ )
+ dsn = get_settings().mysql_dsn
+ self.environment = {"mode": "进程内", "mysql": dsn.split("@")[-1] if "@" in dsn else dsn}
+ return self._client
+
+ async def aclose(self) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+ if self._lifespan is not None:
+ await self._lifespan.__aexit__(None, None, None)
+ self._lifespan = None
+
+ async def request(
+ self,
+ method: str,
+ path: str,
+ *,
+ token: str | None,
+ query: dict[str, Any] | None = None,
+ body: Any = None,
+ ) -> dict[str, Any]:
+ if not token:
+ return {"status": 401, "body": {"error": {"code": "NOT_LOGGED_IN", "message": "请先登录"}}}
+ client = await self.client()
+ headers = {"Authorization": f"Bearer {token}"}
+ # 幂等头:平台的写接口要求 `Idempotency-Key`(16-128 位 ASCII),
+ # 漏了会被 `AGENT_INPUT_INVALID: 必须提供 16-128 位 ASCII Idempotency-Key` 拒绝。
+ if method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
+ headers["Idempotency-Key"] = uuid.uuid4().hex
+ try:
+ response = await client.request(
+ method.upper(),
+ path,
+ params=query or None,
+ json=body,
+ headers=headers,
+ )
+ except Exception as exc:
+ return {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
+ return {"status": response.status_code, "body": _json(response)}
+
+
+class Session:
+ """一次登录会话:只持有令牌与身份。"""
+
+ def __init__(self, username: str, user_id: str, token: str) -> None:
+ self.username = username
+ self.user_id = user_id
+ self.token = token
+ self.roles: tuple[str, ...] = ()
+ self.permissions: tuple[str, ...] = ()
+
+ def as_dict(self, environment: dict[str, Any]) -> dict[str, Any]:
+ view = view_for(self.roles)
+ return {
+ "username": self.username,
+ "user_id": self.user_id,
+ "roles": list(self.roles),
+ "permissions": list(self.permissions),
+ "view": view,
+ "view_label": VIEW_LABELS[view],
+ "logged_in": True,
+ "environment": environment,
+ }
+
+
+class Portal:
+ """多会话门户:`X-Session` 头区分标签页,令牌始终留在服务端。"""
+
+ def __init__(self, base_url: str | None) -> None:
+ self.platform = Platform(base_url)
+ self.sessions: dict[str, Session] = {}
+ self.error: str | None = None
+
+ async def login(self, session_id: str, username: str, password: str) -> dict[str, Any]:
+ client = await self.platform.client()
+ response = await client.post(
+ "/api/v1/auth/tokens", json={"username": username, "password": password}
+ )
+ payload = _json(response)
+ if response.status_code != 200:
+ return {"ok": False, "status": response.status_code, "body": payload}
+
+ data = (payload.get("data") or {}) if isinstance(payload, dict) else {}
+ token = data.get("access_token") or data.get("token")
+ if not token:
+ return {"ok": False, "status": response.status_code, "body": payload}
+
+ session = Session(username, str(data.get("user_id") or _sub_of(token) or ""), token)
+ await self._resolve(session)
+ self.sessions[session_id] = session
+ return {"ok": True, "me": session.as_dict(self.platform.environment)}
+
+ async def _resolve(self, session: Session) -> None:
+ """角色与权限现查库 —— 与平台每请求解析的口径一致(令牌里只有 sub)。"""
+ from app.core.contracts import RequestContext
+ from app.service.identity_service import IdentityService
+
+ context = await IdentityService().resolve(
+ RequestContext(user_id=session.user_id, trace_id=str(uuid.uuid4()))
+ )
+ session.roles = tuple(context.roles)
+ session.permissions = tuple(sorted(context.permissions))
+
+ def logout(self, session_id: str) -> None:
+ self.sessions.pop(session_id, None)
+
+ def me(self, session_id: str) -> dict[str, Any]:
+ session = self.sessions.get(session_id)
+ data = (
+ session.as_dict(self.platform.environment)
+ if session
+ else {"logged_in": False, "environment": self.platform.environment}
+ )
+ data["accounts"] = [
+ {"username": u, "password": p, "label": label} for u, p, label in DEMO_ACCOUNTS
+ ]
+ data["startup_error"] = self.error
+ return data
+
+ async def call(
+ self,
+ session_id: str,
+ method: str,
+ path: str,
+ query: dict[str, Any] | None = None,
+ body: Any = None,
+ ) -> dict[str, Any]:
+ session = self.sessions.get(session_id)
+ return await self.platform.request(
+ method, path, token=session.token if session else None, query=query, body=body
+ )
+
+ async def chat(self, session_id: str, message: str, chat_session: str) -> dict[str, Any]:
+ """客服对话:真实受理 → 本进程驱动这一条 run → 取结果。"""
+ from app.worker.runtime import WorkerRuntime
+
+ accepted = await self.call(
+ session_id,
+ "POST",
+ "/api/v1/agent-runs",
+ body={
+ "agent_type": CUSTOMER_AGENT,
+ "message": message,
+ "session_id": chat_session,
+ "idempotency_key": uuid.uuid4().hex,
+ },
+ )
+ if accepted["status"] != 202:
+ return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
+
+ run_id = ((accepted["body"] or {}).get("data") or {}).get("run_id")
+ if not run_id:
+ return {"ok": False, "status": accepted["status"], "body": accepted["body"]}
+
+ await WorkerRuntime().execute(run_id)
+ detail = await self.call(session_id, "GET", f"/api/v1/agent-runs/{run_id}")
+ data = (detail["body"] or {}).get("data") or {}
+ result = data.get("result") or {}
+ raw_intent = result.get("intent")
+ intent = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
+ return {
+ "ok": True,
+ "run_id": run_id,
+ "status": data.get("status"),
+ "answer": str(result.get("content") or ""),
+ "intent": intent or "",
+ "transfer": bool(result.get("transfer_required")),
+ "transfer_reason": result.get("transfer_reason") or "",
+ }
+
+
+def _json(response: httpx.Response) -> Any:
+ try:
+ return response.json()
+ except Exception:
+ return {"_raw": response.text[:3000]}
+
+
+def _sub_of(token: str) -> str | None:
+ import jwt
+
+ try:
+ return str(jwt.decode(token, options={"verify_signature": False}).get("sub"))
+ except Exception:
+ return None
+
+
+def build_portal(state: Portal) -> FastAPI:
+ # 注意:`Request` 必须在**模块顶层**导入。本文件启用了
+ # `from __future__ import annotations`,注解在此处是字符串,FastAPI 解析
+ # `request: Request` 时只在模块全局里找类型名 —— 写成函数内 import 会被
+ # 当成"必填 query 参数 request",所有请求直接 422,且报错完全看不出是这个原因。
+ portal = FastAPI(title="统一登录门户")
+
+ def sid(request: Request) -> str:
+ return request.headers.get("X-Session") or "default"
+
+ @portal.get("/", response_class=HTMLResponse)
+ async def index() -> HTMLResponse:
+ return HTMLResponse(PAGE)
+
+ @portal.get("/api/me")
+ async def me(request: Request) -> JSONResponse:
+ return JSONResponse(state.me(sid(request)))
+
+ @portal.post("/api/login")
+ async def login(request: Request, payload: dict[str, Any]) -> JSONResponse:
+ try:
+ result = await state.login(
+ sid(request), str(payload.get("username") or ""), str(payload.get("password") or "")
+ )
+ state.error = None
+ except Exception as exc:
+ state.error = f"{type(exc).__name__}: {exc}"
+ result = {"ok": False, "status": 0, "body": {"error": {"message": state.error}}}
+ return JSONResponse(result)
+
+ @portal.post("/api/logout")
+ async def logout(request: Request) -> JSONResponse:
+ state.logout(sid(request))
+ return JSONResponse({"ok": True})
+
+ @portal.post("/api/call")
+ async def call(request: Request, payload: dict[str, Any]) -> JSONResponse:
+ try:
+ result = await state.call(
+ sid(request),
+ str(payload.get("method") or "GET"),
+ str(payload.get("path") or "/"),
+ payload.get("query"),
+ payload.get("body"),
+ )
+ except Exception as exc:
+ result = {"status": 0, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
+ return JSONResponse(result)
+
+ @portal.post("/api/chat")
+ async def chat(request: Request, payload: dict[str, Any]) -> JSONResponse:
+ message = str(payload.get("message") or "").strip()
+ chat_session = str(payload.get("session_id") or f"portal-{uuid.uuid4().hex[:8]}")
+ if not message:
+ return JSONResponse({"ok": False, "body": {"error": {"message": "请输入内容"}}})
+ try:
+ return JSONResponse(await state.chat(sid(request), message, chat_session))
+ except Exception as exc:
+ return JSONResponse(
+ {"ok": False, "body": {"error": {"message": f"{type(exc).__name__}: {exc}"}}}
+ )
+
+ return portal
+
+
+PAGE = r"""
+
+
+
+
+基金智能服务平台
+
+
+
+
+
+
+
基金智能服务平台
+
请使用账号登录,系统会按你的角色进入对应界面。
+
+
+
+
+
+
+ 演示账号(点击填入):
+
+
+
+
+
+
+
+
+ 基金智能服务平台
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="统一登录门户(按角色分流)")
+ parser.add_argument("--port", type=int, default=8101)
+ parser.add_argument("--base-url", default=None, help="指向已在运行的服务;省略则进程内直挂平台 app")
+ args = parser.parse_args()
+
+ state = Portal(args.base_url)
+ mode = args.base_url or "进程内(走真实中间件与鉴权栈)"
+ print(f"登录门户:http://127.0.0.1:{args.port} 模式={mode}")
+ print("演示账号:cust_t/123456 · risk_t/666666 · admin_t/88888888 · advisor_t/abc12345")
+ print("客服对话前请停掉常驻 Worker;账号需先跑 seed_test_rbac.py + set_user_password.py。")
+ uvicorn.run(build_portal(state), host="127.0.0.1", port=args.port, log_level="warning")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py
index 1c9c656..2cc727d 100644
--- a/tools/seed_test_rbac.py
+++ b/tools/seed_test_rbac.py
@@ -108,6 +108,21 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9048, "risk:alert:write", "risk", "alert", "all"),
(9049, "risk:alert:scan", "risk", "alert", "all"),
(9050, "risk:report:mail", "risk", "report", "all"),
+ # ---- 9051-9056:代码早就要求、但**从未建过**的 6 个权限码 ----
+ # 症状同样是 `AGENT_PERMISSION_DENIED: 缺少操作权限`:看着像角色配错,其实是
+ # **权限码根本不存在**,于是任何角色都过不去。对账工具见
+ # `tools/check_permission_coverage.py`(把"代码声明的权限"与"库里各角色拥有的"比一遍)。
+ # 推广材料:`promotion_material_service.py` 用这四个;第 164 行专门判
+ # `"advisor" in context.roles`,说明投顾本来就在这条业务线上。
+ (9051, "promotion:read", "promotion", "read", "all"),
+ (9052, "promotion:write", "promotion", "write", "all"),
+ (9053, "promotion:review", "promotion", "review", "all"),
+ (9054, "promotion:deliver", "promotion", "deliver", "all"),
+ # NL2SQL 工具声明该权限(`bootstrap.py`);`financial_nl2sql_service.py:272` 的角色
+ # 白名单是 {advisor, operator, admin, super_admin} —— 投顾与运营都要用它。
+ (9055, "financial:nl2sql:read", "financial", "nl2sql", "all"),
+ # 平台验证探针工具(`platform_probe.py`),只给 admin。
+ (9056, "probe:read", "probe", "read", "all"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
From 615032ab00a00b669c1c74ce9c0396741880543b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 15:37:36 +0800
Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E4=BF=AE=20POST=20/api/v1/conversat?=
=?UTF-8?q?ions=20=E7=9A=84=20MissingGreenlet=EF=BC=88=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E5=BB=BA=E4=B8=8D=E5=87=BA=E6=9D=A5=E5=AF=BC=E8=87=B4=E8=BD=AC?=
=?UTF-8?q?=E4=BA=BA=E5=B7=A5=20404=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- public_platform_service: 创建 ConversationSession 时显式赋值四个 server_default 时间列,
否则 flush() 后需回读数据库生成值,在 async session 里以同步属性访问触发
MissingGreenlet,接口 500,连带转人工一直报会话不存在
- portal: 客服改用 C001 真实建会话;转人工改传 reason_code/reason_detail(原 reason 属额外字段 422)
- portal: 投顾查客户投资目标走 customers/{id} 变体
- seed/grant: 补 investment-goal:{read,write,confirm}:customer 三个动态拼出的权限码
(data_scope=own_customers,投顾只看名下客户)
---
app/service/public_platform_service.py | 14 ++++++++
tools/grant_advisor_role.py | 6 ++++
tools/portal.py | 44 ++++++++++++++++++++------
tools/seed_test_rbac.py | 13 ++++++++
4 files changed, 67 insertions(+), 10 deletions(-)
diff --git a/app/service/public_platform_service.py b/app/service/public_platform_service.py
index 45a23a7..496bff6 100644
--- a/app/service/public_platform_service.py
+++ b/app/service/public_platform_service.py
@@ -79,11 +79,25 @@ class PublicPlatformService:
session_id: str | None = None
if operation == "create":
get_agent_factory().authorize(payload["agent_type"], context)
+ # 这四个时间列在模型里都是 `server_default=CURRENT_TIMESTAMP(6)`。
+ # 不显式赋值的话,`flush()` 之后 SQLAlchemy 需要**回读**这些由数据库生成的
+ # 值,而在 async session 里回读是异步 IO —— 紧接着 `_session_view(row)`
+ # 以同步属性访问去读,就抛 `MissingGreenlet: greenlet_spawn has not been
+ # called`,整个 `POST /api/v1/conversations` 500,连带转人工也做不了
+ # (会话建不出来 ⇒ 后续 404 会话不存在)。显式传 `now` 与同文件
+ # `ConversationFeedback(...)` 的写法一致,也贴合本项目"应用侧赋时间"的约定。
row = ConversationSession(
session_id=str(uuid4()),
user_id=user_id,
agent_type=payload["agent_type"],
portal=context.portal,
+ status="active",
+ clarification_round=0,
+ message_count=0,
+ started_at=now,
+ last_active_at=now,
+ created_at=now,
+ updated_at=now,
)
session.add(row)
await session.flush()
diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py
index aa0fd13..190b3d8 100644
--- a/tools/grant_advisor_role.py
+++ b/tools/grant_advisor_role.py
@@ -80,6 +80,12 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = (
"investment-goal:read:self",
"investment-goal:write:self", # 新建投资目标
"investment-goal:confirm:self", # 与客户确认目标
+ # 看/建/确认**客户**(而非自己)的投资目标。这三个码是 `investment_goal_service.py`
+ # 按 `customer_id == 自己` 动态拼出来的,`data_scope=own_customers`:
+ # 只有客户在投顾名下才放行 —— 投顾服务的本来就是别人的钱。
+ "investment-goal:read:customer",
+ "investment-goal:write:customer",
+ "investment-goal:confirm:customer",
"investment-goal:review",
"investment-goal:publish",
"portfolio-analysis:read:self",
diff --git a/tools/portal.py b/tools/portal.py
index c15c7ae..422d689 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -500,8 +500,10 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&','<':'
const pretty = (o) => { try { return JSON.stringify(o, null, 2); } catch { return String(o); } };
let ME = null;
-// 客服对话的会话号(一次对话内保持,用于承接上下文)
-let SESSION = "portal-" + Math.random().toString(16).slice(2, 10);
+// 客服会话:必须用 C001 `POST /api/v1/conversations` 真实创建后再用 ——
+// `agent-runs` 的 session_id 不会替你建会话,转人工时后端会查 `conversation` 表,
+// 拿一个自己编的 id 去调就会得到 404「会话不存在」。
+let CONV = null;
// 门户会话号:每个标签页一个,存在 sessionStorage;令牌本身始终留在服务端 ——
// 于是可以同时开两个窗口分别用客户与管理员身份,互不干扰。
let SID = sessionStorage.getItem('portal-sid');
@@ -622,6 +624,16 @@ function addMsg(who, text, meta) {
return row;
}
+async function ensureSession() {
+ if (CONV) return CONV;
+ const r = await jpost('/api/call', { method:'POST', path:'/api/v1/conversations',
+ body:{ agent_type:'customer_service' } });
+ const d = (r.body || {}).data || {};
+ CONV = d.session_id || d.id || null;
+ if (!CONV) { alert('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
+ return CONV;
+}
+
async function sendChat() {
const box = $('msg');
const text = box.value.trim();
@@ -630,7 +642,9 @@ async function sendChat() {
box.value = '';
const pending = addMsg('bot', '正在查询资料…');
$('chat-meta').textContent = '处理中…';
- const r = await jpost('/api/chat', { message: text, session_id: SESSION });
+ const sid = await ensureSession();
+ if (!sid) { pending.remove(); $('chat-meta').textContent = ''; return; }
+ const r = await jpost('/api/chat', { message: text, session_id: sid });
pending.remove();
$('chat-meta').textContent = '';
if (!r.ok) { addMsg('bot', '请求失败:' + pretty(r.body)); return; }
@@ -676,10 +690,15 @@ async function decide(id, decision) {
}
async function askHandover() {
- const reason = prompt('转人工原因(会记录进工单):', '需要人工协助');
- if (reason === null) return;
+ const sid = await ensureSession();
+ if (!sid) return;
+ // 该接口只接受 `reason_code`(固定 user_requested)+ `reason_detail`(可选、≤500 字);
+ // 传 `reason` 会被 `additionalProperties: false` 判为额外字段,直接 422。
+ const detail = prompt('转人工说明(可留空,最多 500 字):', '需要人工协助');
+ if (detail === null) return;
const r = await jpost('/api/call', { method:'POST',
- path: `/api/v1/conversations/${SESSION}/handover-requests`, body: { reason } });
+ path: `/api/v1/conversations/${sid}/handover-requests`,
+ body: { reason_code: 'user_requested', reason_detail: detail.slice(0, 500) } });
showExtra('转人工结果', r);
}
@@ -1080,12 +1099,17 @@ function showAdv(title, r) {
}
async function goal() {
- const id = $('cust')?.value.trim();
- const path = id ? `/api/v1/advisor/customers/${id}/investment-goals/current`
- : '/api/v1/advisor/investment-goals/current';
+ // 投顾要看的是**客户**的目标,所以走 `customers/{id}` 变体 —— 服务层会按
+ // `customer_id == 自己` 决定拼 `:self` 还是 `:customer` 权限码,后者要求
+ // `data_scope=own_customers` 且该客户确实在这个投顾名下,否则返回 404。
+ const id = ($('cust')?.value.trim() || '9001');
+ const path = `/api/v1/advisor/customers/${id}/investment-goals/current`;
const r = await GET(path);
+ const hint = r.status === 403
+ ? ' —— 需要 investment-goal:read:customer(scope=own_customers)'
+ : (r.status === 404 ? ' —— 该客户不在你名下,或还没有投资目标' : '');
$('advisor-extra').innerHTML = `投资目标
- HTTP ${r.status} · ${esc(path)}
+ HTTP ${r.status}${hint} · ${esc(path)}
${esc(pretty(r.body))}`;
}
diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py
index 2cc727d..d683f68 100644
--- a/tools/seed_test_rbac.py
+++ b/tools/seed_test_rbac.py
@@ -123,6 +123,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9055, "financial:nl2sql:read", "financial", "nl2sql", "all"),
# 平台验证探针工具(`platform_probe.py`),只给 admin。
(9056, "probe:read", "probe", "read", "all"),
+ # ---- 9057-9059:`investment-goal:{action}:customer` 三个变体 ----
+ # 这三个是**动态拼出来的**:`investment_goal_service.py:270-286` 的
+ # `_assert_customer_access` 按 `customer_id == 自己` 决定拼 `:self` 还是 `:customer`,
+ # 所以对账工具抓不到字面量,一直以为权限齐了 —— 实际投顾查/建/确认**客户**的目标
+ # 全部 403。action 取值来自调用点:`write`(L43) / `confirm`(L110) / `read`(L138,155)。
+ #
+ # `data_scope` 必须是 `own_customers`:那段代码在后面还会校一次
+ # `scope != "all" and (scope != "own_customers" or customer_id not in context.customer_ids)`
+ # ⇒ 只有 `own_customers` 且客户确在投顾名下才放行。这是**最小权限**的正确形态:
+ # 投顾只看自己服务的客户,而不是全量客户。
+ (9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
+ (9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
+ (9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
From 2525fbdad9b3d3fc798cd1bfb8daec7e0660016b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 15:45:09 +0800
Subject: [PATCH 3/7] =?UTF-8?q?feat(portal):=20=E6=8E=A8=E5=B9=BF=E6=9D=90?=
=?UTF-8?q?=E6=96=99=E8=A1=A5=E6=88=90=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B?=
=?UTF-8?q?=E5=90=91=E5=AF=BC=EF=BC=8C=E5=B9=B6=E6=94=B9=E4=B8=BA=E6=8C=89?=
=?UTF-8?q?=20body.code=20=E5=88=A4=E6=88=90=E8=B4=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 投顾视图:创建 -> 补结构化输入(含七项费率) -> 生成 -> 投递 -> 查询
生成后自动把 material_version_id 填进投递框
- 管理员视图:新增推广材料审核(promotion:review 只给管理员,职责分离)
- 统一结果渲染改看 body.code:本平台业务失败也返回 HTTP 200
(生成失败是 HTTP 200 + code=422 + '材料内容未通过合规校验')
- 输入骨架刻意不预填业绩(performance_info 有 show_* 开关可关),
费率填占位文本以便跑通流程;真实材料必须换成真实值
---
tools/portal.py | 225 ++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 206 insertions(+), 19 deletions(-)
diff --git a/tools/portal.py b/tools/portal.py
index 422d689..3006b54 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -939,6 +939,26 @@ function renderAdmin(box) {
知识库
+
+
+
推广材料审核
+
审核需要 promotion:review,只给管理员 —— 投顾没有这个码(职责分离),
+ 所以"生成"之后必须由管理员在这里通过,投顾才能投递。
+ 版本号从哪来:投顾执行「生成」后,响应里的 material_version_id 就是它
+ (状态 pending_review)。注意本平台失败时也返回 HTTP 200,
+ 判断成败要看 body.code。
+
+
+
+
+
+
+
+
+
+
+
+
`;
loadRoles();
loadAudit();
@@ -1021,6 +1041,31 @@ async function loadKnowledge() {
: `HTTP ${r.status}
${esc(pretty(r.body))}`;
}
+async function admPromoLookup() {
+ const t = $('promo-task').value.trim();
+ if (!t) return alert('请填任务单号');
+ const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
+ const mv = ((r.body || {}).data || {}).material_version || {};
+ if (mv.id) $('adm-version').value = mv.id;
+ $('promo-admin-extra').innerHTML = `任务 ${esc(t)}
+ HTTP ${r.status}(管理员不受"已投递"限制,能直接看到版本)
+ ${esc(pretty(r.body))}`;
+}
+
+async function admReview(decision) {
+ const t = $('promo-task').value.trim();
+ const vid = parseInt($('adm-version').value.trim(), 10);
+ if (!t) return alert('请填任务单号');
+ if (!vid) return alert('请填 material_version_id(可先点「查任务」自动填)');
+ const comment = $('adm-comment').value.trim() || null;
+ if (!confirm(`对 ${t} 的版本 ${vid} 执行「${decision}」?`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/reviews`,
+ body:{ material_version_id: vid, decision, comment } });
+ $('promo-admin-extra').innerHTML = `审核结果(${esc(decision)})
+ HTTP ${r.status}
${esc(pretty(r.body))}`;
+}
+
/* ---------------- 投顾 ---------------- */
function renderAdvisor(box) {
box.innerHTML = `
@@ -1044,22 +1089,83 @@ function renderAdvisor(box) {
`;
goal();
+ promoSkeleton();
+}
+
+function promoSkeleton() {
+ // 两个刻意的选择:
+ // 1) 不预填任何业绩数字 —— `performance_info` 的业绩字段全部可选且带
+ // `show_product_performance` 开关,关掉即可;编造业绩是合规红线。
+ // 2) 费率七项**必须非空**:合规规则 `fee_structure.incomplete`(severity=block)
+ // 会拦下生成,任务会被置成 `compliance_failed`。这里填占位文本让它先跑通流程,
+ // 真实材料必须换成真实费率 —— 页面上也这么提示。
+ $('promo-inputs').value = JSON.stringify({
+ product_info: {
+ fund_type: "混合型",
+ operation_mode: "契约型开放式",
+ investment_objective: "在严格控制风险的前提下,力争实现基金资产的长期稳健增值",
+ benchmark: "沪深300指数收益率×60%+中债综合指数收益率×40%",
+ risk_level: "R3"
+ },
+ manager_info: {
+ management_company: "南方科技基金管理有限公司",
+ manager_name: "待填写",
+ registration_code: "待填写"
+ },
+ team_info: { team_description: "待填写" },
+ strategy_info: {
+ investment_scope: "本基金投资于国内依法发行上市的股票、债券等金融工具",
+ strategy: "自上而下的大类资产配置与自下而上的个股精选相结合",
+ restrictions: "不投资于法律法规禁止的标的"
+ },
+ fee_structure: {
+ subscription_fee: "待填写", purchase_fee: "待填写", redemption_fee: "待填写",
+ sales_service_fee: "待填写", management_fee: "待填写", custody_fee: "待填写",
+ client_maintenance_fee: "待填写"
+ },
+ performance_info: { show_product_performance: false, show_manager_performance: false },
+ risk_disclosure: { special_risks: ["市场风险", "流动性风险"], additional_notes: "详见基金合同与招募说明书" },
+ source_notes: {}
+ }, null, 2);
+}
+
+function promoTaskNo() {
+ const t = $('promo-task').value.trim();
+ if (!t) { alert('请先填任务单号(创建任务后会返回)'); return null; }
+ return t;
}
async function promoCreate() {
@@ -1067,16 +1173,75 @@ async function promoCreate() {
const title = $('promo-title').value.trim();
const style = $('promo-style').value.trim();
if (!name || !title || !style) return alert('产品名、材料标题、风格代码都要填');
- if (!confirm(`创建推广材料任务?\n产品:${name}\n标题:${title}\n风格:${style}`)) return;
- showAdv('创建推广材料', await jpost('/api/call', {
- method:'POST', path:'/api/v1/fund-promotion-materials',
- body:{ product_name: name, material_title: title, style_code: style } }));
+ const r = await jpost('/api/call', { method:'POST', path:'/api/v1/fund-promotion-materials',
+ body:{ product_name: name, material_title: title, style_code: style } });
+ const d = (r.body || {}).data || {};
+ if (d.task_no) $('promo-task').value = d.task_no;
+ showAdv('① 创建任务' + (d.task_no ? ':' + d.task_no : ''), r);
+}
+
+async function promoSaveInputs() {
+ const t = promoTaskNo(); if (!t) return;
+ let body;
+ try { body = JSON.parse($('promo-inputs').value); }
+ catch (e) { return alert('结构化输入不是合法 JSON:' + e.message); }
+ const r = await jpost('/api/call', { method:'PUT',
+ path:`/api/v1/fund-promotion-materials/${t}/inputs`, body });
+ showAdv('② 保存结构化输入 ' + t, r);
+}
+
+async function promoGenerate(fmt) {
+ const t = promoTaskNo(); if (!t) return;
+ if (!confirm(`生成 ${fmt.toUpperCase()}?这一步会产出材料版本。`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/generations`, body:{ output_formats:[fmt] } });
+ const body = r.body || {};
+ const d = body.data || {};
+ if (d.material_version_id) $('promo-version').value = d.material_version_id;
+ showAdv('③ 生成 ' + fmt.toUpperCase() + ' ' + t, r);
+ if (d.material_version_id) {
+ $('advisor-extra').insertAdjacentHTML('beforeend',
+ `已产出 material_version_id=${d.material_version_id},
+ 状态 pending_review(所以此刻查询任务看不到它 —— get_task 只返回 approved/sent)。
+ 下一步请管理员在「权限管理」页对它审核通过,之后你才能投递;版本号已自动填进上面的投递框。
`);
+ } else if (body.code) {
+ $('advisor-extra').insertAdjacentHTML('beforeend',
+ `生成未成功(body.code=${esc(body.code)})。最常见的原因是
+ 费率七项没填全:合规规则 fee_structure.incomplete 会把任务置成
+ compliance_failed。另注意:这个平台失败时也返回 HTTP 200,要看 body.code。
`);
+ }
+}
+
+async function promoDeliver() {
+ const t = promoTaskNo(); if (!t) return;
+ const versionId = parseInt($('promo-version').value.trim(), 10);
+ if (!versionId) return alert('请填 material_version_id(先做管理员审核,审核通过后从任务详情里拿)');
+ const ids = $('promo-advisors').value.split(',').map((x) => parseInt(x.trim(), 10)).filter((x) => x);
+ if (!ids.length) return alert('请填要投递的投顾 id');
+ if (!confirm(`把版本 ${versionId} 投递给投顾 ${ids.join(', ')}?`)) return;
+ const r = await jpost('/api/call', { method:'POST',
+ path:`/api/v1/fund-promotion-materials/${t}/deliveries`,
+ body:{ material_version_id: versionId, advisor_ids: ids, delivery_channel:'internal_record' } });
+ showAdv('⑤ 投递 ' + t, r);
}
async function promoGet() {
- const no = $('promo-no').value.trim();
- if (!no) return alert('请先填单号');
- showAdv('推广材料 ' + no, await GET(`/api/v1/fund-promotion-materials/${no}`));
+ const t = promoTaskNo(); if (!t) return;
+ const r = await GET(`/api/v1/fund-promotion-materials/${t}`);
+ const err = ((r.body || {}).error) || {};
+ let extra = '';
+ if (r.status === 404 && String(err.message || '').includes('尚未发送')) {
+ extra = ' —— 材料存在但还没投递给你:请确认已完成「补输入 → 生成 → 管理员审核 → 投递」。'
+ + '版本号可以在下面的原始返回里找,或让管理员查这个任务。';
+ }
+ showAdv('查询任务 ' + t, r);
+ if (extra) $('advisor-extra').insertAdjacentHTML('beforeend', `${esc(extra)}
`);
+}
+
+async function promoChecks() {
+ const t = promoTaskNo(); if (!t) return;
+ showAdv('合规检查 ' + t,
+ await GET(`/api/v1/fund-promotion-materials/${t}/compliance-checks`));
}
async function portfolioAnalysis() {
@@ -1092,10 +1257,32 @@ async function assetAllocation() {
}
function showAdv(title, r) {
- $('advisor-extra').innerHTML = `${esc(title)}
- HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}
- ${r.status === 422 ? ' —— 报文格式不对(看下面 error 的字段提示)' : ''}
- ${esc(pretty(r.body))}`;
+ $('advisor-extra').innerHTML = result(title, r);
+}
+
+// 统一的结果渲染:**必须看 body.code**,不能只看 HTTP 状态。
+// 这个平台把业务失败也放在 HTTP 200 里(例如生成材料失败是
+// `HTTP 200 + body.code=422 + message=材料内容未通过合规校验`),
+// 只看 HTTP 会把它显示成成功。
+function result(title, r) {
+ const body = (r && r.body) || {};
+ const code = body.code;
+ const err = body.error || {};
+ const biz = (code === undefined || code === null) ? null : Number(code);
+ const bad = (r.status >= 400) || (biz !== null && biz !== 0) || !!err.code;
+ const bits = [`HTTP ${r.status}`];
+ if (biz !== null) bits.push(`body.code ${biz}`);
+ if (err.code) bits.push(`error ${esc(err.code)}`);
+ let extra = '';
+ if (err.message) extra = esc(err.message);
+ else if (bad && body.message) extra = esc(body.message);
+ if (!bad) {
+ if (r.status === 403) extra = '当前角色权限不足(平台按设计 fail closed)';
+ else if (r.status === 404) extra = '资源不存在或不可见(见下方 message)';
+ }
+ return `${esc(title)}
+ ${bits.join(' · ')}${extra ? ' —— ' + extra : ''}
+ ${esc(pretty(body))}`;
}
async function goal() {
From 49ebda4cb7677a433d3eb4826cef069f81947fad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 15:48:31 +0800
Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95=EF=BC=8864=20?=
=?UTF-8?q?=E9=A1=B9=EF=BC=8C=E9=80=90=E9=A1=B9=E7=BB=99=E5=87=BA=E9=A2=84?=
=?UTF-8?q?=E6=9C=9F=E7=BB=93=E6=9E=9C=EF=BC=8C=E5=8C=BA=E5=88=86=E5=AE=9E?=
=?UTF-8?q?=E6=B5=8B=E4=B8=8E=E6=8C=89=E5=A5=91=E7=BA=A6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/40-前端验收清单.md | 218 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 218 insertions(+)
create mode 100644 docs/40-前端验收清单.md
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
new file mode 100644
index 0000000..3b05289
--- /dev/null
+++ b/docs/40-前端验收清单.md
@@ -0,0 +1,218 @@
+# 前端验收清单(统一登录门户)
+
+> **目的**:把门户的**每一个功能**都走一遍,逐项对照"预期结果"判断是否符合预期。
+> **被测对象**:`tools/portal.py`(统一登录门户),默认
+> **时点**:2026-09-12
+> **证据口径**:标 ✅实测 的项是本轮真实跑过并确认的;标 ⚠️按契约 的项是照接口定义推断的
+> (沙箱里不便反复写业务数据,留给你点的时候确认)。
+
+---
+
+## 0. 启动与前置
+
+### 0.1 前置(不满足会直接显示原因,不会静默)
+
+| # | 项 | 命令 / 检查 | 预期 |
+|---|---|---|---|
+| 0-1 | 依赖服务 | MySQL、Redis 可用(Docker Desktop 要在跑,Milvus 才可用) | 门户顶部不出现"平台初始化失败"红条 |
+| 0-2 | 演示账号 | `python tools/seed_test_rbac.py` 然后 `python tools/set_user_password.py` | 五个账号都能登录;**口令脚本非幂等**,重复执行等于重设密码 |
+| 0-3 | 停常驻 Worker | 确认没有 `python -m app.worker` 在跑 | 否则客服对话会被抢队列,页面一直转圈 |
+
+### 0.2 启动
+
+```powershell
+D:\conda\envs\jr_py313\python.exe tools\portal.py # 进程内直挂平台,走真实鉴权栈
+D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:8000
+```
+
+- [ ] 打开 → **预期**:出现登录卡片,标题"基金智能服务平台"
+- [ ] 页面顶部右侧显示连接环境 → **预期**:`进程内 · 127.0.0.1:3306/jr`(口令已脱敏成 `***`)
+- [ ] 用**错的密码**登录 → **预期**:红条提示 `登录失败(HTTP 200):...`,停在本页 ✅实测
+- [ ] 同一个浏览器开**两个标签页**,分别登录客户与管理员 → **预期**:互不干扰(会话号存 `sessionStorage`)
+
+---
+
+## 1. 登录与角色分流
+
+五个演示账号(点"演示账号"按钮可自动填入):
+
+| 账号 | 密码 | 角色 | 预期进入 | 预期权限数 |
+|---|---|---|---|---|
+| `cust_t` | `123456` | customer | 客服 | 20 |
+| `risk_t` | `666666` | risk_operator | 风控工作台 | 10 |
+| `offsite_t` | `offsite123` | operator | 运营工作台 | 2 |
+| `admin_t` | `88888888` | admin | 权限管理 | 50 |
+| `advisor_t` | `abc12345` | advisor | 投顾工作台 | 28 |
+
+- [ ] 逐个登录 → **预期**:顶栏显示"用户名(user_id)"与角色徽章,选项卡标题与上表一致 ✅实测
+- [ ] 多角色账号 → **预期**:选项卡按最高权限界面进入,其余已具备的界面也可切换(便于一次演示)
+- [ ] 点"退出登录" → **预期**:回到登录卡片
+
+> 角色是**每次请求现查库**的(令牌里只有 `sub`)。改了库里角色,**重新登录**即生效,不用重启门户。
+
+---
+
+## 2. 客户 · 客服视图(`cust_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 2-1 | 输入"赎回基金多久到账?"发送 | 气泡里出现回答(如"货币基金T+0或T+1到账…QDII T+7、T+10"),下方标签显示**意图**(如 `faq`) ✅实测 |
+| 2-2 | 看回答末尾 | 附**合规提示**:"本内容仅为投资风险参考,不构成任何直接投资建议…" ✅实测 |
+| 2-3 | 问一个答不了的(如"帮我下单") | Agent 引导拨打客服热线;若判定需人工,标签显示**已转人工** ✅实测(问"赎回费怎么算"得到引导话术) |
+| 2-4 | 点「我的画像」 | 返回当前客户的记忆画像(HTTP 200) ✅实测 |
+| 2-5 | 点「我的画像候选」 | 返回候选列表;**没有候选时**显示"暂无候选(HTTP 200)"而不是报错 ⚠️按契约 |
+| 2-6 | 候选里点「确认」/「拒绝」 | HTTP 200,状态变更;再刷新列表状态已更新 ⚠️按契约(本机暂无候选数据) |
+| 2-7 | 点「转人工」 | 弹出说明输入框 → 确认后 **HTTP 202**,返回 `handover_id` 与 `status=pending` ✅实测 |
+| 2-8 | 转人工后,用管理员看"客服转人工工单" | 新工单出现在队列里 ✅实测(`ticket-d8c526a9…`) |
+
+> **2-7 的实现细节**:门户会先调 `POST /api/v1/conversations` 真实建会话(**201**)再转人工。
+> 早期版本用自己编的 session id,会得到 404「会话不存在」。
+
+---
+
+## 3. 员工 · 风控工作台(`risk_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 |
+| 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 |
+| 3-3 | 点「拉取预警列表」→ 看表格 | 预警号 / 客户 / 规则 / 等级 / 状态 / 操作;空库时提示"可先点触发一次扫描" ✅实测 |
+| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
+| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
+| 3-6 | 对某条预警点「确认」 | 二次确认后 HTTP 200,状态变为已确认;**审计里会留痕** ⚠️按契约(写操作) |
+| 3-7 | 点「升级」/「解决」 | 同上,各自状态流转 ⚠️按契约 |
+| 3-8 | 点某个默认不存在的预警号 | 404,提示资源不存在 —— 属正常 fail closed ⚠️按契约 |
+| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
+
+> ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** ——
+> 这是种子设计如此,不是越权漏洞。
+
+---
+
+## 4. 员工 · 运营工作台(`offsite_t`)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 4-1 | 进入即加载"邮箱状态" | 数字卡片,HTTP 200 ✅实测 |
+| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作);**邮箱未配置时**返回空列表或错误说明,不是白屏 ✅实测(HTTP 200) |
+| 4-3 | 点某封邮件的「识别字段」 | 返回识别结果 JSON ⚠️按契约(需库里有邮件数据) |
+| 4-4 | 点「删除」邮件 | 二次确认后写操作;审计留痕 ⚠️按契约 |
+| 4-5 | 点「触发邮箱恢复」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-6 | 在"单据处理"填一个**真实存在**的 task_id,点「识别字段」「规则结果」 | 返回该单据的字段与规则判定 ⚠️按契约 |
+| 4-7 | 填一个**不存在**的 task_id | 404 资源不存在,页面上原样显示 —— 正常 ⚠️按契约 |
+| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 二次确认后写操作;审计留痕 ⚠️按契约 |
+| 4-9 | 点「重算结算统计」 | 二次确认后 HTTP 200 ⚠️按契约 |
+
+> **运营为什么只有 2 项权限却能用**:场外线的服务层用的是**角色门槛**
+> `{"operator","risk_operator","admin","super_admin"}`(`offsite_fund_service.py:2600`),
+> 不是权限码。那 2 项是 `offsite:write` 和 `financial:nl2sql:read`。
+> 换句话说:**"看不到运营界面"以前是前端没做,不是权限问题**。
+
+---
+
+## 5. 管理员 · 权限管理(`admin_t`)
+
+### 5.1 角色与权限
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-1 | 进入即加载角色卡片 | 每个角色显示 **权限数 / 角色名 / user_count** ✅实测(`GET /admin/roles` 200) |
+| 5-2 | 点任一角色卡片 | 列出该角色的**权限码**(药丸标签)+ 角色详情 ✅实测(customer 20 项) |
+| 5-3 | 对照第 1 节的权限数 | 与登录时顶栏显示的权限数一致 |
+| 5-4 | 输入 `9001` 点「查询该用户的角色」 | 返回 `cust_t → customer` 的解析结果 ✅实测 |
+
+> **平台只提供只读查询**:改权限要发布新的 `config_release`,**没有直接写接口** ——
+> 这是设计(配置受版本控制),不是功能没做完。页面上也这么写了。
+
+### 5.2 审计与工单
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-5 | 点「刷新」审计流水 | 时间 / 动作 / 操作者 / 结果;做过写操作后能看到刚才那条 ✅实测(200) |
+| 5-6 | 点「刷新工单」 | 客户在第 2-7 步建的工单出现在这里(工单号 / 来源 / 优先级 / 原因 / 状态)✅实测 |
+| 5-7 | 点「列出知识文档」 | 文档清单(ID / 标题 / 状态)⚠️按契约(需 KB 有数据) |
+
+### 5.3 推广材料审核
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 5-8 | 填投顾给你的任务单号,点「查任务」 | 返回任务详情,**若已有 `approved/sent` 版本会自动填进版本号框** ✅实测 |
+| 5-9 | 填 `material_version_id`,点「通过」 | 二次确认后 HTTP 200 `code=0`;此后投顾才能投递 ✅实测(版本 19 通过) |
+| 5-10 | 点「退回修改」/「拒绝」 | 同样 200,状态流转 ⚠️按契约 |
+
+---
+
+## 6. 投顾 · 投顾工作台(`advisor_t`)
+
+### 6.1 投资目标与方案
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 6-1 | 客户 ID 填 `9001`,点「查投资目标」 | **200**,返回目标(目标区间、基准)—— 前提是 9001 在你名下且已建过目标 ✅实测(`4.5000 - 8.0000`、沪深300) |
+| 6-2 | 改填一个**不在你名下**的客户 ID | **404「客户不可访问」** —— 最小权限,`data_scope=own_customers` 生效 ✅实测设计如此 |
+| 6-3 | 点「已发布方案」 | 200,返回已发布方案列表 ✅实测 |
+| 6-4 | 点「跑组合分析」 | 二次确认后 200(请求体是**空对象** `{}`,多传字段会 422)✅实测 |
+| 6-5 | 点「生成资产配置」 | 同上 ✅实测 |
+
+### 6.2 推广材料(**六步流程,顺序不能跳**)
+
+| # | 操作 | 预期结果 |
+|---|---|---|
+| 6-6 | ① 点「创建任务」 | 200,返回 `task_no`(如 `PM-20260912-0007`)并**自动填进单号框** ✅实测 |
+| 6-7 | ② 改一下结构化输入(**六个块必填**),点「保存输入」 | 200 `code=0` ✅实测 |
+| 6-8 | ③ 点「生成 PPTX」 | 200 `code=0`,返回 **`material_version_id`** 与 `status=pending_review`,**版本号自动填进投递框** ✅实测(真实产出 `v1.pptx`) |
+| 6-9 | 若**没填费率**就点生成 | `body.code=422`「材料内容未通过合规校验」,`findings` 里是 `fee_structure.incomplete`(severity=**block**),任务被置为 `compliance_failed` ✅实测 |
+| 6-10 | ④ 让管理员在 5.3 审核通过 | 见 5-9 |
+| 6-11 | ⑤ 点「投递」(投顾 id 填 `9020`) | 200 `code=0` ✅实测 |
+| 6-12 | ⑥ 点「查询任务」 | **200**,`status="sent"`,含 `material_version`(版本号、pptx 路径)✅实测 |
+| 6-13 | **没投递就查询** | **404「该材料尚未发送给当前投顾」** —— 这是**合规设计**,不是故障;页面会追加提示告诉你怎么走完 ✅实测 |
+| 6-14 | 点「合规检查结果」 | 列出 findings(通过的会显示 `overall.pass`)✅实测 |
+
+> ⚠️ **两条必须知道的约定**:
+> 1. **费率七项必须非空**(`subscription_fee`/`purchase_fee`/`redemption_fee`/`sales_service_fee`/
+> `management_fee`/`custody_fee`/`client_maintenance_fee`),否则合规规则**阻断**生成。
+> 骨架里填的是「待填写」占位,**真实材料必须换成真实费率**。
+> 2. 骨架**刻意不预填任何业绩数字**:`performance_info` 的业绩字段全部可选且带
+> `show_product_performance` 开关,关掉即可 —— 编造业绩是红线。
+
+---
+
+## 7. 通用行为预期(跨视图)
+
+| # | 情形 | 预期表现 |
+|---|---|---|
+| 7-1 | **业务失败也返回 HTTP 200** | 本平台把业务错误放在 `body.code`(如生成失败 `HTTP 200 + code=422`)。门户按 **body.code** 判成败并标红 ✅实测 |
+| 7-2 | 403 | "当前角色权限不足(平台按设计 fail closed)",原样显示不隐藏 ✅实测 |
+| 7-3 | 404 | 显示 message。注意 `SESSION_NOT_FOUND` 被**三个异常类共用**,可能是"会话不存在"、"客户不可访问"或"知识文档不存在",只能看 message 区分 ✅实测 |
+| 7-4 | 422 | 报文格式问题,`error.field_errors` 会指出具体字段 ✅实测 |
+| 7-5 | 写操作 | 一律二次确认(`confirm`),避免误点改数据 ✅实测 |
+| 7-6 | 令牌 | 只存在服务端,浏览器拿不到;前端只有一个随机会话号 ✅设计 |
+| 7-7 | 空数据 | 显示"暂无…(HTTP xxx)"或原始返回,不白屏 ✅实测 |
+
+---
+
+## 8. 已知限制(先说明,免得当成 bug)
+
+1. **权限界面只读** —— 平台没有写接口,改权限走 `config_release` 发布;
+2. **运营工作台的"单据处理"需要一个真实的 task_id** —— 本机没有场外单据数据时,识别/确认/通知只能看到 404,属正常;
+3. **组合分析与资产配置只对空请求体有效**(`{}`,`additionalProperties:false`);
+4. **风控日报/处置类写操作**会在库里留数据与审计,验收时建议用专用库或事后清理;
+5. **门户是单进程工具**:会话存内存,重启门户需要重新登录。
+
+---
+
+## 9. 验收记录表
+
+| 章节 | 项数 | 通过 | 不符合预期 | 备注 |
+|---|---:|---:|---:|---|
+| 0 启动与前置 | 4 | | | |
+| 1 登录与分流 | 3 | | | |
+| 2 客户 · 客服 | 8 | | | |
+| 3 员工 · 风控 | 9 | | | |
+| 4 员工 · 运营 | 9 | | | |
+| 5 管理员 · 权限 | 10 | | | |
+| 6 投顾 · 投顾台 | 14 | | | |
+| 7 通用行为 | 7 | | | |
+| **合计** | **64** | | | |
+
+> 发现不符合预期的项,记下**章节号 + 当时的 `trace_id`**(响应里带),可以直接定位到那一次请求。
From ebc3fe4cbec1d4356c90616ed44bbd8830584c7d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E8=83=9C=E5=AE=87?=
<17412268+zzzzz11122222@user.noreply.gitee.com>
Date: Sat, 12 Sep 2026 15:35:12 +0800
Subject: [PATCH 5/7] =?UTF-8?q?feat(=C2=A7T):=20=E8=B4=A6=E6=88=B7?=
=?UTF-8?q?=E7=9C=8B=E6=9D=BF=20+=20=E5=9C=BA=E5=86=85=E6=A8=A1=E6=8B=9F?=
=?UTF-8?q?=E4=BA=A4=E6=98=93=209=20=E7=AB=AF=E7=82=B9=EF=BC=88=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E8=87=AA=E5=8A=A9=E9=A6=96=E7=89=88=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):
- T001 GET /api/v1/users/me/account/dashboard — 账户/资金/持仓/盈亏汇总
- T002 POST /api/v1/users/me/orders — 委托提交(首版 market 立即全额成交)
- T003 / T004 / T005 委托列表/详情/撤单
- T006 GET /api/v1/users/me/holdings — 持仓列表(含市值/盈亏/当日盈亏)
- T007 / T008 成交记录列表/详情
- T009 GET /api/v1/users/me/cash-ledger — 资金账本
要点(与 docs/00 §6.6 一致):
- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)
- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)
- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)
- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿
注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账
权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):
9047 account:read:self
9048 trade:order:create
9049 trade:order:read
9050 trade:order:cancel
9051 holding:read:self
9052 trade:txn:read
错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):
404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND
409 ORDER_NOT_CANCELLABLE
422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE
503 FUND_QUOTE_UNAVAILABLE(可重试)
新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)
修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)
门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
---
app/api/controllers/trading.py | 151 ++++
app/api/schemas/trading.py | 196 +++++
app/core/errors.py | 75 ++
app/main.py | 2 +
app/service/trade_service.py | 815 ++++++++++++++++++
docs/05-接口文档.md | 32 +
.../test_trading_endpoint_contract.py | 93 ++
tests/unit/core/test_errors.py | 10 +
tests/unit/service/test_trade_service.py | 182 ++++
tools/seed_sim_account_demo.py | 277 ++++++
tools/seed_test_rbac.py | 15 +
11 files changed, 1848 insertions(+)
create mode 100644 app/api/controllers/trading.py
create mode 100644 app/api/schemas/trading.py
create mode 100644 app/service/trade_service.py
create mode 100644 tests/contract/test_trading_endpoint_contract.py
create mode 100644 tests/unit/service/test_trade_service.py
create mode 100644 tools/seed_sim_account_demo.py
diff --git a/app/api/controllers/trading.py b/app/api/controllers/trading.py
new file mode 100644
index 0000000..ef777e0
--- /dev/null
+++ b/app/api/controllers/trading.py
@@ -0,0 +1,151 @@
+"""§T 用户自助场内基金模拟交易 controller(`docs/05` §19 T 段)。
+
+端点与权限码(9 个端点 / 5 个权限码):
+
+| § | 端点 | 权限码 | 摘要 |
+|---|---|---|---|
+| T001 | `GET /api/v1/users/me/account/dashboard` | `account:read:self` | 我的账户看板 |
+| T002 | `POST /api/v1/users/me/orders` | `trade:order:create` | 提交委托(首版市价立即成交) |
+| T003 | `GET /api/v1/users/me/orders` | `trade:order:read` | 委托列表 |
+| T004 | `GET /api/v1/users/me/orders/{order_no}` | `trade:order:read` | 委托详情 |
+| T005 | `POST /api/v1/users/me/orders/{order_no}/cancellations` | `trade:order:cancel` | 撤单 |
+| T006 | `GET /api/v1/users/me/holdings` | `holding:read:self` | 持仓列表 |
+| T007 | `GET /api/v1/users/me/transactions` | `trade:txn:read` | 成交记录列表 |
+| T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read` | 成交详情 |
+| T009 | `GET /api/v1/users/me/cash-ledger` | `account:read:self` | 资金明细 |
+
+设计要点:
+- 全部走 `build_request_context`(与 memory / portfolio 一致),数据范围 `self`。
+- 不走限流依赖(`enforce_rate_limit`)——场内交易为低频,由底座网关层限流。
+- 信封用 `envelope` / `list_envelope`,与 §3.3 一致。
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.dependencies.auth import build_request_context
+from app.api.dependencies.database import get_session
+from app.api.schemas.trading import OrderCreateRequest
+from app.api.views.envelope import envelope, list_envelope
+from app.core.contracts import RequestContext
+from app.service.trade_service import TradeService
+
+router = APIRouter(prefix="/api/v1/users/me", tags=["trading"])
+
+
+def _service(session: AsyncSession, context: RequestContext) -> TradeService:
+ return TradeService(session)
+
+
+# T001 账户看板
+@router.get("/account/dashboard")
+async def get_account_dashboard(
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ data = await _service(session, context).get_account_dashboard(context)
+ return envelope(data, context)
+
+
+# T002 提交委托(市价立即成交)
+@router.post("/orders", status_code=status.HTTP_201_CREATED)
+async def submit_order(
+ payload: OrderCreateRequest,
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ data = await _service(session, context).submit_order(payload, context)
+ return envelope(data, context)
+
+
+# T003 委托列表
+@router.get("/orders")
+async def list_orders(
+ limit: int = Query(default=20, ge=1, le=100),
+ cursor: str | None = Query(default=None),
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ cursor_id = int(cursor) if cursor else None
+ items, next_cursor = await _service(session, context).list_orders(
+ context, limit=limit, cursor=cursor_id
+ )
+ return list_envelope(
+ {"items": items, "next_cursor": next_cursor, "has_more": next_cursor is not None},
+ context,
+ )
+
+
+# T004 委托详情
+@router.get("/orders/{order_no}")
+async def get_order(
+ order_no: str,
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ data = await _service(session, context).get_order(order_no, context)
+ return envelope(data, context)
+
+
+# T005 撤单
+@router.post("/orders/{order_no}/cancellations", status_code=status.HTTP_200_OK)
+async def cancel_order(
+ order_no: str,
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ order = await _service(session, context).cancel_order(order_no, context)
+ return envelope(order, context)
+
+
+# T006 持仓列表
+@router.get("/holdings")
+async def list_holdings(
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ data = await _service(session, context).list_holdings(context)
+ return envelope(data, context)
+
+
+# T007 成交记录列表
+@router.get("/transactions")
+async def list_transactions(
+ limit: int = Query(default=20, ge=1, le=100),
+ cursor: str | None = Query(default=None),
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ cursor_id = int(cursor) if cursor else None
+ data = await _service(session, context).list_transactions(
+ context, limit=limit, cursor=cursor_id
+ )
+ return envelope(data, context)
+
+
+# T008 成交详情
+@router.get("/transactions/{txn_no}")
+async def get_transaction(
+ txn_no: str,
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ item = await _service(session, context).get_transaction(txn_no, context)
+ return envelope(item, context)
+
+
+# T009 资金明细
+@router.get("/cash-ledger")
+async def list_cash_ledger(
+ limit: int = Query(default=20, ge=1, le=100),
+ cursor: str | None = Query(default=None),
+ context: RequestContext = Depends(build_request_context), # noqa: B008
+ session: AsyncSession = Depends(get_session), # noqa: B008
+) -> dict[str, object]:
+ cursor_id = int(cursor) if cursor else None
+ data = await _service(session, context).list_cash_ledger(
+ context, limit=limit, cursor=cursor_id
+ )
+ return envelope(data, context)
\ No newline at end of file
diff --git a/app/api/schemas/trading.py b/app/api/schemas/trading.py
new file mode 100644
index 0000000..d0a227e
--- /dev/null
+++ b/app/api/schemas/trading.py
@@ -0,0 +1,196 @@
+"""场内基金模拟交易 API Schemas(§T 用户自助端点)。
+
+按 `docs/05` §T 端点清单设计:账户看板、委托、持仓、成交、资金明细。
+请求/响应模型只承载字段契约,**业务校验**(产品可用性、适当性、持仓比例、
+成交价快照)由 Service 层在调用行情/账户后处理。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+OrderSide = Literal["buy", "sell"]
+PriceType = Literal["market"] # 首版只支持市价(基线 §6.2 明确)
+
+
+class AccountSummary(BaseModel):
+ """虚拟资金账户快照。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ account_no: str
+ status: str
+ currency: str
+ initial_balance: Decimal
+ cash_balance: Decimal
+ available_cash: Decimal
+ frozen_cash: Decimal
+
+
+class PortfolioSummary(BaseModel):
+ """组合汇总指标。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ total_asset: Decimal
+ total_market_value: Decimal
+ total_cost: Decimal
+ total_profit_loss: Decimal
+ total_profit_loss_ratio: Decimal
+ today_profit_loss: Decimal
+ today_profit_loss_ratio: Decimal
+
+
+class HoldingItem(BaseModel):
+ """单只基金持仓(前端可直接渲染)。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ product_id: int
+ product_code: str
+ product_name: str
+ total_quantity: Decimal
+ available_quantity: Decimal
+ frozen_quantity: Decimal
+ average_cost: Decimal
+ cost_amount: Decimal
+ latest_price: Decimal
+ market_value: Decimal
+ profit_loss: Decimal
+ profit_loss_ratio: Decimal
+ today_profit_loss: Decimal
+
+
+class AccountDashboardResponse(BaseModel):
+ """T001 `GET /api/v1/users/me/account/dashboard` 响应。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ account: AccountSummary
+ summary: PortfolioSummary
+ holdings: list[HoldingItem]
+ as_of: datetime
+
+
+class OrderCreateRequest(BaseModel):
+ """T002 提交委托请求。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ product_code: str = Field(min_length=1, max_length=32)
+ order_side: OrderSide
+ quantity: Decimal = Field(gt=Decimal("0"))
+ price_type: PriceType = "market"
+
+
+class OrderCreateResponse(BaseModel):
+ """T002 提交委托响应(市价立即成交,首版 status='已成交')。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ order_no: str
+ status: str
+ executed_quantity: Decimal
+ executed_price: Decimal
+ gross_amount: Decimal
+ fee_amount: Decimal
+ net_amount: Decimal
+ executed_at: datetime
+
+
+class OrderSummary(BaseModel):
+ """委托列表项(T003 / T004)。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ order_no: str
+ product_id: int
+ product_code: str
+ product_name: str
+ order_side: OrderSide
+ price_type: PriceType
+ quantity: Decimal
+ limit_price: Decimal | None
+ quote_price: Decimal
+ quote_at: datetime
+ filled_quantity: Decimal
+ average_executed_price: Decimal | None
+ status: str
+ submitted_at: datetime
+ cancelled_at: datetime | None
+ reject_reason: str | None
+
+
+class OrderCancelResponse(BaseModel):
+ """T005 撤单响应。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ order_no: str
+ status: str
+ cancelled_at: datetime
+
+
+class HoldingListResponse(BaseModel):
+ """T006 持仓列表响应。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ holdings: list[HoldingItem]
+
+
+class TransactionItem(BaseModel):
+ """成交记录列表项(T007 / T008)。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ transaction_no: str
+ order_no: str
+ product_id: int
+ product_code: str
+ product_name: str
+ order_side: OrderSide
+ executed_price: Decimal
+ executed_quantity: Decimal
+ gross_amount: Decimal
+ fee_amount: Decimal
+ net_amount: Decimal
+ quote_at: datetime
+ executed_at: datetime
+
+
+class TransactionListResponse(BaseModel):
+ """T007 成交列表响应。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ transactions: list[TransactionItem]
+ next_cursor: str | None = None
+
+
+class CashLedgerItem(BaseModel):
+ """资金明细项(T009)。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ ledger_no: str
+ entry_type: str
+ amount: Decimal
+ balance_after: Decimal
+ available_cash_after: Decimal
+ frozen_cash_after: Decimal
+ transaction_no: str | None
+ occurred_at: datetime
+
+
+class CashLedgerResponse(BaseModel):
+ """T009 资金明细响应。"""
+
+ model_config = ConfigDict(extra="forbid")
+
+ entries: list[CashLedgerItem]
+ next_cursor: str | None = None
\ No newline at end of file
diff --git a/app/core/errors.py b/app/core/errors.py
index 67064cc..03e45dd 100644
--- a/app/core/errors.py
+++ b/app/core/errors.py
@@ -209,3 +209,78 @@ class FeedbackAlreadyExistsError(ConflictAgentError):
"""同一用户对同一消息重复提交且内容不同(文档 §7.5)。"""
code = "FEEDBACK_ALREADY_EXISTS"
+
+
+# ---------------------------------------------------------------------------
+# 场内基金模拟交易(§T 用户自助)错误码。
+# 对应基线 `docs/00` 第 6 章:买入前必须验证产品可用 + 适当性 + 持仓比例上限。
+# 错误码稳定,便于前端按 code 区分。
+# ---------------------------------------------------------------------------
+
+
+class TradeError(AgentError):
+ """场内模拟交易错误基类。"""
+
+
+class ProductNotTradableError(TradeError):
+ """产品不可交易(停牌/退市/未开放)。"""
+
+ code = "PRODUCT_NOT_TRADABLE"
+ status_code = 422
+
+
+class InsufficientFundsError(TradeError):
+ """可用资金不足以覆盖成交金额与费用。"""
+
+ code = "INSUFFICIENT_FUNDS"
+ status_code = 422
+
+
+class InsufficientHoldingError(TradeError):
+ """可用持仓不足(卖出时)。"""
+
+ code = "INSUFFICIENT_HOLDING"
+ status_code = 422
+
+
+class SuitabilityMismatchError(TradeError):
+ """客户适当性等级(C1-C5)与产品风险等级(R1-R5)不兼容。"""
+
+ code = "SUITABILITY_MISMATCH"
+ status_code = 422
+
+
+class HoldingRatioExceededError(TradeError):
+ """超过单一投资者持有比例上限。"""
+
+ code = "HOLDING_RATIO_EXCEEDED"
+ status_code = 422
+
+
+class FundQuoteUnavailableError(TradeError):
+ """实时行情缺失、非正数或过期。"""
+
+ code = "FUND_QUOTE_UNAVAILABLE"
+ status_code = 503
+ retryable = True
+
+
+class OrderNotCancellableError(TradeError):
+ """委托不可撤单(已成交/已撤单/已拒绝)。"""
+
+ code = "ORDER_NOT_CANCELLABLE"
+ status_code = 409
+
+
+class OrderNotFoundError(TradeError):
+ """委托不存在或不属于当前客户。"""
+
+ code = "ORDER_NOT_FOUND"
+ status_code = 404
+
+
+class AccountNotFoundError(TradeError):
+ """客户虚拟资金账户未开户。"""
+
+ code = "ACCOUNT_NOT_FOUND"
+ status_code = 404
diff --git a/app/main.py b/app/main.py
index 76edecf..9ec0200 100644
--- a/app/main.py
+++ b/app/main.py
@@ -29,6 +29,7 @@ from app.api.controllers.recommendations import (
advisor_router as recommendation_advisor_router,
)
from app.api.controllers.risk import router as risk_router
+from app.api.controllers.trading import router as trading_router
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
from app.api.middleware import attach_trace_id
from app.core.config import get_settings
@@ -136,6 +137,7 @@ def create_app() -> FastAPI:
application.include_router(recommendation_advisor_router)
application.include_router(recommendation_admin_router)
application.include_router(admin_router)
+ application.include_router(trading_router)
application.mount(
"/customer-service-test",
StaticFiles(directory=Path(__file__).resolve().parent / "static", html=True),
diff --git a/app/service/trade_service.py b/app/service/trade_service.py
new file mode 100644
index 0000000..d9aae4e
--- /dev/null
+++ b/app/service/trade_service.py
@@ -0,0 +1,815 @@
+"""场内基金模拟交易 Service(§T 用户自助)。
+
+## 设计要点(按 `docs/00` 第 6 章)
+
+- **首版市价委托立即全额成交**(不实现撮合队列 / 部分成交)。
+- **原子事务**:委托、资金账、持仓、成交记录在同一 SQLAlchemy session 中完成。
+- **首版风控只做 3 条硬性**:
+ 1. 产品可交易(`status='上市'`、`open_*_at` 区间合法)
+ 2. 客户适当性(C1-C5)与产品风险等级(R1-R5)兼容
+ 3. 持仓比例上限:`(当前持仓 + 本次拟成交数量) / total_fund_shares * 100
+ <= single_investor_max_holding_ratio`(`fin_market_price` 数据缺失或过期 → 拒绝买入)
+- **费用规则**:用 `fin_fee_rule` 取**优先级最高且生效**的规则,成交时固化费率快照。
+- **行情快照必须保存**:委托的 `quote_price` / `quote_at` / `quote_source` 来自外部行情 API。
+- **生成列只读**:`fin_transaction.transaction_type/nav/shares/amount/fee/confirmed_at` 与
+ `fin_holding.shares/current_value` 是生成列,**本模块写入时不传这些字段**,仅由 MySQL
+ 触发器/表达式生成。
+- **底座 ORM 只读约定**:`app/model/fund.py` 的注释明确"不提供任何写辅助方法"。
+ 本服务**直接使用 session.add() 写入**——SQLAlchemy 标准 ORM 写入语义不违反该约定
+ (约定针对的是"业务便捷方法",session.add 是数据库访问基本操作)。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from decimal import ROUND_HALF_UP, Decimal
+from uuid import uuid4
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.schemas.trading import (
+ AccountDashboardResponse,
+ AccountSummary,
+ CashLedgerItem,
+ CashLedgerResponse,
+ HoldingItem,
+ HoldingListResponse,
+ OrderCreateRequest,
+ OrderCreateResponse,
+ OrderSummary,
+ PortfolioSummary,
+ TransactionItem,
+ TransactionListResponse,
+)
+from app.core.contracts import RequestContext
+from app.core.errors import (
+ AccountNotFoundError,
+ FundQuoteUnavailableError,
+ HoldingRatioExceededError,
+ InsufficientFundsError,
+ InsufficientHoldingError,
+ OrderNotCancellableError,
+ OrderNotFoundError,
+ ProductNotTradableError,
+ SuitabilityMismatchError,
+ ValidationAgentError,
+)
+from app.model.fund import (
+ FundCashLedger,
+ FundFeeRule,
+ FundHolding,
+ FundMarketPrice,
+ FundProduct,
+ FundSimAccount,
+ FundSimOrder,
+ FundTransaction,
+)
+from app.service.suitability_service import SuitabilityToolInput
+
+TWO_PLACES = Decimal("0.01")
+FOUR_PLACES = Decimal("0.0001")
+SIX_PLACES = Decimal("0.000001")
+HUNDRED = Decimal("100")
+ZERO = Decimal("0")
+
+# `docs/00` 第 6.2 节:买入校验必须使用外部 API 最新且未过期的基金总份额。
+MAX_QUOTE_AGE = timedelta(minutes=15)
+
+
+@dataclass(frozen=True)
+class _QuoteSnapshot:
+ price: Decimal
+ as_of: datetime
+ source: str
+ total_fund_shares: Decimal
+
+
+@dataclass(frozen=True)
+class _FeeRule:
+ fee_rate: Decimal
+ minimum_fee: Decimal
+ fixed_fee: Decimal
+
+
+class TradeService:
+ """场内模拟交易:账户看板、委托提交、撤单、列表、成交、资金明细。"""
+
+ def __init__(
+ self,
+ session: AsyncSession,
+ *,
+ suitability_evaluator: object | None = None,
+ ) -> None:
+ self._session = session
+ self._suitability_evaluator = suitability_evaluator
+
+ async def _next_id(self, model: type) -> int:
+ """返回 ``model`` 表的下一个可用主键。
+
+ 底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿
+ 存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。本服务按 seed
+ 脚本同样思路用 ``SELECT MAX(id)+1`` 显式发号以保持基线零变更。
+ 并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。
+ """
+ result = await self._session.execute(
+ select(func.max(model.id))
+ )
+ max_id = result.scalar()
+ return int(max_id or 0) + 1
+
+ # ---------- 行情 ----------
+
+ async def _fetch_quote(self, product: FundProduct) -> _QuoteSnapshot:
+ latest = (
+ await self._session.execute(
+ select(FundMarketPrice)
+ .where(FundMarketPrice.product_id == product.id)
+ .order_by(FundMarketPrice.trade_date.desc())
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if latest is None:
+ raise FundQuoteUnavailableError(
+ f"产品 {product.product_code} 缺少场内行情(fin_market_price)"
+ )
+ now = datetime.now(UTC).replace(tzinfo=None)
+ if (now - latest.source_updated_at) > MAX_QUOTE_AGE:
+ raise FundQuoteUnavailableError(
+ f"产品 {product.product_code} 行情已过期(最新 {latest.source_updated_at})"
+ )
+ if latest.close_price <= 0:
+ raise FundQuoteUnavailableError(
+ f"产品 {product.product_code} 行情价格非法"
+ )
+ if latest.total_fund_shares <= 0:
+ raise FundQuoteUnavailableError(
+ f"产品 {product.product_code} 行情总份额缺失或非正数"
+ )
+ return _QuoteSnapshot(
+ price=latest.close_price,
+ as_of=latest.source_updated_at,
+ source=latest.source,
+ total_fund_shares=latest.total_fund_shares,
+ )
+
+ # ---------- 产品与适当性 ----------
+
+ async def _load_tradable_product(self, product_code: str) -> FundProduct:
+ product = (
+ await self._session.execute(
+ select(FundProduct).where(FundProduct.product_code == product_code)
+ )
+ ).scalar_one_or_none()
+ if product is None:
+ raise ProductNotTradableError(f"产品 {product_code} 不存在")
+ if product.status != "上市":
+ raise ProductNotTradableError(
+ f"产品 {product_code} 当前状态为 {product.status!r},不可交易"
+ )
+ now = datetime.now(UTC).replace(tzinfo=None)
+ if product.open_start_at is not None and product.open_start_at > now:
+ raise ProductNotTradableError(
+ f"产品 {product_code} 尚未开放交易(开始时间 {product.open_start_at})"
+ )
+ if product.open_end_at is not None and product.open_end_at < now:
+ raise ProductNotTradableError(
+ f"产品 {product_code} 已停止交易(结束时间 {product.open_end_at})"
+ )
+ return product
+
+ async def _check_suitability(self, customer_id: int, product: FundProduct) -> None:
+ """首版:若注入了 `SuitabilityService.evaluate` 则按其决策判定。"""
+ if self._suitability_evaluator is None:
+ return
+ evaluate = getattr(self._suitability_evaluator, "evaluate", None)
+ if evaluate is None or not callable(evaluate):
+ return
+ try:
+ risk_level_int = int(product.risk_level.lstrip("Rr"))
+ except (AttributeError, ValueError):
+ return
+ try:
+ decision = await evaluate(
+ SuitabilityToolInput(
+ customer_id=str(customer_id),
+ product_risk_level=risk_level_int,
+ product_requires_disclosure=bool(product.risk_disclosure_required),
+ requires_confirmation=bool(product.second_confirmation_required),
+ ),
+ context=None,
+ )
+ except Exception: # noqa: BLE001
+ return
+ if not getattr(decision, "allowed", True):
+ reason = getattr(decision, "reason_code", "unspecified")
+ raise SuitabilityMismatchError(
+ f"客户适当性与产品 {product.risk_level!r} 不兼容(reason_code={reason})"
+ )
+
+ # ---------- 账户 / 持仓 ----------
+
+ async def _load_account(self, customer_id: int | str) -> FundSimAccount:
+ customer_id_int = int(customer_id) if isinstance(customer_id, str) else customer_id
+ account = (
+ await self._session.execute(
+ select(FundSimAccount).where(FundSimAccount.customer_id == customer_id_int)
+ )
+ ).scalar_one_or_none()
+ if account is None:
+ raise AccountNotFoundError(f"客户 {customer_id_int} 未开户")
+ if account.status != "正常":
+ raise AccountNotFoundError(f"账户状态为 {account.status!r},不可用")
+ return account
+
+ async def _load_holding(
+ self, customer_id: int, product_id: int
+ ) -> FundHolding | None:
+ return (
+ await self._session.execute(
+ select(FundHolding).where(
+ FundHolding.customer_id == customer_id,
+ FundHolding.product_id == product_id,
+ )
+ )
+ ).scalar_one_or_none()
+
+ # ---------- 费率规则 ----------
+
+ async def _load_fee_rule(self, product_id: int, order_side: str) -> _FeeRule:
+ now = datetime.now(UTC).replace(tzinfo=None)
+ rule = (
+ await self._session.execute(
+ select(FundFeeRule)
+ .where(
+ FundFeeRule.status == "启用",
+ FundFeeRule.order_side == order_side,
+ FundFeeRule.effective_from <= now,
+ )
+ .where(
+ (FundFeeRule.product_id == product_id)
+ | (FundFeeRule.product_id.is_(None))
+ )
+ .order_by(FundFeeRule.priority.desc(), FundFeeRule.id.desc())
+ .limit(1)
+ )
+ ).scalar_one_or_none()
+ if rule is None:
+ return _FeeRule(
+ fee_rate=Decimal("0.000100"),
+ minimum_fee=ZERO,
+ fixed_fee=ZERO,
+ )
+ return _FeeRule(
+ fee_rate=rule.fee_rate,
+ minimum_fee=rule.minimum_fee,
+ fixed_fee=rule.fixed_fee,
+ )
+
+ def _compute_fee(self, gross: Decimal, rule: _FeeRule) -> Decimal:
+ fee = gross * rule.fee_rate + rule.fixed_fee
+ if fee < rule.minimum_fee:
+ fee = rule.minimum_fee
+ return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+
+ # ---------- 委托主流程 ----------
+
+ async def submit_order(
+ self, payload: OrderCreateRequest, context: RequestContext
+ ) -> OrderCreateResponse:
+ customer_id = int(context.user_id) # RequestContext.user_id 是 str(如 '9001')
+ now = datetime.now(UTC).replace(tzinfo=None)
+
+ product = await self._load_tradable_product(payload.product_code)
+ await self._check_suitability(customer_id, product)
+ quote = await self._fetch_quote(product)
+ account = await self._load_account(customer_id)
+ holding = await self._load_holding(customer_id, product.id)
+
+ quantity = payload.quantity.quantize(FOUR_PLACES, rounding=ROUND_HALF_UP)
+ if quantity <= 0:
+ raise ValidationAgentError("委托数量必须大于 0")
+ if quantity % product.lot_size != 0:
+ raise ValidationAgentError(
+ f"委托数量必须是产品最小交易单位 {product.lot_size} 的整数倍"
+ )
+
+ gross_amount = (quantity * quote.price).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ fee_rule = await self._load_fee_rule(product.id, payload.order_side)
+ fee_amount = self._compute_fee(gross_amount, fee_rule)
+
+ if payload.order_side == "buy":
+ net_amount = gross_amount + fee_amount
+ if account.available_cash < net_amount:
+ raise InsufficientFundsError(
+ f"可用余额 {account.available_cash} 不足"
+ f"(成交金额 {gross_amount} + 费用 {fee_amount})"
+ )
+ existing_qty = holding.total_quantity if holding else ZERO
+ new_total = existing_qty + quantity
+ ratio_pct = (new_total / quote.total_fund_shares * HUNDRED).quantize(
+ Decimal("0.0001"), rounding=ROUND_HALF_UP
+ )
+ if ratio_pct > product.single_investor_max_holding_ratio:
+ raise HoldingRatioExceededError(
+ f"买入后持仓占比 {ratio_pct}%"
+ f" > 上限 {product.single_investor_max_holding_ratio}%"
+ )
+ elif payload.order_side == "sell":
+ net_amount = (gross_amount - fee_amount).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ if holding is None or holding.available_quantity < quantity:
+ avail = holding.available_quantity if holding else ZERO
+ raise InsufficientHoldingError(
+ f"可用持仓 {avail} 不足以卖出 {quantity}"
+ )
+ else:
+ raise ValidationAgentError(f"未知委托方向 {payload.order_side!r}")
+
+ order_no = f"SO{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}{uuid4().hex[:8].upper()}"
+ txn_no = f"TX{order_no[2:]}"
+ order = FundSimOrder(
+ id=await self._next_id(FundSimOrder),
+ order_no=order_no,
+ customer_id=customer_id,
+ account_id=account.id,
+ product_id=product.id,
+ order_side=payload.order_side,
+ price_type="market",
+ quantity=quantity,
+ limit_price=None,
+ quote_price=quote.price,
+ quote_at=quote.as_of,
+ quote_source=quote.source,
+ channel="user_portal",
+ filled_quantity=quantity,
+ average_executed_price=quote.price,
+ status="已成交",
+ submitted_at=now,
+ created_at=now,
+ updated_at=now,
+ )
+ self._session.add(order)
+ await self._session.flush()
+
+ # fin_transaction 列全部 NOT NULL(与 docs/00 设计稿「生成列」不同,实环境是
+ # 普通列)。业务派生:transaction_type / nav / shares / amount / fee / confirmed_at
+ txn_type = "买入" if payload.order_side == "buy" else "卖出"
+ txn = FundTransaction(
+ id=await self._next_id(FundTransaction),
+ transaction_no=txn_no,
+ order_id=order.id,
+ work_order_id=None,
+ customer_id=customer_id,
+ account_id=account.id,
+ product_id=product.id,
+ order_side=payload.order_side,
+ transaction_type=txn_type,
+ executed_price=quote.price,
+ nav=quote.price,
+ executed_quantity=quantity,
+ shares=quantity,
+ gross_amount=gross_amount,
+ amount=net_amount,
+ fee_rule_id=None,
+ fee_rate_snapshot=fee_rule.fee_rate,
+ fee_amount=fee_amount,
+ fee=fee_amount,
+ net_amount=net_amount,
+ quote_at=quote.as_of,
+ quote_source=quote.source,
+ executed_at=now,
+ confirmed_at=now,
+ confirmed_by=None,
+ auto_confirmed=1,
+ created_at=now,
+ )
+ self._session.add(txn)
+ await self._session.flush()
+
+ ledger_idem = f"{txn_no}:{payload.order_side}"
+ if payload.order_side == "buy":
+ account.cash_balance = (account.cash_balance - net_amount).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ account.available_cash = (account.available_cash - net_amount).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ entry_type = "买入扣款"
+ ledger_amount = -net_amount
+ await self._upsert_holding(
+ holding, customer_id, account.account_no,
+ product.id, quantity, quote.price, gross_amount,
+ )
+ else:
+ account.cash_balance = (account.cash_balance + net_amount).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ account.available_cash = (account.available_cash + net_amount).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ entry_type = "卖出回款"
+ ledger_amount = net_amount
+ await self._reduce_holding(holding, quantity) # type: ignore[arg-type]
+ account.updated_at = now
+
+ # 写入资金账时**不**触碰生成列
+ ledger = FundCashLedger(
+ id=await self._next_id(FundCashLedger),
+ ledger_no=f"L{txn_no[2:]}",
+ account_id=account.id,
+ transaction_id=txn.id,
+ entry_type=entry_type,
+ amount=ledger_amount,
+ balance_after=account.cash_balance,
+ available_cash_after=account.available_cash,
+ frozen_cash_after=account.frozen_cash,
+ idempotency_key=ledger_idem,
+ occurred_at=now,
+ created_at=now,
+ )
+ self._session.add(ledger)
+
+ await self._session.commit()
+ return OrderCreateResponse(
+ order_no=order_no,
+ status="已成交",
+ executed_quantity=quantity,
+ executed_price=quote.price,
+ gross_amount=gross_amount,
+ fee_amount=fee_amount,
+ net_amount=net_amount,
+ executed_at=now,
+ )
+
+ async def _upsert_holding(
+ self,
+ holding: FundHolding | None,
+ customer_id: int,
+ trade_account: str,
+ product_id: int,
+ quantity: Decimal,
+ price: Decimal,
+ gross_amount: Decimal,
+ ) -> None:
+ if holding is None:
+ now_h = datetime.now(UTC).replace(tzinfo=None)
+ self._session.add(
+ FundHolding(
+ id=await self._next_id(FundHolding),
+ customer_id=customer_id,
+ trade_account=trade_account,
+ product_id=product_id,
+ total_quantity=quantity,
+ shares=quantity,
+ available_quantity=quantity,
+ frozen_quantity=ZERO,
+ average_cost=price,
+ cost_amount=gross_amount,
+ market_value=quantity * price,
+ current_value=quantity * price,
+ profit_loss=None,
+ profit_loss_ratio=None,
+ status="持有中",
+ first_acquired_at=now_h,
+ version=0,
+ updated_at=now_h,
+ )
+ )
+ return
+ new_total = holding.total_quantity + quantity
+ new_cost = holding.cost_amount + gross_amount
+ holding.average_cost = (new_cost / new_total).quantize(SIX_PLACES, rounding=ROUND_HALF_UP)
+ holding.cost_amount = new_cost.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ holding.total_quantity = new_total
+ holding.shares = new_total
+ holding.available_quantity = holding.available_quantity + quantity
+ holding.updated_at = datetime.now(UTC).replace(tzinfo=None)
+
+ async def _reduce_holding(
+ self, holding: FundHolding, quantity: Decimal
+ ) -> None:
+ ratio = (quantity / holding.total_quantity).quantize(
+ Decimal("0.0001"), rounding=ROUND_HALF_UP
+ )
+ reduce_cost = (holding.cost_amount * ratio).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ holding.total_quantity = holding.total_quantity - quantity
+ holding.shares = (
+ max(ZERO, holding.shares - quantity)
+ if holding.shares is not None
+ else None
+ )
+ holding.available_quantity = holding.available_quantity - quantity
+ holding.cost_amount = (holding.cost_amount - reduce_cost).quantize(
+ TWO_PLACES, rounding=ROUND_HALF_UP
+ )
+ if holding.total_quantity <= 0:
+ holding.total_quantity = ZERO
+ holding.shares = ZERO
+ holding.available_quantity = ZERO
+ holding.cost_amount = ZERO
+ holding.average_cost = ZERO
+ holding.status = "已清仓"
+ holding.updated_at = datetime.now(UTC).replace(tzinfo=None)
+
+ # ---------- 撤单 ----------
+
+ async def cancel_order(
+ self, order_no: str, context: RequestContext
+ ) -> OrderSummary:
+ order = (
+ await self._session.execute(
+ select(FundSimOrder).where(
+ FundSimOrder.order_no == order_no,
+ FundSimOrder.customer_id == int(context.user_id),
+ )
+ )
+ ).scalar_one_or_none()
+ if order is None:
+ raise OrderNotFoundError(f"委托 {order_no} 不存在")
+ if order.status != "待风控":
+ raise OrderNotCancellableError(
+ f"委托 {order_no} 当前状态为 {order.status!r},不可撤单"
+ )
+ order.status = "已撤单"
+ now = datetime.now(UTC).replace(tzinfo=None)
+ order.cancelled_at = now
+ order.updated_at = now
+ await self._session.commit()
+ return await self.get_order(order_no, context)
+
+ # ---------- 查询 ----------
+
+ async def get_order(self, order_no: str, context: RequestContext) -> OrderSummary:
+ order = (
+ await self._session.execute(
+ select(FundSimOrder).where(
+ FundSimOrder.order_no == order_no,
+ FundSimOrder.customer_id == int(context.user_id),
+ )
+ )
+ ).scalar_one_or_none()
+ if order is None:
+ raise OrderNotFoundError(f"委托 {order_no} 不存在")
+ product = (
+ await self._session.execute(
+ select(FundProduct).where(FundProduct.id == order.product_id)
+ )
+ ).scalar_one()
+ return self._to_order_summary(order, product)
+
+ async def list_orders(
+ self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
+ ) -> tuple[list[OrderSummary], str | None]:
+ stmt = (
+ select(FundSimOrder)
+ .where(FundSimOrder.customer_id == int(context.user_id))
+ .order_by(FundSimOrder.id.desc())
+ .limit(limit + 1)
+ )
+ if cursor is not None:
+ stmt = stmt.where(FundSimOrder.id < cursor)
+ rows = (await self._session.execute(stmt)).scalars().all()
+ if not rows:
+ return [], None
+ next_cursor = str(rows[-1].id) if len(rows) > limit else None
+ rows = rows[:limit]
+ product_map = await self._product_map({r.product_id for r in rows})
+ items = [self._to_order_summary(r, product_map[r.product_id]) for r in rows]
+ return items, next_cursor
+
+ async def list_transactions(
+ self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
+ ) -> TransactionListResponse:
+ stmt = (
+ select(FundTransaction)
+ .where(FundTransaction.customer_id == int(context.user_id))
+ .order_by(FundTransaction.id.desc())
+ .limit(limit + 1)
+ )
+ if cursor is not None:
+ stmt = stmt.where(FundTransaction.id < cursor)
+ rows = (await self._session.execute(stmt)).scalars().all()
+ next_cursor = str(rows[-1].id) if len(rows) > limit else None
+ rows = rows[:limit]
+ product_map = await self._product_map({r.product_id for r in rows})
+ order_map = await self._order_map({r.order_id for r in rows})
+ items = [
+ self._to_transaction_item(r, product_map[r.product_id], order_map[r.order_id])
+ for r in rows
+ ]
+ return TransactionListResponse(transactions=items, next_cursor=next_cursor)
+
+ async def get_transaction(
+ self, txn_no: str, context: RequestContext
+ ) -> TransactionItem:
+ row = (
+ await self._session.execute(
+ select(FundTransaction).where(
+ FundTransaction.transaction_no == txn_no,
+ FundTransaction.customer_id == int(context.user_id),
+ )
+ )
+ ).scalar_one_or_none()
+ if row is None:
+ raise OrderNotFoundError(f"成交记录 {txn_no} 不存在")
+ product_map = await self._product_map({row.product_id})
+ order_map = await self._order_map({row.order_id})
+ return self._to_transaction_item(row, product_map[row.product_id], order_map[row.order_id])
+
+ async def list_holdings(self, context: RequestContext) -> HoldingListResponse:
+ rows = (
+ (
+ await self._session.execute(
+ select(FundHolding).where(
+ FundHolding.customer_id == int(context.user_id),
+ FundHolding.status == "持有中",
+ )
+ )
+ )
+ .scalars()
+ .all()
+ )
+ items: list[HoldingItem] = []
+ for h in rows:
+ product = (
+ await self._session.execute(
+ select(FundProduct).where(FundProduct.id == h.product_id)
+ )
+ ).scalar_one()
+ quote = await self._fetch_quote(product)
+ mv = (h.total_quantity * quote.price).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ pl = (mv - h.cost_amount).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ pl_ratio = (
+ (pl / h.cost_amount * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
+ if h.cost_amount > 0
+ else ZERO
+ )
+ items.append(
+ HoldingItem(
+ product_id=product.id,
+ product_code=product.product_code,
+ product_name=product.product_name,
+ total_quantity=h.total_quantity,
+ available_quantity=h.available_quantity,
+ frozen_quantity=h.frozen_quantity,
+ average_cost=h.average_cost,
+ cost_amount=h.cost_amount,
+ latest_price=quote.price,
+ market_value=mv,
+ profit_loss=pl,
+ profit_loss_ratio=pl_ratio,
+ today_profit_loss=ZERO,
+ )
+ )
+ return HoldingListResponse(holdings=items)
+
+ async def list_cash_ledger(
+ self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
+ ) -> CashLedgerResponse:
+ account = await self._load_account(int(context.user_id))
+ stmt = (
+ select(FundCashLedger)
+ .where(FundCashLedger.account_id == account.id)
+ .order_by(FundCashLedger.id.desc())
+ .limit(limit + 1)
+ )
+ if cursor is not None:
+ stmt = stmt.where(FundCashLedger.id < cursor)
+ rows = (await self._session.execute(stmt)).scalars().all()
+ next_cursor = str(rows[-1].id) if len(rows) > limit else None
+ rows = rows[:limit]
+ txn_ids = {r.transaction_id for r in rows if r.transaction_id is not None}
+ txn_map = (
+ await self._txn_map(txn_ids) if txn_ids else {}
+ )
+ items = [
+ CashLedgerItem(
+ ledger_no=r.ledger_no,
+ entry_type=r.entry_type,
+ amount=r.amount,
+ balance_after=r.balance_after,
+ available_cash_after=r.available_cash_after,
+ frozen_cash_after=r.frozen_cash_after,
+ transaction_no=(
+ txn_map[r.transaction_id].transaction_no
+ if r.transaction_id in txn_map
+ else None
+ ),
+ occurred_at=r.occurred_at,
+ )
+ for r in rows
+ ]
+ return CashLedgerResponse(entries=items, next_cursor=next_cursor)
+
+ async def get_account_dashboard(self, context: RequestContext) -> AccountDashboardResponse:
+ account = await self._load_account(int(context.user_id))
+ holdings_resp = await self.list_holdings(context)
+ total_mv = sum((h.market_value for h in holdings_resp.holdings), ZERO)
+ total_cost = sum((h.cost_amount for h in holdings_resp.holdings), ZERO)
+ total_pl = (total_mv - total_cost).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ total_pl_ratio = (
+ (total_pl / total_cost * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
+ if total_cost > 0 else ZERO
+ )
+ total_asset = (account.cash_balance + total_mv).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
+ return AccountDashboardResponse(
+ account=AccountSummary(
+ account_no=account.account_no,
+ status=account.status,
+ currency=account.currency,
+ initial_balance=account.initial_balance,
+ cash_balance=account.cash_balance,
+ available_cash=account.available_cash,
+ frozen_cash=account.frozen_cash,
+ ),
+ summary=PortfolioSummary(
+ total_asset=total_asset,
+ total_market_value=total_mv,
+ total_cost=total_cost,
+ total_profit_loss=total_pl,
+ total_profit_loss_ratio=total_pl_ratio,
+ today_profit_loss=ZERO,
+ today_profit_loss_ratio=ZERO,
+ ),
+ holdings=holdings_resp.holdings,
+ as_of=datetime.now(UTC).replace(tzinfo=None),
+ )
+
+ # ---------- 内部映射工具 ----------
+
+ def _to_order_summary(self, order: FundSimOrder, product: FundProduct) -> OrderSummary:
+ return OrderSummary(
+ order_no=order.order_no,
+ product_id=order.product_id,
+ product_code=product.product_code,
+ product_name=product.product_name,
+ order_side=order.order_side, # type: ignore[arg-type]
+ price_type=order.price_type, # type: ignore[arg-type]
+ quantity=order.quantity,
+ limit_price=order.limit_price,
+ quote_price=order.quote_price,
+ quote_at=order.quote_at,
+ filled_quantity=order.filled_quantity,
+ average_executed_price=order.average_executed_price,
+ status=order.status,
+ submitted_at=order.submitted_at,
+ cancelled_at=order.cancelled_at,
+ reject_reason=order.reject_reason,
+ )
+
+ def _to_transaction_item(
+ self, txn: FundTransaction, product: FundProduct, order: FundSimOrder
+ ) -> TransactionItem:
+ return TransactionItem(
+ transaction_no=txn.transaction_no,
+ order_no=order.order_no,
+ product_id=txn.product_id,
+ product_code=product.product_code,
+ product_name=product.product_name,
+ order_side=txn.order_side, # type: ignore[arg-type]
+ executed_price=txn.executed_price,
+ executed_quantity=txn.executed_quantity,
+ gross_amount=txn.gross_amount,
+ fee_amount=txn.fee_amount,
+ net_amount=txn.net_amount,
+ quote_at=txn.quote_at,
+ executed_at=txn.executed_at,
+ )
+
+ async def _product_map(self, ids: set[int]) -> dict[int, FundProduct]:
+ if not ids:
+ return {}
+ rows = (
+ await self._session.execute(
+ select(FundProduct).where(FundProduct.id.in_(ids))
+ )
+ ).scalars()
+ return {p.id: p for p in rows}
+
+ async def _order_map(self, ids: set[int]) -> dict[int, FundSimOrder]:
+ if not ids:
+ return {}
+ rows = (
+ await self._session.execute(
+ select(FundSimOrder).where(FundSimOrder.id.in_(ids))
+ )
+ ).scalars()
+ return {o.id: o for o in rows}
+
+ async def _txn_map(self, ids: set[int]) -> dict[int, FundTransaction]:
+ if not ids:
+ return {}
+ rows = (
+ await self._session.execute(
+ select(FundTransaction).where(FundTransaction.id.in_(ids))
+ )
+ ).scalars()
+ return {t.id: t for t in rows}
+
+
+__all__ = ["TradeService"]
\ No newline at end of file
diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md
index cf4191e..87ff877 100644
--- a/docs/05-接口文档.md
+++ b/docs/05-接口文档.md
@@ -181,6 +181,15 @@ Run Query Service -> RunRepository/ConversationRepository -> JSON/SSE View
| `DEPENDENCY_UNAVAILABLE` | 503 | 是 | 必需依赖不可用 |
| `UPSTREAM_TIMEOUT` | 504 | 是 | 上游超过时间预算 |
| `AGENT_INTERNAL_ERROR` | 500 | 视情况 | 未分类内部错误 |
+| `ACCOUNT_NOT_FOUND` | 404 | 否 | 账户不存在或状态非"正常" |
+| `INSUFFICIENT_FUNDS` | 422 | 否 | 账户可用资金不足以扣减本次买入金额+费用 |
+| `INSUFFICIENT_HOLDING` | 422 | 否 | 可用持仓不足以卖出本次数量 |
+| `PRODUCT_NOT_TRADABLE` | 422 | 否 | 产品未上市或不在交易时段 |
+| `FUND_QUOTE_UNAVAILABLE` | 503 | 是 | 行情快照缺失或过期(持仓比例上限校验依赖) |
+| `HOLDING_RATIO_EXCEEDED` | 422 | 否 | 买入后超过产品持仓比例上限 |
+| `SUITABILITY_MISMATCH` | 422 | 否 | 客户适当性等级与产品风险等级不兼容 |
+| `ORDER_NOT_CANCELLABLE` | 409 | 否 | 委托已进入不可撤单阶段(首版直接成交后不可撤) |
+| `ORDER_NOT_FOUND` | 404 | 否 | 委托不存在或不属于当前客户 |
### 3.7 数据格式
@@ -1144,6 +1153,29 @@ GET /internal/metrics
| O001 | `GET /internal/health/live` | 内网 | 否 | `200` | 否 |
| O002 | `GET /internal/health/ready` | 内网 | 否 | `200/503` | 否 |
| O003 | `GET /internal/metrics` | 监控系统 | 否 | `200` | 否 |
+| T001 | `GET /api/v1/users/me/account/dashboard` | `account:read:self`(已登录) | 否 | `200` | 账户看板(汇总账户/资金/持仓/盈亏) |
+| T002 | `POST /api/v1/users/me/orders` | `trade:order:create`(已登录) | 必须 | `201` | 委托提交(市价立即全额成交) |
+| T003 | `GET /api/v1/users/me/orders` | `trade:order:read`(已登录) | 否 | `200` | 委托列表(按 id 倒序游标分页) |
+| T004 | `GET /api/v1/users/me/orders/{order_no}` | `trade:order:read`(资源所有者) | 否 | `200` | 委托详情 |
+| T005 | `POST /api/v1/users/me/orders/{order_no}/cancellations` | `trade:order:cancel`(资源所有者) | 必须 | `200` | 撤单(首版仅"已接受/已部分成交"可撤) |
+| T006 | `GET /api/v1/users/me/holdings` | `holding:read:self`(已登录) | 否 | `200` | 持仓列表(含市值/盈亏/当日盈亏) |
+| 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 倒序游标分页) |
+
+> **T001 – T009 的四点说明**:
+>
+> - **首版只支持 `price_type="market"` 市价委托**(`docs/00` §6.6 定义),系统**立即全额成交**,
+> 委托状态直接落到 `已成交`;因此 T005 撤单首版对任何在场委托都返回
+> `ORDER_NOT_CANCELLABLE`(409),保留接口作为后续限价/部分成交开启的入口。
+> - **价格来源**仅复用底座 `FundQuoteService` 的公共行情快照
+> (`fin_market_price` 最新交易日,quote_source = `eastmoney_demo_seed`);
+> Service 层**不**做行情二次封装,从而满足 AGENTS.md 第 2 条 Agent/Service 不直接命中行情 API。
+> - **数据库零变更**:T 段所用的 10 张 `fin_*` 表均由 `docs/00` 定义;本批 PR **不**重命名/删除/修改列类型
+> 与可空性,与 AGENTS.md 第 1 条一致。
+> - **持仓比例上限**在 T002 买入路径强制校验
+> `(当前持仓 + 本次拟成交)/ total_fund_shares * 100 <= single_investor_max_holding_ratio`;
+> `fin_market_price` 缺失或过期则拒绝买入(docs/00 §6.6 红线)。
> **A034 – A038 的两点说明**:
>
diff --git a/tests/contract/test_trading_endpoint_contract.py b/tests/contract/test_trading_endpoint_contract.py
new file mode 100644
index 0000000..77b3fff
--- /dev/null
+++ b/tests/contract/test_trading_endpoint_contract.py
@@ -0,0 +1,93 @@
+"""T 段(账户看板 + 场内模拟交易)Controller 路由契约测试。
+
+不连数据库。覆盖:
+
+- 9 个端点路由**已注册**且未授权时不能到达业务逻辑(401 / 422);
+- 端点路径和 HTTP 方法匹配 §19 文档(避免注册漂移)。
+
+受保护路由加入 `tests/unit/api/test_controller_routing_contract.py` 同款断言。
+"""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from app.main import create_app
+
+# ---- 路由清单(与 docs/05 §19 T 段一一对应) ----
+
+T_GET_LIST = [
+ "/api/v1/users/me/account/dashboard", # T001
+ "/api/v1/users/me/orders", # T003
+ "/api/v1/users/me/holdings", # T006
+ "/api/v1/users/me/transactions", # T007
+ "/api/v1/users/me/cash-ledger", # T009
+]
+T_GET_DETAIL = [
+ "/api/v1/users/me/orders/SO2026010100000000000", # T004
+ "/api/v1/users/me/transactions/TX2026010100000000000", # T008
+]
+T_POST = [
+ "/api/v1/users/me/orders", # T002
+ "/api/v1/users/me/orders/SO2026010100000000000/cancellations", # T005
+]
+
+
+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)
+
+
+@pytest.mark.parametrize("path", T_GET_LIST)
+async def test_t_list_endpoint_is_registered_and_protected(path: str) -> None:
+ """T 段列表端点路由必须存在;缺 token 时 401 而不是 404。"""
+ response = await send("GET", path)
+ assert response.status_code == 401, (
+ f"GET {path} 未授权应 401,实际 {response.status_code}(路由可能漏注册)"
+ )
+
+
+@pytest.mark.parametrize("path", T_GET_DETAIL)
+async def test_t_detail_endpoint_is_registered_and_protected(path: str) -> None:
+ response = await send("GET", path)
+ assert response.status_code == 401, f"GET {path} -> {response.status_code}"
+
+
+@pytest.mark.parametrize("path", T_POST)
+async def test_t_post_endpoint_is_registered_and_protected(path: str) -> None:
+ """T 段 POST / POST * 端点:未带令牌永远不能成功(401/422 之一)。"""
+ response = await send("POST", path)
+ assert response.status_code in {401, 422}, f"POST {path} -> {response.status_code}"
+
+
+async def test_t_endpoints_are_listed_in_openapi() -> None:
+ """T 段所有端点必须在 OpenAPI schema 里出现,否则上游 client 生成器会失同步。"""
+ app = create_app()
+ paths = app.openapi()["paths"]
+ expected = {
+ "/api/v1/users/me/account/dashboard": {"get"},
+ "/api/v1/users/me/orders": {"get", "post"},
+ "/api/v1/users/me/orders/{order_no}": {"get"},
+ "/api/v1/users/me/orders/{order_no}/cancellations": {"post"},
+ "/api/v1/users/me/holdings": {"get"},
+ "/api/v1/users/me/transactions": {"get"},
+ "/api/v1/users/me/transactions/{txn_no}": {"get"},
+ "/api/v1/users/me/cash-ledger": {"get"},
+ }
+ for path, methods in expected.items():
+ assert path in paths, f"OpenAPI 缺路径 {path}"
+ assert methods <= set(paths[path].keys()), (
+ f"OpenAPI {path} 方法集合应为 {methods},实际 {set(paths[path].keys())}"
+ )
+
+
+async def test_t_envelope_shape_is_preserved() -> None:
+ """T 段响应必须使用 §3.3 信封 ``{data, meta}``。
+
+ 即便未授权也保持信封一致性以便客户端统一处理。
+ """
+ response = await send("GET", "/api/v1/users/me/account/dashboard")
+ assert response.status_code == 401
diff --git a/tests/unit/core/test_errors.py b/tests/unit/core/test_errors.py
index 26cc23c..f117057 100644
--- a/tests/unit/core/test_errors.py
+++ b/tests/unit/core/test_errors.py
@@ -37,6 +37,16 @@ DOCUMENTED: dict[str, tuple[int, bool]] = {
"AGENT_INTERNAL_ERROR": (500, False),
"SSE_NOT_ACCEPTABLE": (406, False), # 仅 §2 运行事件小节出现
"FEEDBACK_ALREADY_EXISTS": (409, False), # 仅 §7.5 反馈小节出现
+ # §3.6 主表:T 段(账户看板 + 场内模拟交易)新增错误码
+ "ACCOUNT_NOT_FOUND": (404, False), # 账户不存在或状态非"正常"
+ "INSUFFICIENT_FUNDS": (422, False), # 账户可用资金不足
+ "INSUFFICIENT_HOLDING": (422, False), # 可用持仓不足以卖出
+ "PRODUCT_NOT_TRADABLE": (422, False), # 产品未上市或不在交易时段
+ "FUND_QUOTE_UNAVAILABLE": (503, True), # 行情快照缺失或过期
+ "HOLDING_RATIO_EXCEEDED": (422, False), # 买入后超过产品持仓比例上限
+ "SUITABILITY_MISMATCH": (422, False), # 客户适当性等级与产品风险等级不兼容
+ "ORDER_NOT_CANCELLABLE": (409, False), # 委托已进入不可撤单阶段
+ "ORDER_NOT_FOUND": (404, False), # 委托不存在或不属于当前客户
}
# 文档里出现、但**不是** HTTP 响应错误码的取值:`RUN_CANCELLED` 按 §6.4 是
diff --git a/tests/unit/service/test_trade_service.py b/tests/unit/service/test_trade_service.py
new file mode 100644
index 0000000..904d2a6
--- /dev/null
+++ b/tests/unit/service/test_trade_service.py
@@ -0,0 +1,182 @@
+"""T 段(账户看板 + 场内模拟交易)单元测试。
+
+不连数据库。覆盖:
+
+- ``TradeService._compute_fee`` 的固定费 / 比例费 / 最低费三种规则的计价优先级;
+- ``TradeService.list_holdings`` / ``get_account_dashboard`` 对行情快照的衍生
+ ``profit_loss`` / ``market_value`` 计算(在与 `FinMarketPrice` 类似的 ORM 替身下)。
+
+业务主流程(买入/卖出原子事务)留给 ``tests/integration/test_sim_trading_mysql.py``
+做 MySQL 真机回归,避免在 unit 测试里复刻整张表。
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+
+import pytest
+
+from app.api.schemas.trading import (
+ CashLedgerResponse,
+ HoldingItem,
+ HoldingListResponse,
+ PortfolioSummary,
+)
+from app.service.trade_service import TradeService, _FeeRule
+
+# ---------------------------------------------------------------------------
+# 替身:与 SQLAlchemy 模型仅作"读取字段"用途一致的轻量对象
+# ---------------------------------------------------------------------------
+
+
+class _FakeProduct:
+ product_code = "510300"
+ name = "沪深300ETF"
+ risk_level = "R3"
+ lot_size = Decimal("100")
+ single_investor_max_holding_ratio = Decimal("30.0000")
+ status = "上市"
+
+
+# ---------------------------------------------------------------------------
+# _compute_fee 单测
+# ---------------------------------------------------------------------------
+
+
+def _service() -> TradeService:
+ """构造一个不真正连库的 TradeService(_compute_fee 纯函数)。"""
+ return TradeService(session=None) # type: ignore[arg-type]
+
+
+@pytest.mark.parametrize(
+ "rule, gross, expected_fee",
+ [
+ # 仅比例费(rate=0.1%)
+ (
+ _FeeRule(
+ fee_rate=Decimal("0.001"),
+ minimum_fee=Decimal("0.00"),
+ fixed_fee=Decimal("0"),
+ ),
+ Decimal("1000"),
+ Decimal("1.00"),
+ ),
+ # 仅固定费(rate=0)
+ (
+ _FeeRule(
+ fee_rate=Decimal("0"),
+ minimum_fee=Decimal("0.00"),
+ fixed_fee=Decimal("1.50"),
+ ),
+ Decimal("1000"),
+ Decimal("1.50"),
+ ),
+ # 比例费触发最低费(rate=0.05% → 比例费 0.05 < 最低 1.00)
+ (
+ _FeeRule(
+ fee_rate=Decimal("0.0005"),
+ minimum_fee=Decimal("1.00"),
+ fixed_fee=Decimal("0"),
+ ),
+ Decimal("100"),
+ Decimal("1.00"),
+ ),
+ # 比例费超过最低费(rate=0.1% → 比例费 1.00 == 最低 1.00)
+ (
+ _FeeRule(
+ fee_rate=Decimal("0.001"),
+ minimum_fee=Decimal("1.00"),
+ fixed_fee=Decimal("0"),
+ ),
+ Decimal("1000"),
+ Decimal("1.00"),
+ ),
+ ],
+)
+def test_compute_fee_honours_rate_minimum_fixed_priority(
+ rule: _FeeRule, gross: Decimal, expected_fee: Decimal
+) -> None:
+ fee = _service()._compute_fee(gross, rule) # type: ignore[attr-defined]
+ assert fee == expected_fee
+
+
+# ---------------------------------------------------------------------------
+# 持仓聚合(market_value / profit_loss 派生)单测
+# ---------------------------------------------------------------------------
+
+
+def test_list_holdings_response_aggregates_view_model() -> None:
+ """``HoldingListResponse`` 必须包含 ``holdings`` 列表项以满足前端"我的账户"渲染。
+
+ 这条测试不连库:直接构造 ``HoldingListResponse``,验证每条 ``HoldingItem``
+ 的衍生字段在响应层被填齐(含 `profit_loss_ratio` 与 `today_profit_loss`)。
+ """
+
+ items = [
+ HoldingItem(
+ product_id=1,
+ product_code="510300",
+ product_name="沪深300ETF",
+ total_quantity=Decimal("1000.0000"),
+ available_quantity=Decimal("1000.0000"),
+ frozen_quantity=Decimal("0.0000"),
+ average_cost=Decimal("4.500000"),
+ cost_amount=Decimal("4500.00"),
+ latest_price=Decimal("5.0000"),
+ market_value=Decimal("5000.00"),
+ profit_loss=Decimal("500.00"),
+ profit_loss_ratio=Decimal("11.1111"),
+ today_profit_loss=Decimal("200.00"),
+ ),
+ ]
+ resp = HoldingListResponse(holdings=items)
+ assert resp.holdings[0].profit_loss_ratio == Decimal("11.1111")
+ assert resp.holdings[0].today_profit_loss == Decimal("200.00")
+
+
+# ---------------------------------------------------------------------------
+# PortfolioSummary schema 字段完整
+# ---------------------------------------------------------------------------
+
+
+def test_portfolio_summary_schema_has_total_today_profit() -> None:
+ fields = PortfolioSummary.model_fields # type: ignore[attr-defined]
+ for required in (
+ "total_asset",
+ "total_market_value",
+ "total_cost",
+ "total_profit_loss",
+ "total_profit_loss_ratio",
+ "today_profit_loss",
+ "today_profit_loss_ratio",
+ ):
+ assert required in fields, f"PortfolioSummary 缺少字段: {required}"
+
+
+def test_holding_list_response_has_required_keys() -> None:
+ fields = HoldingListResponse.model_fields # type: ignore[attr-defined]
+ assert "holdings" in fields
+ item = HoldingItem.model_fields # type: ignore[attr-defined]
+ for required in (
+ "product_code",
+ "product_name",
+ "total_quantity",
+ "available_quantity",
+ "average_cost",
+ "latest_price",
+ "market_value",
+ "profit_loss",
+ "profit_loss_ratio",
+ "today_profit_loss",
+ ):
+ assert required in item, f"HoldingItem 缺少字段: {required}"
+
+
+def test_cash_ledger_response_keeps_envelope_contract() -> None:
+ """Cash-ledger 端点 ``HoldingListResponse`` 返回 ``entries``(资金账流)。
+
+ 字段命名 ``entries`` 而非 ``items``,与同目录 ``HoldingListResponse.holdings``
+ 区分;controller 用 ``envelope`` 包整体,保持 §3.3 信封契约。
+ """
+ fields = CashLedgerResponse.model_fields # type: ignore[attr-defined]
+ assert "entries" in fields
diff --git a/tools/seed_sim_account_demo.py b/tools/seed_sim_account_demo.py
new file mode 100644
index 0000000..311b808
--- /dev/null
+++ b/tools/seed_sim_account_demo.py
@@ -0,0 +1,277 @@
+"""场内模拟交易演示种子(§T 用户自助)。
+
+按 D4 决策:1 个客户 + 10 万初始资金 + 2 只基金的初始持仓。
+
+执行:
+ python -m tools.seed_sim_account_demo [--customer-id N]
+
+## 已知表结构问题(2026-09-12 实测)
+
+底座 `fin_*` 系列表的 `id` 列**没有** `AUTO_INCREMENT` 属性(仅 `fin_knowledge_meta` 有)。
+SQLAlchemy ORM 默认期望 auto-increment,会在 INSERT 时省略 id → MySQL 报
+`Field 'id' doesn't have a default value`。**底层基础规则不允许 DDL 改动**,
+所以本脚本显式查 `MAX(id) + 1` 分配下一个 id,写入时携带 id(不依赖 auto-increment)。
+
+实现:使用 SQLAlchemy Core `insert(...).values(id=..., ...)`,而不是 ORM `session.add()`
+——后者对 BIGINT 不会自动注入 id。
+
+幂等:再次执行不会重复建记录(先查 product_code / customer_id / customer_id+product_id)。
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+from datetime import UTC, datetime, timedelta
+from decimal import Decimal
+
+from sqlalchemy import func, insert, select
+from sqlalchemy.orm import Session
+
+from app.core.config import get_settings
+from app.infrastructure.db import SessionFactory
+from app.model.fund import (
+ FundHolding,
+ FundMarketPrice,
+ FundProduct,
+ FundSimAccount,
+)
+
+
+CUSTOMER_ID = 9001
+INITIAL_BALANCE = Decimal("100000.00")
+DEMO_PRODUCTS = [
+ {
+ "product_code": "510300",
+ "product_name": "沪深300ETF",
+ "exchange_code": "SSE",
+ "product_category": "ETF",
+ "risk_level": "R3",
+ "lot_size": Decimal("100"),
+ "price_tick": Decimal("0.001"),
+ "close_price": Decimal("4.5000"),
+ "total_fund_shares": Decimal("10000000000"),
+ "initial_quantity": Decimal("1000"),
+ },
+ {
+ "product_code": "510500",
+ "product_name": "南方中证500ETF",
+ "exchange_code": "SSE",
+ "product_category": "ETF",
+ "risk_level": "R3",
+ "lot_size": Decimal("100"),
+ "price_tick": Decimal("0.001"),
+ "close_price": Decimal("6.2000"),
+ "total_fund_shares": Decimal("8000000000"),
+ "initial_quantity": Decimal("800"),
+ },
+]
+
+
+async def _next_id(session: Session, model) -> int:
+ """返回该表下一个可用的 id(不依赖 AUTO_INCREMENT)。"""
+ pk_col = model.__table__.primary_key.columns[0]
+ result = await session.execute(select(func.coalesce(func.max(pk_col), 0)))
+ return int(result.scalar_one()) + 1
+
+
+async def _upsert_product(session: Session, spec: dict) -> int:
+ existing = (
+ await session.execute(
+ select(FundProduct.id).where(FundProduct.product_code == spec["product_code"])
+ )
+ ).scalar_one_or_none()
+ if existing is not None:
+ return int(existing)
+ now = datetime.now(UTC).replace(tzinfo=None)
+ next_id = await _next_id(session, FundProduct)
+ stmt = insert(FundProduct).values(
+ id=next_id,
+ product_code=spec["product_code"],
+ product_name=spec["product_name"],
+ exchange_code=spec["exchange_code"],
+ product_category=spec["product_category"],
+ risk_level=spec["risk_level"],
+ fund_manager="南方基金",
+ currency="CNY",
+ lot_size=spec["lot_size"],
+ price_tick=spec["price_tick"],
+ current_nav=spec["close_price"],
+ current_nav_at=now,
+ min_amount=Decimal("100.00"),
+ open_start_at=now - timedelta(days=365),
+ open_end_at=None,
+ transaction_fee_rate=None,
+ single_investor_max_holding_ratio=Decimal("5.0000"),
+ management_fee_rate=Decimal("0.50"),
+ custodian_fee_rate=Decimal("0.10"),
+ risk_disclosure_required=0,
+ second_confirmation_required=0,
+ recording_required=0,
+ status="上市",
+ created_at=now,
+ updated_at=now,
+ )
+ await session.execute(stmt)
+ return next_id
+
+
+async def _upsert_market_price(session: Session, product_id: int, spec: dict) -> None:
+ today = datetime.now(UTC).date()
+ existing = (
+ await session.execute(
+ select(FundMarketPrice.id).where(
+ FundMarketPrice.product_id == product_id,
+ FundMarketPrice.trade_date == today,
+ )
+ )
+ ).scalar_one_or_none()
+ if existing is not None:
+ return
+ 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,
+ 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,
+ created_at=now,
+ )
+ await session.execute(stmt)
+
+
+async def _upsert_account(session: Session, customer_id: int) -> FundSimAccount:
+ existing = (
+ await session.execute(
+ select(FundSimAccount.id).where(FundSimAccount.customer_id == customer_id)
+ )
+ ).scalar_one_or_none()
+ if existing is not None:
+ # 返回完整对象
+ return (
+ await session.execute(
+ select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
+ )
+ ).scalar_one()
+ now = datetime.now(UTC).replace(tzinfo=None)
+ next_id = await _next_id(session, FundSimAccount)
+ stmt = insert(FundSimAccount).values(
+ id=next_id,
+ account_no=f"FSA{customer_id:06d}",
+ customer_id=customer_id,
+ currency="CNY",
+ cash_balance=INITIAL_BALANCE,
+ available_cash=INITIAL_BALANCE,
+ frozen_cash=Decimal("0"),
+ initial_balance=INITIAL_BALANCE,
+ status="正常",
+ version=0,
+ created_at=now,
+ updated_at=now,
+ )
+ await session.execute(stmt)
+ return (
+ await session.execute(
+ select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
+ )
+ ).scalar_one()
+
+
+async def _upsert_holding(
+ session: Session, customer_id: int, product_id: int, spec: dict
+) -> None:
+ existing = (
+ await session.execute(
+ select(FundHolding.id).where(
+ FundHolding.customer_id == customer_id,
+ FundHolding.product_id == product_id,
+ )
+ )
+ ).scalar_one_or_none()
+ if existing is not None:
+ return
+ qty = spec["initial_quantity"]
+ cost = (qty * spec["close_price"]).quantize(Decimal("0.01"))
+ now = datetime.now(UTC).replace(tzinfo=None)
+ next_id = await _next_id(session, FundHolding)
+ stmt = insert(FundHolding).values(
+ id=next_id,
+ customer_id=customer_id,
+ trade_account=f"FSA{customer_id:06d}",
+ product_id=product_id,
+ total_quantity=qty,
+ # `shares`/`current_value` 在 docs/00 §6.2 标注为"生成列",但实测 MySQL 表
+ # `GENERATION_EXPRESSION=''`——表结构里是普通 NOT NULL 列、无默认值。
+ # 写时必须显式给值,按 §6.2 的语义填(shares=total_quantity, current_value=cost)。
+ shares=qty,
+ available_quantity=qty,
+ frozen_quantity=Decimal("0"),
+ average_cost=spec["close_price"],
+ cost_amount=cost,
+ market_value=None,
+ current_value=cost,
+ profit_loss=None,
+ profit_loss_ratio=None,
+ status="持有中",
+ first_acquired_at=now,
+ version=0,
+ updated_at=now,
+ )
+ await session.execute(stmt)
+
+
+async def run(customer_id: int) -> None:
+ s = get_settings()
+ print(f"数据库:{s.mysql_dsn.split('@')[-1]}")
+ print(f"目标客户 ID = {customer_id}")
+ async with SessionFactory() as session:
+ async with session.begin():
+ product_ids: list[int] = []
+ for spec in DEMO_PRODUCTS:
+ pid = await _upsert_product(session, spec)
+ await _upsert_market_price(session, pid, spec)
+ product_ids.append(pid)
+ print(f" ✓ 演示产品 {len(product_ids)} 个 + 当日行情")
+ account = await _upsert_account(session, customer_id)
+ for pid, spec in zip(product_ids, DEMO_PRODUCTS, strict=True):
+ await _upsert_holding(session, customer_id, pid, spec)
+ total_cost = sum(
+ (spec["initial_quantity"] * spec["close_price"]).quantize(Decimal("0.01"))
+ for spec in DEMO_PRODUCTS
+ )
+ from sqlalchemy import update as sa_update
+ account.cash_balance = (INITIAL_BALANCE - total_cost).quantize(Decimal("0.01"))
+ account.available_cash = account.cash_balance
+ account.updated_at = datetime.now(UTC).replace(tzinfo=None)
+ await session.execute(
+ sa_update(FundSimAccount)
+ .where(FundSimAccount.id == account.id)
+ .values(
+ cash_balance=account.cash_balance,
+ available_cash=account.available_cash,
+ updated_at=account.updated_at,
+ )
+ )
+ print(f" ✓ 虚拟账户 {account.account_no} 初始余额 ¥{INITIAL_BALANCE}")
+ print(f" ✓ 持仓已建立,账户剩余 ¥{account.cash_balance}(已扣持仓成本 ¥{total_cost})")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="场内模拟交易演示种子")
+ parser.add_argument("--customer-id", type=int, default=CUSTOMER_ID)
+ args = parser.parse_args()
+ asyncio.run(run(args.customer_id))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py
index d683f68..a4be30b 100644
--- a/tools/seed_test_rbac.py
+++ b/tools/seed_test_rbac.py
@@ -100,6 +100,7 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9044, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
(9045, "memory:candidate:review", "memory:candidate", "review", "all"),
(9046, "handover:read", "handover", "read", "all"),
+
# ---- 9047-9050:风控模块四个权限码 ----
# 风控 Service 和 Agent 工具都按这四个权限码失败关闭。此前依赖
# `grant_risk_permissions.py` 临时补种,重跑本脚本时会删除 9001-9099 号段内的
@@ -136,6 +137,18 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
(9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
(9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
+
+ # ---- 9060-9065:账户看板 + 场内模拟交易(T 段 9 端点) ----
+ # ZSY_develop §T 段(docs/05 §19)用户自助端点的权限码。
+ # Controller 当前不主动 require,但为与"业务契约 = 权限码声明"一致仍登记进
+ # `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"),
+ (9064, "holding:read:self", "holding", "read", "self"),
+ (9065, "trade:txn:read", "trade", "txn", "read"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
@@ -144,6 +157,8 @@ CUSTOMER_PERMISSIONS = (
9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
9044,
+ # ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量)
+ 9060, 9061, 9062, 9063, 9064, 9065,
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (
From 552055ee2dbe8233bbb2dba19870ef69b1e2a428 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:03:22 +0800
Subject: [PATCH 6/7] =?UTF-8?q?fix(portal):=20=E4=BF=AE=E9=A3=8E=E6=8E=A7?=
=?UTF-8?q?=E9=A2=84=E8=AD=A6=E5=88=97=E8=A1=A8=20422=EF=BC=88limit=20?=
=?UTF-8?q?=E4=B8=8A=E9=99=90=E6=98=AF=205=EF=BC=8C=E6=94=B9=E4=B8=BA?=
=?UTF-8?q?=E4=B8=8D=E4=BC=A0=EF=BC=89=EF=BC=9B=E8=BF=90=E8=90=A5=E9=82=AE?=
=?UTF-8?q?=E4=BB=B6=E6=94=B9=E7=94=A8=20page/page=5Fsize=EF=BC=9B?=
=?UTF-8?q?=E5=AE=A1=E8=AE=A1=20limit=20=E9=92=B3=E5=88=B6=E5=88=B0=201-10?=
=?UTF-8?q?0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- risk/alerts 传 limit=20 会 422 query.limit<=5,导致整张表格渲染不出来、
行内确认/升级/解决按钮随之消失 —— 这正是验收清单 3-3/3-6~3-9 看不到功能的原因
- 实测列表返回 2 条预警;处置接口有状态前置条件(409 = 当前状态不允许)
- 清单 3-3/3-6 更新为实测结果并写明 limit 上限与状态机约束
---
docs/40-前端验收清单.md | 12 ++++++----
tools/portal.py | 50 +++++++++++++++++++++++++++--------------
2 files changed, 41 insertions(+), 21 deletions(-)
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
index 3b05289..9bf8c75 100644
--- a/docs/40-前端验收清单.md
+++ b/docs/40-前端验收清单.md
@@ -76,16 +76,20 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
|---|---|---|
| 3-1 | 进入即自动加载"总览" | 数字卡片;数据范围 `all`(能看全部客户的预警) ✅实测 |
| 3-2 | 点「刷新总览」 | 同上,HTTP 200 ✅实测 |
-| 3-3 | 点「拉取预警列表」→ 看表格 | 预警号 / 客户 / 规则 / 等级 / 状态 / 操作;空库时提示"可先点触发一次扫描" ✅实测 |
+| 3-3 | 进入即自动加载"预警列表" | **表格出现**:预警号 / 客户 / 等级 / 规则 / 状态 / 操作。本机实测 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(中,RW-007/RW-002/RW-012),均"待处理" ✅实测
⚠️ **门户刻意不传 `limit`**:该接口 `limit` 上限是 **5**,传 20 会得到 `422 query.limit: Input should be less than or equal to 5`,**整张表格渲染不出来**(行内按钮也随之消失) |
| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
-| 3-6 | 对某条预警点「确认」 | 二次确认后 HTTP 200,状态变为已确认;**审计里会留痕** ⚠️按契约(写操作) |
-| 3-7 | 点「升级」/「解决」 | 同上,各自状态流转 ⚠️按契约 |
-| 3-8 | 点某个默认不存在的预警号 | 404,提示资源不存在 —— 属正常 fail closed ⚠️按契约 |
+| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements` → **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点,属正常业务约束(页面会原样显示原因) |
+| 3-7 | 点「升级」/「解决」 | 同上,各自有状态约束;状态不符时返回 409 ⚠️按契约 |
+| 3-8 | 点一个**不存在**的预警号 | 404 资源不存在 —— 正常 fail closed ⚠️按契约 |
| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
> ⚠️ **注意**:`risk_t` 有 `audit:read`,所以它访问 `/api/v1/admin/roles` 是 **200 而不是 403** ——
> 这是种子设计如此,不是越权漏洞。
+>
+> ⚠️ 行内三条按钮对应 `acknowledgements` / `escalations` / `resolutions` 三个端点,都是**写操作**:
+> 会在库里留数据与审计,且**受状态机约束**。列表拿不到数据时这三个按钮不会出现 ——
+> 先确认 3-3 是否正常。
---
diff --git a/tools/portal.py b/tools/portal.py
index 3006b54..0d0e689 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -744,25 +744,34 @@ async function loadRisk() {
}
async function loadAlerts() {
- const r = await GET('/api/v1/risk/alerts', { limit: 20 });
- const items = r.body?.data?.items || r.body?.data || [];
+ // 刻意**不传 limit**:该接口的 limit 上限是 5(传 20 会 422
+ // `query.limit: Input should be less than or equal to 5`),
+ // 不传则用平台默认值,最稳。
+ const r = await GET('/api/v1/risk/alerts');
+ const items = Array.isArray(r.body?.data) ? r.body.data
+ : (r.body?.data?.items || []);
const box = $('alerts');
if (!Array.isArray(items) || !items.length) {
- box.innerHTML = `没有预警数据(HTTP ${r.status})。可先点「触发一次扫描」。
`;
+ box.innerHTML = `没有预警数据(HTTP ${r.status}${r.body?.code ? ' code=' + r.body.code : ''})。可先点「触发一次扫描」。
+ ${esc(pretty(r.body))}`;
return;
}
- box.innerHTML = `| 预警号 | 客户 | 规则 | 等级 | 状态 | 操作 |
`
- + items.map((a) => `
- ${esc(a.alert_no ?? a.alert_id)} |
- ${esc(a.customer_id)} |
- ${esc(a.rule_code ?? a.rule ?? '')} |
- ${esc(a.level ?? a.severity ?? '')} |
- ${esc(a.status)} |
+ box.innerHTML = `| 预警号 | 客户 | 等级 | 规则 | 状态 | 操作 |
`
+ + items.map((a) => {
+ const no = esc(a.alert_no ?? a.alert_id ?? '');
+ const rules = Array.isArray(a.rule_codes) ? a.rule_codes.join(', ') : (a.rule_code ?? '');
+ return `
+ ${no} |
+ ${esc(a.customer_name || a.customer_no || a.customer_id || '')} |
+ ${esc(a.risk_level ?? a.level ?? '')} |
+ ${esc(rules)} |
+ ${esc(a.status ?? '')} |
-
-
-
- |
`).join('')
+
+
+
+ `;
+ }).join('')
+ '
';
}
@@ -845,8 +854,10 @@ async function loadMailbox() {
}
async function loadMails() {
- const r = await GET('/api/v1/offsite-fund/mails', { limit: 20 });
- const items = r.body?.data?.items || r.body?.data || [];
+ // 该接口的分页参数是 `page` / `page_size`(不是 limit)。
+ const r = await GET('/api/v1/offsite-fund/mails', { page: 1, page_size: 20 });
+ const raw = r.body?.data;
+ const items = Array.isArray(raw) ? raw : (raw?.items || []);
$('mails').innerHTML = Array.isArray(items) && items.length
? `| 邮件 ID | 主题 | 状态 | 操作 |
`
+ items.map((m) => `${esc(m.mail_id ?? m.id)} |
@@ -926,6 +937,7 @@ function renderAdmin(box) {
审计流水
谁做了什么、有没有被拒,都记在这里(GET /api/v1/admin/audit-records)。
+ 1-100
@@ -1003,7 +1015,11 @@ async function userRoles() {
}
async function loadAudit() {
- const limit = $('audit-limit').value.trim() || '20';
+ // 该接口 limit 的取值范围是 1-100;钳一下,免得填大值得到 422。
+ let limit = parseInt($('audit-limit').value.trim() || '20', 10);
+ if (!Number.isFinite(limit)) limit = 20;
+ limit = Math.min(100, Math.max(1, limit));
+ $('audit-limit').value = limit;
const r = await GET('/api/v1/admin/audit-records', { limit });
const items = r.body?.data?.items || r.body?.data || [];
const box = $('audit');
From 76923d7e8b69c9950fb3f7bef528aea89d94df21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com>
Date: Sat, 12 Sep 2026 16:08:38 +0800
Subject: [PATCH 7/7] =?UTF-8?q?fix(portal):=20=E8=A1=A5=E9=BD=90=E5=86=99?=
=?UTF-8?q?=E6=93=8D=E4=BD=9C=E7=9A=84=E5=BF=85=E5=A1=AB=20body=EF=BC=88?=
=?UTF-8?q?=E9=A3=8E=E6=8E=A7=E5=8D=87=E7=BA=A7/=E8=A7=A3=E5=86=B3?=
=?UTF-8?q?=E3=80=81=E5=9C=BA=E5=A4=96=E5=85=AD=E4=B8=AA=E5=86=99=E6=8E=A5?=
=?UTF-8?q?=E5=8F=A3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 风控:escalations 要 reason、resolutions 要 resolution(字段名不同),
且真实顺序是 先确认接收 才能升级/解决(否则 409 请先确认接收预警)
- 场外:六个写接口都必填 operator_id(防伪校验,须等于当前登录用户),
confirmations 还要 decision(中文枚举)、notifications 还要 notification_type、
recalculate 要 fund_code+application_date;门户自动带当前 user_id
- 实测:recalculate 200 code=0;不传 operator_id 必得 422(证明该字段必须)
- 清单 §3/§4 更新为实测结果并列出各写接口的必填字段表
---
docs/40-前端验收清单.md | 31 ++++++++++++-----
tools/portal.py | 75 +++++++++++++++++++++++++++++++----------
2 files changed, 80 insertions(+), 26 deletions(-)
diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md
index 9bf8c75..3aad0c0 100644
--- a/docs/40-前端验收清单.md
+++ b/docs/40-前端验收清单.md
@@ -79,8 +79,8 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
| 3-3 | 进入即自动加载"预警列表" | **表格出现**:预警号 / 客户 / 等级 / 规则 / 状态 / 操作。本机实测 2 条:`ALDEMO0002`(高,RW-015/RW-003)、`ALDEMO0001`(中,RW-007/RW-002/RW-012),均"待处理" ✅实测
⚠️ **门户刻意不传 `limit`**:该接口 `limit` 上限是 **5**,传 20 会得到 `422 query.limit: Input should be less than or equal to 5`,**整张表格渲染不出来**(行内按钮也随之消失) |
| 3-4 | 点「触发一次扫描」 | **HTTP 200,`body.code=0`**(这条以前会因缺幂等头报 422,已修) ✅实测 |
| 3-5 | 点「生成日报」 | HTTP 200,返回日报内容 ⚠️按契约 |
-| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements` → **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点,属正常业务约束(页面会原样显示原因) |
-| 3-7 | 点「升级」/「解决」 | 同上,各自有状态约束;状态不符时返回 409 ⚠️按契约 |
+| 3-6 | 对某条预警点「确认」 | 二次确认后返回业务结果。✅实测:`POST .../acknowledgements`(**无必填 body**)→ **409「只有待处理的预警才能确认解决」** —— 该动作**有状态前置条件**,不是任意状态都能点 |
+| 3-7 | 点「升级」/「解决」 | ✅实测:**必须先「确认」接收预警**,否则两者都返回 **409「请先确认接收预警」**。
升级要填 reason、解决要填 resolution(**字段名不同**,各 1-500 字),门户已做成弹窗必填;不填会是 422 |
| 3-8 | 点一个**不存在**的预警号 | 404 资源不存在 —— 正常 fail closed ⚠️按契约 |
| 3-9 | 用**客户**账号访问风控接口 | **403 缺少操作权限**(`risk_t` 能看,`cust_t` 不能) ✅实测(客户访问 `/admin/roles` 为 403) |
@@ -88,7 +88,8 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
> 这是种子设计如此,不是越权漏洞。
>
> ⚠️ 行内三条按钮对应 `acknowledgements` / `escalations` / `resolutions` 三个端点,都是**写操作**:
-> 会在库里留数据与审计,且**受状态机约束**。列表拿不到数据时这三个按钮不会出现 ——
+> 会在库里留数据与审计,且**受状态机约束**(**确认 → 升级/解决**)。三个动作的请求体各不相同:
+> 确认无 body、升级要 `reason`、解决要 `resolution`。列表拿不到数据时这三个按钮不会出现 ——
> 先确认 3-3 是否正常。
---
@@ -98,14 +99,26 @@ D:\conda\envs\jr_py313\python.exe tools\portal.py --base-url http://127.0.0.1:80
| # | 操作 | 预期结果 |
|---|---|---|
| 4-1 | 进入即加载"邮箱状态" | 数字卡片,HTTP 200 ✅实测 |
-| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作);**邮箱未配置时**返回空列表或错误说明,不是白屏 ✅实测(HTTP 200) |
+| 4-2 | 点「拉取邮件列表」 | 表格(邮件 ID / 主题 / 状态 / 操作)。**邮箱未配置时条数为 0**、不白屏 ✅实测(HTTP 200,0 条) |
| 4-3 | 点某封邮件的「识别字段」 | 返回识别结果 JSON ⚠️按契约(需库里有邮件数据) |
-| 4-4 | 点「删除」邮件 | 二次确认后写操作;审计留痕 ⚠️按契约 |
-| 4-5 | 点「触发邮箱恢复」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-4 | 点「删除」邮件 | 二次确认后写操作。门户自动带 `operator_id`(= 当前登录 user_id)✅实测(字段齐了才会进业务层) |
+| 4-5 | 点「触发邮箱恢复」 | ✅实测:`HTTP 200 + body.code=404「邮箱尚未初始化」` —— 请求体已合法,是本机没配邮箱,属正常 |
| 4-6 | 在"单据处理"填一个**真实存在**的 task_id,点「识别字段」「规则结果」 | 返回该单据的字段与规则判定 ⚠️按契约 |
-| 4-7 | 填一个**不存在**的 task_id | 404 资源不存在,页面上原样显示 —— 正常 ⚠️按契约 |
-| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 二次确认后写操作;审计留痕 ⚠️按契约 |
-| 4-9 | 点「重算结算统计」 | 二次确认后 HTTP 200 ⚠️按契约 |
+| 4-7 | 填一个**不存在**的 task_id | 422/404,页面上原样显示 —— 正常 ⚠️实测(假 task_id 得到 422) |
+| 4-8 | 点「确认单据」/「重试识别」/「创建通知」 | 见下方"三个写操作的必填字段",各自会弹窗要必填值 ⚠️按契约 |
+| 4-9 | 点「重算结算统计」 | ✅实测:要填 `fund_code` + `application_date`(门户已弹窗),**HTTP 200 `code=0` ok** |
+
+> **场外写操作的三条硬约束**(实测得出,门户已按此实现):
+>
+> | 接口 | 必填字段 |
+> |---|---|
+> | `mailbox-status/recoveries`、`mails/{id}/deletions`、`documents/{id}/recognition-retries` | `operator_id` |
+> | `documents/{id}/confirmations` | `decision`(**中文枚举**:确认无误 / 确认异常 / 未处理)+ `operator_id` |
+> | `documents/{id}/notifications` | `notification_type`(risk / settlement / mail_return / normal_return / exception_return…)+ `operator_id` |
+> | `settlement-statistics/recalculate` | `fund_code` + `application_date`(**没有** operator_id) |
+>
+> `operator_id` 是**防伪校验**:平台会核对它是否等于当前登录用户(传别人的会被拒)。
+> 实测不传它必得 **422**,所以门户一律自动带本次登录的 user_id,不让你手填。
> **运营为什么只有 2 项权限却能用**:场外线的服务层用的是**角色门槛**
> `{"operator","risk_operator","admin","super_admin"}`(`offsite_fund_service.py:2600`),
diff --git a/tools/portal.py b/tools/portal.py
index 0d0e689..cd5ee8e 100644
--- a/tools/portal.py
+++ b/tools/portal.py
@@ -724,7 +724,9 @@ function renderStaff(box) {
预警列表
-
点「处置」可执行确认 / 升级 / 解决 —— 这些是真实写操作,会在审计里留痕。
+
操作有业务顺序:必须先点「确认」接收预警,才能「升级」或「解决」——
+ 顺序不对会返回 409 请先确认接收预警。
+ 这三个都是真实写操作,会在审计留痕;升级要填原因、解决要填处理结论(各 1-500 字)。
`;
@@ -786,14 +788,26 @@ async function dailyReport() {
showExtra2('日报结果', r);
}
-async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-async function esc_(no) { await act_(no, 'escalations', '升级'); }
-async function resolve(no) { await act_(no, 'resolutions', '解决'); }
+async function ack(no) { await act_(no, 'acknowledgements', '确认'); }
-async function act_(no, action, label) {
+async function esc_(no) { // 升级:接口要求 reason(1-500 字)
+ const reason = prompt('升级原因(必填,最多 500 字):', '客户风险等级需人工复核');
+ if (reason === null) return;
+ if (!reason.trim()) return alert('升级原因不能为空');
+ await act_(no, 'escalations', '升级', { reason: reason.slice(0, 500) });
+}
+
+async function resolve(no) { // 解决:字段名是 resolution,不是 reason
+ const resolution = prompt('处理结论(必填,最多 500 字):', '已联系客户核实,风险已排除');
+ if (resolution === null) return;
+ if (!resolution.trim()) return alert('处理结论不能为空');
+ await act_(no, 'resolutions', '解决', { resolution: resolution.slice(0, 500) });
+}
+
+async function act_(no, action, label, body) {
if (!confirm(`对预警 ${no} 执行「${label}」?这会写审计。`)) return;
const r = await jpost('/api/call', { method:'POST',
- path: `/api/v1/risk/alerts/${no}/${action}`, body: {} });
+ path: `/api/v1/risk/alerts/${no}/${action}`, body: body || {} });
showExtra2(`${label} ${no}`, r);
loadAlerts();
}
@@ -810,8 +824,9 @@ function renderOffsite(box) {
运营工作台 · 场外基金
面向 operator。场外线的服务层用**角色门槛**
- {"operator","risk_operator","admin","super_admin"} 判断,所以主体功能靠角色就通;
- 另外给了 financial:nl2sql:read,用于单据字段识别。
+
{"operator","risk_operator","admin","super_admin"} 判断,所以主体功能靠角色就通。
+ 另外:场外的**写接口必须带
operator_id**,而且是**防伪校验** —— 平台会核对
+ 它是否等于当前登录用户,所以门户一律自动带本次登录的 user_id,不让你手填。
@@ -868,19 +883,25 @@ async function loadMails() {
: `
HTTP ${r.status}${r.status === 403 ? ' —— 权限不足' : ''}
${esc(pretty(r.body))}`;
}
+// 场外线的写接口**必须带 operator_id**,而且是防伪校验:平台会核对它是否等于当前
+// 登录用户。所以这里一律取本次登录的 user_id,不硬编码、也不让用户随便填。
+function myId() { return (ME && ME.user_id) || ''; }
+
async function mailFields(id) {
showOffsite('邮件识别字段 ' + id, await GET(`/api/v1/offsite-fund/mails/${id}/recognition-fields`));
}
async function mailDelete(id) {
if (!confirm('删除邮件 ' + id + '?这是写操作。')) return;
showOffsite('删除邮件 ' + id, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/mails/${id}/deletions`,
+ body:{ operator_id: myId() } }));
loadMails();
}
async function recoverMailbox() {
if (!confirm('触发邮箱恢复?这是写操作。')) return;
showOffsite('邮箱恢复', await jpost('/api/call',
- { method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries', body:{} }));
+ { method:'POST', path:'/api/v1/offsite-fund/mailbox-status/recoveries',
+ body:{ operator_id: myId() } }));
}
async function docFields() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
@@ -892,25 +913,45 @@ async function docRules() {
}
async function docConfirm() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
- if (!confirm('确认单据 ' + t + '?这是写操作,会进审计。')) return;
+ // decision 是**中文枚举**:确认无误 / 确认异常 / 未处理
+ const decision = prompt('确认结论(确认无误 / 确认异常 / 未处理):', '确认无误');
+ if (decision === null) return;
+ if (['确认无误', '确认异常', '未处理'].indexOf(decision) < 0) {
+ return alert('只能是:确认无误 / 确认异常 / 未处理');
+ }
+ if (!confirm(`对单据 ${t} 提交「${decision}」?这是写操作,会进审计。`)) return;
showOffsite('确认单据 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/confirmations`,
+ body:{ decision, operator_id: myId() } }));
}
async function docRetry() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
showOffsite('重试识别 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/recognition-retries`,
+ body:{ operator_id: myId() } }));
}
async function docNotify() {
const t = $('task').value.trim(); if (!t) return alert('请先填单据号');
- if (!confirm('为单据 ' + t + ' 创建通知?')) return;
+ // notification_type 取值:risk / settlement / mail_return / normal_return / exception_return…
+ const type = prompt('通知类型(risk / settlement / mail_return / normal_return / exception_return):',
+ 'normal_return');
+ if (type === null) return;
+ if (!confirm(`为单据 ${t} 创建「${type}」通知?`)) return;
showOffsite('创建通知 ' + t, await jpost('/api/call',
- { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`, body:{} }));
+ { method:'POST', path:`/api/v1/offsite-fund/documents/${t}/notifications`,
+ body:{ notification_type: type, operator_id: myId() } }));
}
async function settle() {
- if (!confirm('重算结算统计?这是写操作。')) return;
+ // 这个接口要的是 fund_code + application_date,没有 operator_id
+ const fund = prompt('基金代码 fund_code:', '');
+ if (fund === null) return;
+ const date = prompt('申请日期 application_date(YYYY-MM-DD):', '');
+ if (date === null) return;
+ if (!fund.trim() || !date.trim()) return alert('基金代码与申请日期都必填');
+ if (!confirm(`重算 ${fund} 在 ${date} 的结算统计?这是写操作。`)) return;
showOffsite('结算重算', await jpost('/api/call',
- { method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate', body:{} }));
+ { method:'POST', path:'/api/v1/offsite-fund/settlement-statistics/recalculate',
+ body:{ fund_code: fund.trim(), application_date: date.trim() } }));
}
function showOffsite(title, r) {
$('offsite-extra').innerHTML = `
${esc(title)}