diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:06:45 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:06:45 +0800 |
| commit | 2ada70d6467aec11b45328112d598454ca50f2d6 (patch) | |
| tree | 5deab7d12095dfd0599c109edc2200a38258040e /src/FundLab.Api/App.fs | |
| parent | c60905e9e7f992a7f8c79c3812e92a44b2d606f5 (diff) | |
| download | fund-lab-2ada70d6467aec11b45328112d598454ca50f2d6.tar.gz | |
feat(api): 接入基金 PostgreSQL 持久化与幂等接口
[变更性质]
- 本提交新增基金创建与读取的 PostgreSQL 持久化能力,并完成 3a API 收口验证。
[新增功能]
- 提供带事务和幂等键的基金创建、重放、冲突检测及基金读取接口。
- 覆盖认证、JSON 输入、数值边界、进程重启持久化和 SQL 故障回滚测试。
[实现方案]
- 使用 Npgsql 建立 `funds` 与 `fund_idempotencies` 表,并以 advisory lock 串行化同一幂等键。
- 通过真实 Kestrel 子进程和临时 PostgreSQL 验证 HTTP 行为;同步更新环境、构建和交接文档。
[影响范围]
- API 新增 PostgreSQL 配置要求 `FUND_LAB_DATABASE_URL`,前端构建入口和本地验证命令同步明确。
- 本提交不包含 `docs/overnight-progress.md` 的现有 Hermes 修改。
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 148 |
1 files changed, 143 insertions, 5 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 44628ab..0eb367d 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -1,6 +1,9 @@ namespace FundLab.Api open System +open System.Globalization +open System.IO +open System.Text.Json open Giraffe open Microsoft.AspNetCore.Http @@ -10,7 +13,103 @@ type EmptyPortfolioResponse = message: string } +type FundApiResponse = + { + id: Guid + name: string + currency: string + initialCash: string + initialUnitNav: string + isSynthetic: bool + availableCash: string + status: string + } + +type ApiErrorResponse = + { + error: string + message: string + } + module App = + let private invariant = CultureInfo.InvariantCulture + + let private cashText (value: decimal) = value.ToString("0.00", invariant) + let private unitNavText (value: decimal) = value.ToString("0.00000000", invariant) + + let private fundResponse (fund: FundRecord) : FundApiResponse = + { + id = fund.Id + name = fund.Name + currency = fund.Currency + initialCash = cashText fund.InitialCash + initialUnitNav = unitNavText fund.InitialUnitNav + isSynthetic = fund.IsSynthetic + availableCash = cashText fund.AvailableCash + status = fund.Status + } + + let private errorResponse status error message : HttpHandler = + setStatusCode status + >=> json ({ + error = error + message = message + } : ApiErrorResponse) + + let private tryStringProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty(name, &property) && property.ValueKind = JsonValueKind.String then + property.GetString() |> Option.ofObj + else + None + + let private tryBoolProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty(name, &property) then + match property.ValueKind with + | JsonValueKind.True -> Some true + | JsonValueKind.False -> Some false + | _ -> None + else + None + + let private tryDecimal (label: string) (text: string) = + if String.IsNullOrWhiteSpace text then + Error(sprintf "%s must be a decimal string" label) + else + match Decimal.TryParse(text, NumberStyles.AllowLeadingSign ||| NumberStyles.AllowDecimalPoint, invariant) with + | true, value -> Ok value + | false, _ -> Error(sprintf "%s must be a decimal string" label) + + let private parseFundCommand (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 "name", tryStringProperty root "initialCash", tryStringProperty root "initialUnitNav", tryBoolProperty root "isSynthetic" with + | Some name, Some initialCashText, Some initialUnitNavText, Some isSynthetic -> + match tryDecimal "initialCash" initialCashText, tryDecimal "initialUnitNav" initialUnitNavText with + | Ok initialCash, Ok initialUnitNav -> + Ok { + Name = name + InitialCash = initialCash + InitialUnitNav = initialUnitNav + IsSynthetic = isSynthetic + } + | Error message, _ + | _, Error message -> Error message + | _ -> + Error "name, initialCash, initialUnitNav and isSynthetic are required" + with + | :? JsonException -> Error "request body must be valid JSON" + + let private invokeHandler handler next ctx = handler next ctx + let private unauthorized : HttpHandler = setStatusCode 401 >=> setHttpHeader "WWW-Authenticate" "Bearer" @@ -35,18 +134,57 @@ module App = } : HealthResponse) let private emptyPortfolio : HttpHandler = - json { - status = "empty" - message = "尚未创建基金/尚未选择投资" - } + json ({ + status = "empty" + message = "尚未创建基金/尚未选择投资" + } : EmptyPortfolioResponse) + + let private createFund (repository: FundRepository) : HttpHandler = + fun next ctx -> + task { + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + match parseFundCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx + | Ok command -> + try + match repository.CreateFund(idempotencyKey, command) with + | FundWriteResult.Created fund -> + return! invokeHandler (setStatusCode 201 >=> json (fundResponse fund)) next ctx + | FundWriteResult.Replayed fund -> + return! invokeHandler (json (fundResponse fund)) next ctx + | FundWriteResult.IdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | FundWriteResult.Invalid message -> + return! invokeHandler (errorResponse 400 "INVALID_FUND_REQUEST" message) next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed") next ctx + } + + let private getFund (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 + | Some fund -> json (fundResponse fund) next ctx + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx - let createApplication () : HttpHandler = + let createApplication (repository: FundRepository) : HttpHandler = choose [ GET >=> route "/health" >=> health subRoute "/api" ( requireBearer >=> choose [ GET >=> route "/portfolio/summary" >=> emptyPortfolio + POST >=> route "/funds" >=> createFund repository + GET >=> routef "/funds/%s" (getFund repository) ] ) setStatusCode 404 >=> text "Not Found" |
