diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 06:23:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 06:23:10 +0800 |
| commit | 03f89aea966dfe9edcd08695a79788a6be703b26 (patch) | |
| tree | 3d09c85f4df050197df6c707492a17588c3335cf /src/FundLab.Api/App.fs | |
| parent | d870d67cd0dfe9d48205f16cb8fbcfc4bd5e0b05 (diff) | |
| download | fund-lab-03f89aea966dfe9edcd08695a79788a6be703b26.tar.gz | |
feat(api,web): 模拟认购订单创建/读取,幂等与预留现金(3d-1)
- API: POST/GET /api/funds/{fundId}/orders,advisory lock + 行级锁事务预留现金,
幂等键重放/冲突/校验/目录缺失错误码齐全,基金响应新增 reservedCash
- Persistence: subscription_orders 与幂等表 + 保留现金列迁移
- Web: 认购表单与待确认订单列表(已提交·待确认),失败重试复用幂等键,
token 轮换隔离延迟响应
- 测试: API 41(含并发不足/回滚/真实宿主重启回归)、Web 17、Domain 19;
浏览器 QA 58 项全过
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 103 |
1 files changed, 103 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index f61e760..a2aa271 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -22,9 +22,23 @@ type FundApiResponse = initialUnitNav: string isSynthetic: bool availableCash: string + reservedCash: string status: string } +type SubscriptionOrderApiResponse = + { + id: Guid + fundId: Guid + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + isSynthetic: bool + } + type ApiErrorResponse = { error: string @@ -86,9 +100,23 @@ module App = initialUnitNav = unitNavText fund.InitialUnitNav isSynthetic = fund.IsSynthetic availableCash = cashText fund.AvailableCash + reservedCash = cashText fund.ReservedCash status = fund.Status } + let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderApiResponse = + { + id = order.Id + fundId = order.FundId + fundCode = order.FundCode + amount = cashText order.Amount + feeAmount = cashText order.FeeAmount + reservedTotal = cashText order.ReservedTotal + status = order.Status + submittedAt = timestampText order.SubmittedAt + isSynthetic = order.IsSynthetic + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -148,6 +176,30 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseOrderCommand (body: string) = + try + use document = JsonDocument.Parse(body) + let root = document.RootElement + + if root.ValueKind <> JsonValueKind.Object then + Error "request body must be a JSON object" + else + match tryStringProperty root "fundCode", tryStringProperty root "amount", tryStringProperty root "feeAmount" with + | Some fundCode, Some amountText, Some feeAmountText -> + match tryDecimal "amount" amountText, tryDecimal "feeAmount" feeAmountText with + | Ok amount, Ok feeAmount -> + Ok { + FundCode = fundCode + Amount = amount + FeeAmount = feeAmount + } + | Error message, _ + | _, Error message -> Error message + | _ -> + Error "fundCode, amount and feeAmount are required" + with + | :? JsonException -> Error "request body must be valid JSON" + let private invokeHandler handler next ctx = handler next ctx let private unauthorized : HttpHandler = @@ -216,6 +268,55 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx + let private createOrder (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID") next ctx + | true, fundId -> + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + match parseOrderCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateSubscriptionOrder(idempotencyKey, fundId, command) with + | SubscriptionOrderWriteResult.OrderCreated order -> + return! invokeHandler (setStatusCode 201 >=> json (orderResponse order)) next ctx + | SubscriptionOrderWriteResult.OrderReplayed order -> + return! invokeHandler (json (orderResponse order)) next ctx + | SubscriptionOrderWriteResult.OrderIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | SubscriptionOrderWriteResult.OrderInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx + | SubscriptionOrderWriteResult.OrderFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | SubscriptionOrderWriteResult.OrderInstrumentNotFound -> + return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "fund code was not found in the instrument catalog") next ctx + | SubscriptionOrderWriteResult.OrderInsufficientFunds -> + return! invokeHandler (errorResponse 409 "INSUFFICIENT_FUNDS" "available cash is not enough to reserve the amount plus fee") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed") next ctx + } + + let private getOrders (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + let orders = repository.GetSubscriptionOrders fundId + json (orders |> List.map orderResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed" next ctx + let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -311,6 +412,8 @@ module App = [ GET >=> route "/portfolio/summary" >=> emptyPortfolio POST >=> route "/funds" >=> createFund repository + POST >=> routef "/funds/%s/orders" (createOrder repository) + GET >=> routef "/funds/%s/orders" (getOrders repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
