2026-09-12 15:35:12 +08:00
|
|
|
|
"""§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
|
|
|
|
|
|
|
2026-09-14 20:19:55 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query, status
|
2026-09-12 15:35:12 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.dependencies.auth import build_request_context
|
|
|
|
|
|
from app.api.dependencies.database import get_session
|
2026-09-14 20:19:55 +08:00
|
|
|
|
from app.api.schemas.trading import OrderCreateRequest, OrderCreateResponse
|
2026-09-12 15:35:12 +08:00
|
|
|
|
from app.api.views.envelope import envelope, list_envelope
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
2026-09-14 20:19:55 +08:00
|
|
|
|
from app.service.api_transaction_service import ApiTransactionService
|
2026-09-13 19:24:59 +08:00
|
|
|
|
from app.service.authorization_service import AuthorizationService
|
|
|
|
|
|
from app.service.suitability_service import SuitabilityService
|
2026-09-12 15:35:12 +08:00
|
|
|
|
from app.service.trade_service import TradeService
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/users/me", tags=["trading"])
|
|
|
|
|
|
|
2026-09-14 20:19:55 +08:00
|
|
|
|
#: T002 的幂等作用域。与权限码同名,便于审计时一眼对上是哪个写操作。
|
|
|
|
|
|
ORDER_SCOPE = "trade:order:create"
|
|
|
|
|
|
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
|
def _service(session: AsyncSession, context: RequestContext) -> TradeService:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
return TradeService(session, suitability_evaluator=SuitabilityService())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _authorize(context: RequestContext, permission: str) -> None:
|
|
|
|
|
|
"""Enforce the endpoint permission declared in docs/05 before DB work."""
|
|
|
|
|
|
await AuthorizationService.require(context, permission)
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "account:read:self")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
data = await _service(session, context).get_account_dashboard(context)
|
|
|
|
|
|
return envelope(data, context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# T002 提交委托(市价立即成交)
|
2026-09-14 20:19:55 +08:00
|
|
|
|
#
|
|
|
|
|
|
# ⚠️ 幂等:本端点此前**没有任何幂等保护** —— 全仓 7 个 controller 共 29 处声明了
|
|
|
|
|
|
# `Idempotency-Key`,唯独 trading.py 一处都没有(`docs/05` §5.2 要求写端点必须带)。
|
|
|
|
|
|
# 后果是**实测复现**过的:同一笔意愿在网关超时后被客户端按标准重试重发,
|
|
|
|
|
|
# 两次请求各自生成新的 `order_no`、各扣一次款、各建一次仓
|
|
|
|
|
|
# (实测:可用现金 -910.50 = 2×455.25,持仓 3500 -> 3700)。
|
|
|
|
|
|
#
|
|
|
|
|
|
# 现在接 `ApiTransactionService.execute_in`:业务写入与幂等回执**同一个事务**,
|
|
|
|
|
|
# 同键同 body 的第二次请求直接回放上次响应,不再重复成交。
|
2026-09-12 15:35:12 +08:00
|
|
|
|
@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
|
2026-09-14 20:19:55 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-12 15:35:12 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-14 20:19:55 +08:00
|
|
|
|
await _authorize(context, ORDER_SCOPE)
|
|
|
|
|
|
|
|
|
|
|
|
async def action(inner: AsyncSession) -> dict[str, object]:
|
|
|
|
|
|
# `commit=False`:提交由 `execute_in` 统一做,业务写入与幂等回执同事务。
|
|
|
|
|
|
response = await _service(inner, context).submit_order(payload, context, commit=False)
|
|
|
|
|
|
# 统一 JSON 化。`execute_in` 回放的是**已落库的 dict**,首次返回值必须是
|
|
|
|
|
|
# 同一形状,否则"重放"与"首次"两种路径返回的字段类型会不一致。
|
|
|
|
|
|
return response.model_dump(mode="json")
|
|
|
|
|
|
|
|
|
|
|
|
data = await ApiTransactionService().execute_in(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
ORDER_SCOPE,
|
|
|
|
|
|
key,
|
|
|
|
|
|
payload.model_dump(mode="json"),
|
|
|
|
|
|
action,
|
|
|
|
|
|
)
|
|
|
|
|
|
# 还原成响应模型再套信封:保持与改动前**完全一致**的响应结构
|
|
|
|
|
|
# (金额仍是字符串化 Decimal,见 docs/05 §3.3)。
|
|
|
|
|
|
return envelope(OrderCreateResponse.model_validate(data), context)
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "trade:order:read")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "trade:order:read")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "trade:order:cancel")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "holding:read:self")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "trade:txn:read")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "trade:txn:read")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
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]:
|
2026-09-13 19:24:59 +08:00
|
|
|
|
await _authorize(context, "account:read:self")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
cursor_id = int(cursor) if cursor else None
|
|
|
|
|
|
data = await _service(session, context).list_cash_ledger(
|
|
|
|
|
|
context, limit=limit, cursor=cursor_id
|
|
|
|
|
|
)
|
2026-09-13 19:24:59 +08:00
|
|
|
|
return envelope(data, context)
|