From 64a4ca5c0a523ffc51c861eac1291671b90a2f3a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 06:38:58 +0800 Subject: fix(api,web): 下单/读取请求身份隔离与零手续费支持(3d-1 评审修正) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web: orderSeq 拆分为 orderCreateSeq/orderReadSeq,并发刷新不再丢弃在途下单结果;换基金/换 token 双序列失效并携带 fundId 双重守卫 - 零手续费:DB CHECK fee_amount >= 0(幂等迁移),校验改为拒绝负数,UI 提示与占位符同步 - 测试:API 增加零手续费回放与 DB 负手续费约束回归(43/43),浏览器新增 H 系列延迟 POST 并发刷新/失败恢复/换基金隔离(78/78) --- qa/driver/browser-test.js | 99 +++++++++++++++++++++++++++++++++-- src/FundLab.Api/Persistence.fs | 9 ++-- src/FundLab.Web/App.fs | 57 ++++++++++++-------- tests/FundLab.Api.Tests/OrderTests.fs | 85 ++++++++++++++++++++++++++++-- 4 files changed, 218 insertions(+), 32 deletions(-) diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 2f18ac2..4e557d3 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -318,13 +318,13 @@ async function orderScenario(page) { "G4 非法金额校验提示" ); await page.fill(".order-amount-input", "100.00"); - await page.fill(".order-fee-input", "0.00"); + await page.fill(".order-fee-input", "-0.01"); await page.click(".order-submit-action"); await expectText( page, ".error-banner", - "认购手续费必须是大于零的两位小数金额,例如 1.50", - "G5 非法手续费校验提示" + "认购手续费必须是不小于零的两位小数金额,例如 0.00", + "G5 负手续费校验提示" ); check("G6 校验阶段无认购 POST", orderPosts.length === 0, `posts=${orderPosts.length}`); @@ -406,6 +406,99 @@ async function orderScenario(page) { await page.unroute("**/api/funds/*/orders"); await page.screenshot({ path: SHOTS + "/09-order-retry.png" }); + // ==== H 系列: 下单/读取请求身份隔离 + 零手续费 + 换基金隔离 ==== + await page.route("**/api/funds/*/orders", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + await new Promise((r) => setTimeout(r, 1200)); + await route.continue(); + }); + await page.fill(".order-code-input", "000001"); + await page.fill(".order-amount-input", "5.00"); + await page.fill(".order-fee-input", "0.00"); + await page.click(".order-submit-action"); + await expectText(page, ".order-submit-action", "下单中...", "H1 延迟 POST 进入下单中状态"); + const refreshStart = orderGets; + await page.click(".orders-refresh-action"); + for (let i = 0; i < 60 && orderGets === refreshStart; i++) { + await page.waitForTimeout(100); + } + check("H2 并发刷新在 POST 在途时完成", orderGets > refreshStart, `gets=${orderGets}/${refreshStart}`); + const midRows = await page.locator(".order-row").count(); + check("H3 刷新落地时 POST 仍未完成", midRows === 2, `rows=${midRows}`); + await page.waitForFunction(() => document.querySelectorAll(".order-row").length === 3, null, { timeout: 15000 }); + const doneBtn = ((await page.textContent(".order-submit-action")) || "").trim(); + check("H4 延迟 POST 完成后按钮恢复认购下单", doneBtn === "认购下单", doneBtn); + await summaryValue(page, "可用现金", "19883.00", "H5 零手续费下单后可用现金 19883.00"); + await summaryValue(page, "保留现金", "117.00", "H6 零手续费下单后保留现金 117.00"); + const zeroFeeRow = ((await page.textContent(".pending-orders")) || ""); + check( + "H7 零手续费订单行金额 5.00 手续费 0.00 预留 5.00", + zeroFeeRow.includes("金额 5.00") && zeroFeeRow.includes("手续费 0.00") && zeroFeeRow.includes("预留合计 5.00"), + zeroFeeRow.slice(0, 200) + ); + await page.unroute("**/api/funds/*/orders"); + await page.screenshot({ path: SHOTS + "/10-order-delayed-refresh.png" }); + + await page.route("**/api/funds/*/orders", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + await new Promise((r) => setTimeout(r, 1200)); + await route.continue(); + }); + await page.fill(".order-amount-input", "99999.00"); + await page.fill(".order-fee-input", "0.01"); + await page.click(".order-submit-action"); + await expectText(page, ".order-submit-action", "下单中...", "H8 失败路径进入下单中状态"); + const failRefreshStart = orderGets; + await page.click(".orders-refresh-action"); + for (let i = 0; i < 60 && orderGets === failRefreshStart; i++) { + await page.waitForTimeout(100); + } + await page.waitForSelector(".error-banner", { timeout: 15000 }); + const failText = ((await page.textContent(".error-banner")) || "").trim(); + check("H9 延迟 409 显示不足横幅", failText.includes("available cash is not enough"), failText.slice(0, 120)); + const failBtn = ((await page.textContent(".order-submit-action")) || "").trim(); + check("H10 失败后按钮恢复(不被刷新卡死)", failBtn === "认购下单", failBtn); + const failRows = await page.locator(".order-row").count(); + check("H11 失败后无新订单行", failRows === 3, `rows=${failRows}`); + await summaryValue(page, "可用现金", "19883.00", "H12 失败后可用现金不变"); + await summaryValue(page, "保留现金", "117.00", "H13 失败后保留现金不变"); + await page.unroute("**/api/funds/*/orders"); + + await page.route("**/api/funds/*/orders", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + await new Promise((r) => setTimeout(r, 1500)); + await route.continue(); + }); + await page.fill(".order-amount-input", "7.00"); + await page.fill(".order-fee-input", "0.00"); + await page.click(".order-submit-action"); + await expectText(page, ".order-submit-action", "下单中...", "H14 换基金前 POST 在途"); + await page.fill(".fund-name-input", "切换基金测试"); + await page.fill(".fund-cash-input", "3000.00"); + await page.click(".fund-create-action"); + await page.waitForSelector(".order-row", { state: "detached", timeout: 10000 }); + check("H15 换基金立即清空旧基金订单行", true); + const switchBtn = ((await page.textContent(".order-submit-action")) || "").trim(); + check("H16 换基金后按钮恢复(在途请求被丢弃)", switchBtn === "认购下单", switchBtn); + const switchHint = ((await page.textContent(".pending-orders")) || "").trim(); + check("H17 换基金后显示暂无待确认订单", switchHint.includes("暂无待确认订单"), switchHint.slice(0, 40)); + await page.waitForTimeout(2200); + const staleRows = await page.locator(".order-row").count(); + check("H18 延迟 POST 落地后旧订单仍未出现", staleRows === 0, `rows=${staleRows}`); + await summaryValue(page, "可用现金", "3000.00", "H19 新基金可用现金 3000.00"); + await summaryValue(page, "保留现金", "0.00", "H20 新基金保留现金 0.00"); + await page.unroute("**/api/funds/*/orders"); + await page.screenshot({ path: SHOTS + "/11-fund-switch-isolated.png" }); + await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "GET") { await route.continue(); diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 88c0350..6c60bdd 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -131,13 +131,16 @@ type FundRepository(connectionString: string) = 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), + 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() ); + ALTER TABLE subscription_orders DROP CONSTRAINT IF EXISTS subscription_orders_fee_amount_check; + ALTER TABLE subscription_orders ADD CONSTRAINT subscription_orders_fee_amount_check CHECK (fee_amount >= 0); + CREATE TABLE IF NOT EXISTS subscription_order_idempotencies ( idempotency_key text PRIMARY KEY, request_hash text NOT NULL, @@ -564,8 +567,8 @@ type FundRepository(connectionString: string) = 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 command.FeeAmount < 0m then + Error "fee amount cannot be negative" elif Decimal.Round(command.Amount, 2) <> command.Amount then Error "amount exceeds cash precision" elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 5a89b5b..51113a0 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -228,7 +228,8 @@ type Model = orderCode: string orderAmount: string orderFee: string - orderSeq: int + orderCreateSeq: int + orderReadSeq: int orderInFlight: bool lastOrderAttempt: OrderAttempt option pendingOrders: RawOrder list @@ -258,8 +259,8 @@ type Msg = | OrderAmountChanged of string | OrderFeeChanged of string | OrderCreateRequested - | OrderCreateCompleted of requestId: int * order: RawOrder - | OrderCreateFailed of requestId: int * message: string + | OrderCreateCompleted of requestId: int * fundId: string * order: RawOrder + | OrderCreateFailed of requestId: int * fundId: string * message: string | OrdersReadRequested | OrdersReadCompleted of requestId: int * orders: RawOrder array | OrdersReadFailed of requestId: int * message: string @@ -276,6 +277,11 @@ let isPositiveCash (text: string) = | true, value -> value > 0M | false, _ -> false +let isNonNegativeCash (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 @@ -307,7 +313,8 @@ let init () = orderCode = "" orderAmount = "" orderFee = "" - orderSeq = 0 + orderCreateSeq = 0 + orderReadSeq = 0 orderInFlight = false lastOrderAttempt = None pendingOrders = [] @@ -359,8 +366,8 @@ 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)) + (fun order -> OrderCreateCompleted(requestId, fundId, order)) + (fun error -> OrderCreateFailed(requestId, fundId, errorText error)) let private readOrdersCommand token fundId requestId = Cmd.OfPromise.either @@ -391,7 +398,8 @@ let update message model = orderCode = "" orderAmount = "" orderFee = "" - orderSeq = model.orderSeq + 1 + orderCreateSeq = model.orderCreateSeq + 1 + orderReadSeq = model.orderReadSeq + 1 orderInFlight = false lastOrderAttempt = None pendingOrders = [] @@ -543,6 +551,11 @@ let update message model = createdFund = Some fund fundInFlight = false lastCreateAttempt = None + orderCreateSeq = model.orderCreateSeq + 1 + orderReadSeq = model.orderReadSeq + 1 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None }, Cmd.ofMsg OrdersReadRequested @@ -596,12 +609,12 @@ let update message model = { 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 not (isValidCashText fee) || not (isNonNegativeCash fee) then + { model with error = Some "认购手续费必须是不小于零的两位小数金额,例如 0.00" }, Cmd.none elif model.orderInFlight then model, Cmd.none else - let requestId = model.orderSeq + 1 + let requestId = model.orderCreateSeq + 1 let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee { @@ -609,7 +622,7 @@ let update message model = orderCode = code orderAmount = amount orderFee = fee - orderSeq = requestId + orderCreateSeq = requestId orderInFlight = true lastOrderAttempt = Some @@ -631,8 +644,9 @@ let update message model = feeAmount = fee } requestId - | OrderCreateCompleted (requestId, order) -> - if requestId = model.orderSeq then + | OrderCreateCompleted (requestId, fundId, order) -> + if requestId = model.orderCreateSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with orderInFlight = false @@ -642,24 +656,25 @@ let update message model = Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ] else model, Cmd.none - | OrderCreateFailed (requestId, message) -> - if requestId = model.orderSeq then + | OrderCreateFailed (requestId, fundId, message) -> + if requestId = model.orderCreateSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) 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 + let requestId = model.orderReadSeq + 1 - { model with orderSeq = requestId; error = None }, + { model with orderReadSeq = 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 + if requestId = model.orderReadSeq then { model with pendingOrders = orders |> Array.toList @@ -669,7 +684,7 @@ let update message model = else model, Cmd.none | OrdersReadFailed (requestId, message) -> - if requestId = model.orderSeq then + if requestId = model.orderReadSeq then { model with error = Some message }, Cmd.none else model, Cmd.none @@ -1068,7 +1083,7 @@ let private subscribePanel model dispatch = Html.span "认购手续费(元)" Html.input [ prop.className "text-input order-fee-input" - prop.placeholder "例如 1.50" + prop.placeholder "0.00 或 1.50" prop.value model.orderFee prop.onChange (fun value -> dispatch (OrderFeeChanged value)) ] @@ -1084,7 +1099,7 @@ let private subscribePanel model dispatch = ] Html.p [ prop.className "hint" - prop.text "金额与手续费按两位小数字符串提交;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" + prop.text "金额与手续费按两位小数字符串提交,手续费允许 0.00;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" ] Html.div [ prop.className "pending-orders" diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index 5e6771c..d1f57df 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -6,6 +6,7 @@ open System.Net.Http open System.Text.Json open System.Threading.Tasks open FundLab.Api +open Npgsql open Xunit [] @@ -167,7 +168,7 @@ type OrderTests(fixture: PostgresFixture) = orderBody code "0.00" "1.00" orderBody code "-5.00" "1.00" orderBody code "10.005" "1.00" - orderBody code "10.00" "0.00" + orderBody code "10.00" "-0.01" orderBody code "10.00" "1.005" "{\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" "{\"fundCode\":123,\"amount\":\"10.00\",\"feeAmount\":\"1.00\"}" @@ -189,14 +190,14 @@ type OrderTests(fixture: PostgresFixture) = Assert.Equal(0, orderCount listBody) let boundaryStatus, boundaryBody = - postOrder api fundId key (orderBody code "0.01" "0.01") + postOrder api fundId key (orderBody code "0.01" "0.00") Assert.Equal(201, boundaryStatus) - Assert.Contains("\"reservedTotal\":\"0.02\"", boundaryBody) + Assert.Contains("\"reservedTotal\":\"0.01\"", boundaryBody) let _, fundAfterBoundary = getFund api fundId - Assert.Contains("\"availableCash\":\"999.98\"", fundAfterBoundary) - Assert.Contains("\"reservedCash\":\"0.02\"", fundAfterBoundary) + Assert.Contains("\"availableCash\":\"999.99\"", fundAfterBoundary) + Assert.Contains("\"reservedCash\":\"0.01\"", fundAfterBoundary) [] member _.``subscription order requires authentication and an idempotency key``() = @@ -398,3 +399,77 @@ type OrderTests(fixture: PostgresFixture) = Assert.Equal(200, replayStatus) Assert.Equal(orderId, PersistenceTestHelpers.responseId replayResponse) + + [] + member _.``zero fee subscription order is accepted and replays``() = + let code = seedInstrument () + let fundId = createFund 200.00m + let api = app () + let key = fixture.Key("order-zero-fee") + + let status, body = + postOrder api fundId key (orderBody code "50.00" "0.00") + + Assert.Equal(201, status) + Assert.Contains("\"amount\":\"50.00\"", body) + Assert.Contains("\"feeAmount\":\"0.00\"", body) + Assert.Contains("\"reservedTotal\":\"50.00\"", body) + Assert.Contains("\"status\":\"submitted\"", body) + let orderId = PersistenceTestHelpers.responseId body + + let replayStatus, replayBody = postOrder api fundId key (orderBody code "50.00" "0.00") + Assert.Equal(200, replayStatus) + Assert.Equal(orderId, PersistenceTestHelpers.responseId replayBody) + + let _, fundBody = getFund api fundId + Assert.Contains("\"availableCash\":\"150.00\"", fundBody) + Assert.Contains("\"reservedCash\":\"50.00\"", fundBody) + + let _, listBody = getOrderList api fundId + Assert.Equal(1, orderCount listBody) + + [] + member _.``database constraint rejects negative fee but allows exact zero fee``() = + let code = seedInstrument () + let fundId = createFund 100.00m + repository () |> ignore + + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + + let insertOrder (fee: decimal) (reservedTotal: decimal) = + use command = connection.CreateCommand() + command.CommandText <- + """ + 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, 'submitted', true) + """ + + command.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid).Value <- box (Guid.NewGuid()) + command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid).Value <- box fundId + command.Parameters.Add("fund_code", NpgsqlTypes.NpgsqlDbType.Text).Value <- box code + command.Parameters.Add("amount", NpgsqlTypes.NpgsqlDbType.Numeric).Value <- box 10.00m + command.Parameters.Add("fee_amount", NpgsqlTypes.NpgsqlDbType.Numeric).Value <- box fee + command.Parameters.Add("reserved_total", NpgsqlTypes.NpgsqlDbType.Numeric).Value <- box reservedTotal + command.ExecuteNonQuery() |> ignore + + Assert.Throws(fun () -> insertOrder -1.00m 9.00m) |> ignore + + insertOrder 0.00m 10.00m + + let negativeCount = + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM subscription_orders WHERE fund_id = @fund_id AND fee_amount < 0" + [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + let zeroCount = + PersistenceTestHelpers.queryCount + fixture.ConnectionString + "SELECT count(*) FROM subscription_orders WHERE fund_id = @fund_id AND fee_amount = 0" + [ "fund_id", box fundId, NpgsqlTypes.NpgsqlDbType.Uuid ] + + Assert.Equal(0L, negativeCount) + Assert.Equal(1L, zeroCount) -- cgit v1.2.3