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] =?UTF-8?q?feat(=C2=A7T):=20=E8=B4=A6=E6=88=B7=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=20+=20=E5=9C=BA=E5=86=85=E6=A8=A1=E6=8B=9F=E4=BA=A4?= =?UTF-8?q?=E6=98=93=209=20=E7=AB=AF=E7=82=B9=EF=BC=88=E7=94=A8=E6=88=B7?= =?UTF-8?q?=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 = (