summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 06:23:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 06:23:10 +0800
commit03f89aea966dfe9edcd08695a79788a6be703b26 (patch)
tree3d09c85f4df050197df6c707492a17588c3335cf /src/FundLab.Api/App.fs
parentd870d67cd0dfe9d48205f16cb8fbcfc4bd5e0b05 (diff)
downloadfund-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.fs103
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 [])