summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 06:38:58 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 06:38:58 +0800
commit64a4ca5c0a523ffc51c861eac1291671b90a2f3a (patch)
tree12b846aa15ba8563dfbce29f53b966667c8d30a7
parent03f89aea966dfe9edcd08695a79788a6be703b26 (diff)
downloadfund-lab-64a4ca5c0a523ffc51c861eac1291671b90a2f3a.tar.gz
fix(api,web): 下单/读取请求身份隔离与零手续费支持(3d-1 评审修正)
- Web: orderSeq 拆分为 orderCreateSeq/orderReadSeq,并发刷新不再丢弃在途下单结果;换基金/换 token 双序列失效并携带 fundId 双重守卫 - 零手续费:DB CHECK fee_amount >= 0(幂等迁移),校验改为拒绝负数,UI 提示与占位符同步 - 测试:API 增加零手续费回放与 DB 负手续费约束回归(43/43),浏览器新增 H 系列延迟 POST 并发刷新/失败恢复/换基金隔离(78/78)
-rw-r--r--qa/driver/browser-test.js99
-rw-r--r--src/FundLab.Api/Persistence.fs9
-rw-r--r--src/FundLab.Web/App.fs57
-rw-r--r--tests/FundLab.Api.Tests/OrderTests.fs85
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
[<Collection("postgres")>]
@@ -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)
[<Fact>]
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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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<Npgsql.PostgresException>(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)