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 | |
| 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')
| -rw-r--r-- | src/FundLab.Api/App.fs | 103 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 331 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 298 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 37 |
5 files changed, 777 insertions, 8 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 []) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index e48d9b1..88c0350 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -28,6 +28,7 @@ type FundRecord = InitialUnitNav: decimal IsSynthetic: bool AvailableCash: decimal + ReservedCash: decimal Status: string } @@ -37,6 +38,36 @@ type FundWriteResult = | IdempotencyConflict | Invalid of string +[<CLIMutable>] +type SubscriptionOrderCommand = + { + FundCode: string + Amount: decimal + FeeAmount: decimal + } + +type SubscriptionOrderRecord = + { + Id: Guid + FundId: Guid + FundCode: string + Amount: decimal + FeeAmount: decimal + ReservedTotal: decimal + Status: string + SubmittedAt: DateTimeOffset + IsSynthetic: bool + } + +type SubscriptionOrderWriteResult = + | OrderCreated of SubscriptionOrderRecord + | OrderReplayed of SubscriptionOrderRecord + | OrderIdempotencyConflict + | OrderInvalid of string + | OrderFundNotFound + | OrderInstrumentNotFound + | OrderInsufficientFunds + type FundRepository(connectionString: string) = let cashMaximum = 999999999999999999.99m let unitNavMaximum = 99999999999999999999.99999999m @@ -92,6 +123,28 @@ type FundRepository(connectionString: string) = CREATE INDEX IF NOT EXISTS fund_nav_observations_date_idx ON fund_nav_observations (instrument_code, nav_date); + + ALTER TABLE funds ADD COLUMN IF NOT EXISTS reserved_cash numeric(20, 2) NOT NULL DEFAULT 0; + + CREATE TABLE IF NOT EXISTS subscription_orders ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + fund_code text NOT NULL, + amount numeric(20, 2) NOT NULL CHECK (amount > 0), + fee_amount numeric(20, 2) NOT NULL CHECK (fee_amount > 0), + reserved_total numeric(20, 2) NOT NULL CHECK (reserved_total > 0), + status text NOT NULL, + is_synthetic boolean NOT NULL, + submitted_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS subscription_order_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + order_id uuid NOT NULL REFERENCES subscription_orders(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); """ let statusText status = @@ -109,6 +162,7 @@ type FundRepository(connectionString: string) = InitialUnitNav = fund.InitialUnitNav IsSynthetic = fund.IsSynthetic AvailableCash = fund.AvailableCash + ReservedCash = fund.FrozenCash Status = statusText fund.Status } @@ -121,7 +175,8 @@ type FundRepository(connectionString: string) = InitialUnitNav = reader.GetDecimal(4) IsSynthetic = reader.GetBoolean(5) AvailableCash = reader.GetDecimal(6) - Status = reader.GetString(7) + ReservedCash = reader.GetDecimal(7) + Status = reader.GetString(8) } let dateTimeOffsetFromReader (reader: DbDataReader) index = @@ -189,7 +244,7 @@ type FundRepository(connectionString: string) = transaction """ SELECT id, name, currency, initial_cash, initial_unit_nav, - is_synthetic, available_cash, status + is_synthetic, available_cash, reserved_cash, status FROM funds WHERE id = @fund_id """ @@ -219,10 +274,10 @@ type FundRepository(connectionString: string) = """ INSERT INTO funds (id, name, currency, initial_cash, initial_unit_nav, - is_synthetic, available_cash, status) + is_synthetic, available_cash, reserved_cash, status) VALUES (@id, @name, @currency, @initial_cash, @initial_unit_nav, - @is_synthetic, @available_cash, @status) + @is_synthetic, @available_cash, @reserved_cash, @status) """ addParameter command "id" NpgsqlDbType.Uuid (box fund.Id) |> ignore @@ -232,6 +287,7 @@ type FundRepository(connectionString: string) = addParameter command "initial_unit_nav" NpgsqlDbType.Numeric (box fund.InitialUnitNav) |> ignore addParameter command "is_synthetic" NpgsqlDbType.Boolean (box fund.IsSynthetic) |> ignore addParameter command "available_cash" NpgsqlDbType.Numeric (box fund.AvailableCash) |> ignore + addParameter command "reserved_cash" NpgsqlDbType.Numeric (box fund.FrozenCash) |> ignore addParameter command "status" NpgsqlDbType.Text (box (statusText fund.Status)) |> ignore command.ExecuteNonQuery() |> ignore @@ -250,6 +306,121 @@ type FundRepository(connectionString: string) = addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore command.ExecuteNonQuery() |> ignore + let orderStatusText = "submitted" + + let orderRecordFromReader (reader: DbDataReader) : SubscriptionOrderRecord = + { + Id = reader.GetGuid(0) + FundId = reader.GetGuid(1) + FundCode = reader.GetString(2) + Amount = reader.GetDecimal(3) + FeeAmount = reader.GetDecimal(4) + ReservedTotal = reader.GetDecimal(5) + Status = reader.GetString(6) + IsSynthetic = reader.GetBoolean(7) + SubmittedAt = reader.GetFieldValue<DateTimeOffset>(8) + } + + let findOrder connection transaction orderId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic, submitted_at + FROM subscription_orders + WHERE id = @order_id + """ + + addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(orderRecordFromReader reader) else None + + let findOrderIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + "SELECT request_hash, fund_id, order_id FROM subscription_order_idempotencies WHERE idempotency_key = @idempotency_key" + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then + Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2)) + else + None + + let instrumentExists connection transaction code = + use command = + commandWithTransaction + connection + transaction + "SELECT 1 FROM instruments WHERE code = @code" + + addParameter command "code" NpgsqlDbType.Text (box code) |> ignore + + use reader = command.ExecuteReader() + reader.Read() + + let lockFundForOrder connection transaction fundId = + use command = + commandWithTransaction + connection + transaction + "SELECT is_synthetic FROM funds WHERE id = @fund_id FOR UPDATE" + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + if reader.Read() then Some(reader.GetBoolean(0)) else None + + let insertSubscriptionOrder connection transaction (order: SubscriptionOrderRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO subscription_orders + (id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic) + VALUES + (@id, @fund_id, @fund_code, @amount, @fee_amount, + @reserved_total, @status, @is_synthetic) + RETURNING submitted_at + """ + + addParameter command "id" NpgsqlDbType.Uuid (box order.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box order.FundId) |> ignore + addParameter command "fund_code" NpgsqlDbType.Text (box order.FundCode) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box order.Amount) |> ignore + addParameter command "fee_amount" NpgsqlDbType.Numeric (box order.FeeAmount) |> ignore + addParameter command "reserved_total" NpgsqlDbType.Numeric (box order.ReservedTotal) |> ignore + addParameter command "status" NpgsqlDbType.Text (box order.Status) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box order.IsSynthetic) |> ignore + + use reader = command.ExecuteReader() + reader.Read() |> ignore + reader.GetFieldValue<DateTimeOffset>(0) + + let insertOrderIdempotency connection transaction key requestHash orderId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO subscription_order_idempotencies (idempotency_key, request_hash, order_id, fund_id) + VALUES (@idempotency_key, @request_hash, @order_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + let upsertInstrument connection transaction (payload: MarketDataSearchPayload) payloadHash (instrument: MarketDataInstrument) = use command = commandWithTransaction @@ -371,6 +542,43 @@ type FundRepository(connectionString: string) = else Ok() + let orderRequestHash (fundId: Guid) (command: SubscriptionOrderCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let fundCode = if isNull command.FundCode then "" else command.FundCode + let payload = + String.concat + "|" + [ + "subscription-order" + encoded (fundId.ToString("D")) + encoded fundCode + (encoded (command.Amount.ToString("G29", invariant))) + (encoded (command.FeeAmount.ToString("G29", invariant))) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + + let validateOrderCommand (command: SubscriptionOrderCommand) = + if String.IsNullOrWhiteSpace command.FundCode then + Error "fund code cannot be empty" + elif command.Amount <= 0m then + Error "amount must be positive" + elif command.FeeAmount <= 0m then + Error "fee amount must be positive" + elif Decimal.Round(command.Amount, 2) <> command.Amount then + Error "amount exceeds cash precision" + elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then + Error "fee amount exceeds cash precision" + elif command.Amount > cashMaximum then + Error "amount exceeds database precision" + elif command.FeeAmount > cashMaximum then + Error "fee amount exceeds database precision" + elif command.Amount + command.FeeAmount > cashMaximum then + Error "reserved total exceeds database precision" + else + Ok() + let ledgerErrorMessage error = match error with | InvalidIdentifier label -> sprintf "%s is invalid" label @@ -552,3 +760,118 @@ type FundRepository(connectionString: string) = () raise error + + member _.CreateSubscriptionOrder(idempotencyKey: string, fundId: Guid, command: SubscriptionOrderCommand) = + if String.IsNullOrWhiteSpace idempotencyKey then + OrderInvalid "idempotency key cannot be empty" + else + match validateOrderCommand command with + | Error message -> OrderInvalid message + | Ok() -> + let fingerprint = orderRequestHash fundId command + let reservedTotal = command.Amount + command.FeeAmount + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findOrderIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, orderId) + when existingHash = fingerprint && existingFundId = fundId -> + match findOrder connection (Some transaction) orderId with + | Some order -> + transaction.Commit() + OrderReplayed order + | None -> + transaction.Rollback() + OrderInvalid "idempotency record references a missing order" + | Some _ -> + transaction.Rollback() + OrderIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + OrderFundNotFound + | Some isSynthetic -> + if instrumentExists connection (Some transaction) command.FundCode then + use cashCommand = + commandWithTransaction + connection + (Some transaction) + """ + UPDATE funds + SET available_cash = available_cash - @reserved_total, + reserved_cash = reserved_cash + @reserved_total + WHERE id = @fund_id AND available_cash >= @reserved_total + """ + + addParameter cashCommand "reserved_total" NpgsqlDbType.Numeric (box reservedTotal) |> ignore + addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + if cashCommand.ExecuteNonQuery() = 0 then + transaction.Rollback() + OrderInsufficientFunds + else + let order: SubscriptionOrderRecord = + { + Id = Guid.NewGuid() + FundId = fundId + FundCode = command.FundCode + Amount = command.Amount + FeeAmount = command.FeeAmount + ReservedTotal = reservedTotal + Status = orderStatusText + SubmittedAt = DateTimeOffset.UnixEpoch + IsSynthetic = isSynthetic + } + + let submittedAt = insertSubscriptionOrder connection (Some transaction) order + insertOrderIdempotency connection (Some transaction) idempotencyKey fingerprint order.Id fundId + transaction.Commit() + OrderCreated { order with SubmittedAt = submittedAt } + else + transaction.Rollback() + OrderInstrumentNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetSubscriptionOrders(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT id, fund_id, fund_code, amount, fee_amount, + reserved_total, status, is_synthetic, submitted_at + FROM subscription_orders + WHERE fund_id = @fund_id + ORDER BY submitted_at DESC, id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<SubscriptionOrderRecord>() + + while reader.Read() do + records.Add(orderRecordFromReader reader) + + records |> Seq.toList diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 43c438f..5a89b5b 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -117,9 +117,29 @@ type FundSummary = initialUnitNav: string isSynthetic: bool availableCash: string + reservedCash: string status: string } +type CreateOrderPayload = + { + idempotencyKey: string + fundCode: string + amount: string + feeAmount: string + } + +type RawOrder = + { + id: string + fundCode: string + amount: string + feeAmount: string + reservedTotal: string + status: string + submittedAt: string + } + type CreateAttempt = { idempotencyKey: string @@ -127,6 +147,14 @@ type CreateAttempt = cash: string } +type OrderAttempt = + { + idempotencyKey: string + fundCode: string + amount: string + feeAmount: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -143,6 +171,12 @@ module Api = [<Import("getFund", "./src/api.js")>] let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative + [<Import("createOrder", "./src/api.js")>] + let createOrder (token: string) (fundId: string) (payload: CreateOrderPayload) : JS.Promise<RawOrder> = jsNative + + [<Import("getOrders", "./src/api.js")>] + let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -191,6 +225,13 @@ type Model = fundInFlight: bool createdFund: FundSummary option lastCreateAttempt: CreateAttempt option + orderCode: string + orderAmount: string + orderFee: string + orderSeq: int + orderInFlight: bool + lastOrderAttempt: OrderAttempt option + pendingOrders: RawOrder list error: string option } @@ -213,6 +254,15 @@ type Msg = | FundReadRequested | FundReadCompleted of requestId: int * fund: FundSummary | FundReadFailed of requestId: int * message: string + | OrderCodeChanged of string + | OrderAmountChanged of string + | OrderFeeChanged of string + | OrderCreateRequested + | OrderCreateCompleted of requestId: int * order: RawOrder + | OrderCreateFailed of requestId: int * message: string + | OrdersReadRequested + | OrdersReadCompleted of requestId: int * orders: RawOrder array + | OrdersReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -221,11 +271,22 @@ let private cashPattern = let isValidCashText (text: string) = cashPattern.IsMatch text +let isPositiveCash (text: string) = + match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value > 0M + | false, _ -> false + let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = match lastAttempt with | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") +let resolveOrderKey (lastAttempt: OrderAttempt option) (code: string) (amount: string) (fee: string) = + match lastAttempt with + | Some attempt when attempt.fundCode = code && attempt.amount = amount && attempt.feeAmount = fee -> + attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + let init () = { token = "" @@ -243,6 +304,13 @@ let init () = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = 0 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None } @@ -287,6 +355,20 @@ let private readFundCommand token fundId requestId = (fun fund -> FundReadCompleted(requestId, fund)) (fun error -> FundReadFailed(requestId, errorText error)) +let private createOrderCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createOrder token fundId payload) + () + (fun order -> OrderCreateCompleted(requestId, order)) + (fun error -> OrderCreateFailed(requestId, errorText error)) + +let private readOrdersCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getOrders token fundId) + () + (fun orders -> OrdersReadCompleted(requestId, orders)) + (fun error -> OrdersReadFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -306,6 +388,13 @@ let update message model = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = model.orderSeq + 1 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None }, Cmd.none @@ -456,7 +545,7 @@ let update message model = lastCreateAttempt = None error = None }, - Cmd.none + Cmd.ofMsg OrdersReadRequested else model, Cmd.none | FundCreateFailed (requestId, message) -> @@ -488,6 +577,102 @@ let update message model = { model with fundInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | OrderCodeChanged value -> + { model with orderCode = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderAmountChanged value -> + { model with orderAmount = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderFeeChanged value -> + { model with orderFee = value; error = None; lastOrderAttempt = None }, Cmd.none + | OrderCreateRequested -> + let code = model.orderCode.Trim() + let amount = model.orderAmount.Trim() + let fee = model.orderFee.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with error = Some "请输入 API token" }, Cmd.none + elif model.createdFund.IsNone then + { model with error = Some "请先创建一个基金" }, Cmd.none + elif code = "" then + { model with error = Some "请输入基金代码" }, Cmd.none + elif not (isValidCashText amount) || not (isPositiveCash amount) then + { model with error = Some "认购金额必须是大于零的两位小数金额,例如 100.00" }, Cmd.none + elif not (isValidCashText fee) || not (isPositiveCash fee) then + { model with error = Some "认购手续费必须是大于零的两位小数金额,例如 1.50" }, Cmd.none + elif model.orderInFlight then + model, Cmd.none + else + let requestId = model.orderSeq + 1 + let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee + + { + model with + orderCode = code + orderAmount = amount + orderFee = fee + orderSeq = requestId + orderInFlight = true + lastOrderAttempt = + Some + { + idempotencyKey = idempotencyKey + fundCode = code + amount = amount + feeAmount = fee + } + error = None + }, + createOrderCommand + model.token + model.createdFund.Value.id + { + idempotencyKey = idempotencyKey + fundCode = code + amount = amount + feeAmount = fee + } + requestId + | OrderCreateCompleted (requestId, order) -> + if requestId = model.orderSeq then + { + model with + orderInFlight = false + lastOrderAttempt = None + error = None + }, + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ] + else + model, Cmd.none + | OrderCreateFailed (requestId, message) -> + if requestId = model.orderSeq then + { model with orderInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none + | OrdersReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.orderSeq + 1 + + { model with orderSeq = requestId; error = None }, + readOrdersCommand model.token fund.id requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + model, Cmd.none + | OrdersReadCompleted (requestId, orders) -> + if requestId = model.orderSeq then + { + model with + pendingOrders = orders |> Array.toList + error = None + }, + Cmd.none + else + model, Cmd.none + | OrdersReadFailed (requestId, message) -> + if requestId = model.orderSeq then + { model with error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -794,6 +979,7 @@ let private fundPanel model dispatch = fundSummaryRow "币种" fund.currency fundSummaryRow "初始现金" fund.initialCash fundSummaryRow "可用现金" fund.availableCash + fundSummaryRow "保留现金" fund.reservedCash fundSummaryRow "初始单位净值" fund.initialUnitNav fundSummaryRow "份额" "0(新基金尚未买入持仓)" fundSummaryRow "状态" fund.status @@ -819,6 +1005,109 @@ let private fundPanel model dispatch = ] ] +let private orderStatusText (status: string) = + if status = "submitted" then "已提交 · 待确认" else status + +let private orderRow (order: RawOrder) = + Html.div [ + prop.className "order-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text order.fundCode ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" order.amount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ] + Html.span [ prop.className "order-cell"; prop.text (sprintf "预留合计 %s" order.reservedTotal) ] + Html.span [ prop.className "order-status"; prop.text (orderStatusText order.status) ] + Html.span [ prop.className "order-cell"; prop.text order.submittedAt ] + ] + ] + +let private subscribePanel model dispatch = + Html.section [ + prop.className "panel subscribe-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "04 / SUBSCRIBE" ] + Html.h2 "认购一笔基金(模拟)" + ] + Html.span [ prop.className "section-note"; prop.text "Pending · no execution" ] + ] + ] + Html.div [ + prop.className "fund-form-row" + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "基金代码" + Html.input [ + prop.className "text-input order-code-input" + prop.placeholder "六位基金代码" + prop.value model.orderCode + prop.onChange (fun value -> dispatch (OrderCodeChanged value)) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "认购金额(元)" + Html.input [ + prop.className "text-input order-amount-input" + prop.placeholder "例如 100.00" + prop.value model.orderAmount + prop.onChange (fun value -> dispatch (OrderAmountChanged value)) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "认购手续费(元)" + Html.input [ + prop.className "text-input order-fee-input" + prop.placeholder "例如 1.50" + prop.value model.orderFee + prop.onChange (fun value -> dispatch (OrderFeeChanged value)) + ] + ] + ] + Html.button [ + prop.className "primary-action order-submit-action" + prop.disabled model.orderInFlight + prop.onClick (fun _ -> dispatch OrderCreateRequested) + prop.text ((if model.orderInFlight then "下单中..." else "认购下单"): string) + ] + ] + ] + Html.p [ + prop.className "hint" + prop.text "金额与手续费按两位小数字符串提交;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" + ] + Html.div [ + prop.className "pending-orders" + prop.children [ + if List.isEmpty model.pendingOrders then + Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ] + else + yield! (model.pendingOrders |> List.map orderRow) + ] + ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action orders-refresh-action" + prop.onClick (fun _ -> dispatch OrdersReadRequested) + prop.text "刷新订单" + ] + ] + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -833,7 +1122,7 @@ let view model dispatch = Html.span "FUND LAB" ] ] - Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ] + Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3D-1" ] ] ] Html.div [ @@ -844,7 +1133,7 @@ let view model dispatch = prop.children [ Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ] Html.h1 "把净值曲线,先看清楚。" - Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。" + Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。认购只生成待确认订单并冻结现金,不执行真实交易。" ] ] Html.div [ @@ -874,7 +1163,8 @@ let view model dispatch = searchPanel model dispatch navPanel model dispatch fundPanel model dispatch - Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ] + subscribePanel model dispatch + Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 0d3b6fd..7b4f738 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -52,3 +52,19 @@ export function createFund(token, payload) { export function getFund(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}`, token); } + +export function createOrder(token, fundId, payload) { + const body = `{"fundCode":${JSON.stringify(payload.fundCode)},"amount":${JSON.stringify(payload.amount)},"feeAmount":${JSON.stringify(payload.feeAmount)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + +export function getOrders(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token); +} diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index dcd8833..58d340d 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -479,6 +479,43 @@ h2 { margin-top: 10px; } +.pending-orders { + display: flex; + flex-direction: column; + gap: 8px; +} + +.order-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #ffffff; + font-size: 12px; + color: #1f2937; +} + +.order-code { + font-family: "SFMono-Regular", Consolas, monospace; + font-weight: 700; + color: #1e40af; +} + +.order-cell { + color: #475569; +} + +.order-status { + padding: 2px 10px; + border: 1px solid #f1b6b6; + border-radius: 999px; + color: #b45309; + font-size: 11px; +} + .footer-note { padding: 16px 0 34px; font-family: "SFMono-Regular", Consolas, monospace; |
