diff --git a/app/api/controllers/trading.py b/app/api/controllers/trading.py index afac8e0..3451e60 100644 --- a/app/api/controllers/trading.py +++ b/app/api/controllers/trading.py @@ -22,20 +22,24 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, Header, 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.schemas.trading import OrderCreateRequest, OrderCreateResponse from app.api.views.envelope import envelope, list_envelope from app.core.contracts import RequestContext +from app.service.api_transaction_service import ApiTransactionService from app.service.authorization_service import AuthorizationService from app.service.suitability_service import SuitabilityService from app.service.trade_service import TradeService router = APIRouter(prefix="/api/v1/users/me", tags=["trading"]) +#: T002 的幂等作用域。与权限码同名,便于审计时一眼对上是哪个写操作。 +ORDER_SCOPE = "trade:order:create" + def _service(session: AsyncSession, context: RequestContext) -> TradeService: return TradeService(session, suitability_evaluator=SuitabilityService()) @@ -58,15 +62,42 @@ async def get_account_dashboard( # T002 提交委托(市价立即成交) +# +# ⚠️ 幂等:本端点此前**没有任何幂等保护** —— 全仓 7 个 controller 共 29 处声明了 +# `Idempotency-Key`,唯独 trading.py 一处都没有(`docs/05` §5.2 要求写端点必须带)。 +# 后果是**实测复现**过的:同一笔意愿在网关超时后被客户端按标准重试重发, +# 两次请求各自生成新的 `order_no`、各扣一次款、各建一次仓 +# (实测:可用现金 -910.50 = 2×455.25,持仓 3500 -> 3700)。 +# +# 现在接 `ApiTransactionService.execute_in`:业务写入与幂等回执**同一个事务**, +# 同键同 body 的第二次请求直接回放上次响应,不再重复成交。 @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 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - await _authorize(context, "trade:order:create") - data = await _service(session, context).submit_order(payload, context) - return envelope(data, context) + 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) # T003 委托列表 diff --git a/app/service/trade_service.py b/app/service/trade_service.py index c600c73..8cf1f3b 100644 --- a/app/service/trade_service.py +++ b/app/service/trade_service.py @@ -220,13 +220,26 @@ class TradeService: # ---------- 账户 / 持仓 ---------- - async def _load_account(self, customer_id: int | str) -> FundSimAccount: + async def _load_account( + self, customer_id: int | str, *, for_update: bool = False + ) -> FundSimAccount: + """加载虚拟账户。 + + ⚠️ `for_update=True` 时加 `SELECT … FOR UPDATE` 行锁,**下单路径必须用**: + 否则同一客户的并发下单会各自读到旧余额、各自判"够不够"、各自扣减, + 结果是可用现金被扣成负数。 + + **加锁顺序固定为 账户 → 持仓**(见 `submit_order`):并发事务只要按同一顺序 + 取锁就不会互相等待成环,这是避免死锁的关键,改顺序前请先想清楚。 + 只读路径(看板、持仓列表、流水)不加锁 —— 它们不修改余额,加锁只会降低并发。 + """ 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() + statement = select(FundSimAccount).where( + FundSimAccount.customer_id == customer_id_int + ) + if for_update: + statement = statement.with_for_update() + account = (await self._session.execute(statement)).scalar_one_or_none() if account is None: raise AccountNotFoundError(f"客户 {customer_id_int} 未开户") if account.status != "正常": @@ -234,16 +247,21 @@ class TradeService: return account async def _load_holding( - self, customer_id: int, product_id: int + self, customer_id: int, product_id: int, *, for_update: bool = False ) -> FundHolding | None: - return ( - await self._session.execute( - select(FundHolding).where( - FundHolding.customer_id == customer_id, - FundHolding.product_id == product_id, - ) - ) - ).scalar_one_or_none() + """加载持仓。 + + `for_update=True` 的用途与风险同 `_load_account`:不锁的话, + "持仓 1000 份,两笔各卖 1000 份"两笔都能读到 `available_quantity=1000` + 并通过校验,最终把可用份额扣成负数(超卖)。 + """ + statement = select(FundHolding).where( + FundHolding.customer_id == customer_id, + FundHolding.product_id == product_id, + ) + if for_update: + statement = statement.with_for_update() + return (await self._session.execute(statement)).scalar_one_or_none() # ---------- 费率规则 ---------- @@ -286,16 +304,31 @@ class TradeService: # ---------- 委托主流程 ---------- async def submit_order( - self, payload: OrderCreateRequest, context: RequestContext + self, + payload: OrderCreateRequest, + context: RequestContext, + *, + commit: bool = True, ) -> OrderCreateResponse: + """提交委托。 + + `commit=False` 供**幂等包装层**使用:T002 现在由 + `ApiTransactionService.execute_in` 包住,业务写入与幂等回执必须在 + **同一个事务**里(`docs/05` §5.2),所以内层不能再自己提交 —— + 否则就成了"内层提交外层事务",幂等记录与业务写入会分处两个事务, + 回执写失败时业务已经落库,重放就失去了意义。 + """ 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, context) quote = await self._fetch_quote(product) - account = await self._load_account(customer_id) - holding = await self._load_holding(customer_id, product.id) + # ⚠️ 「校验 + 扣减」必须在**同一个行锁**内完成,加锁顺序固定:账户 → 持仓。 + # 不加锁时,并发下单各自读到旧余额/旧份额,各自的校验都通过,最终把 + # 可用现金扣成负数、或让卖出超出可用份额(超卖),也可能突破持仓比例上限。 + account = await self._load_account(customer_id, for_update=True) + holding = await self._load_holding(customer_id, product.id, for_update=True) quantity = payload.quantity.quantize(FOUR_PLACES, rounding=ROUND_HALF_UP) if quantity <= 0: @@ -442,7 +475,8 @@ class TradeService: ) self._session.add(ledger) - await self._session.commit() + if commit: + await self._session.commit() return OrderCreateResponse( order_no=order_no, status="已成交",