From 30415593a67d747eac0a85b7f5f5bbb142f32ac5 Mon Sep 17 00:00:00 2001 From: CieanYau Date: Sat, 7 Nov 2026 01:01:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A2=E5=8A=A1=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E8=AE=B0=E5=BD=95=E8=AF=B7=E6=B1=82=E4=B9=B1=E5=BA=8F?= =?UTF-8?q?=E4=B8=8E=E5=88=97=E8=A1=A8=E7=A6=BB=E9=A1=B5=E5=9B=9E=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pages/finance/index.vue | 22 +++- tests/review-finance.e2e.test.mjs | 166 ++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 tests/review-finance.e2e.test.mjs diff --git a/app/pages/finance/index.vue b/app/pages/finance/index.vue index 137eb4d..8b6b5c9 100644 --- a/app/pages/finance/index.vue +++ b/app/pages/finance/index.vue @@ -13,6 +13,7 @@ definePageMeta({ }); import { toDisplayString } from "vue"; import { onMounted } from "vue"; +import { onScopeDispose } from "vue"; import { watch } from "vue"; import { createBlock } from "vue"; import { ref } from "vue"; @@ -315,9 +316,11 @@ var je = { pt = ref(!1), G = ref(``), K = ref(`0.00`), - q = 0; + q = 0, + disposed = !1; // 交易流水使用独立请求序号,快速筛选时忽略更早的结果。 async function J() { + if (disposed) return; let e = ++q; ((ut.value = !0), (U.value = ``)); try { @@ -341,6 +344,7 @@ var je = { let Y = 0; // 余额流水与累计金额同步更新;失败时不保留旧金额冒充新结果。 async function X() { + if (disposed) return; let e = ++Y; ((pt.value = !0), (G.value = ``)); try { @@ -412,6 +416,7 @@ var je = { Ct = 0; // 按服务端状态分页查询提现记录;本地关键词筛选仅作用于当前返回页。 async function Q() { + if (disposed) return; let e = ++Ct; ((bt.value = !0), (xt.value = ``)); try { @@ -442,28 +447,37 @@ var je = { Dt = ref(0), Ot = ref(1), kt = ref(!1), - $ = ref(``); - // 冻结余额独立分页读取;异常时清空旧列表并显示错误。 + $ = ref(``), + freezeRequestVersion = 0; + // 冻结记录与其他流水遵守同一代次规则,旧请求不能改写结果、错误或加载状态。 async function At() { + if (disposed) return; + let requestVersion = ++freezeRequestVersion; ((kt.value = !0), ($.value = ``)); try { let e = await _.get(`/account/credit/freeze`, { page: Ot.value, limit: T.value, }); + if (requestVersion !== freezeRequestVersion) return; ((Et.value = e.list ?? []), (Dt.value = e.count ?? 0)); } catch (e) { + if (requestVersion !== freezeRequestVersion) return; (($.value = e instanceof Error ? e.message : `加载冻结记录失败`), (Et.value = []), (Dt.value = 0)); } finally { - kt.value = !1; + requestVersion === freezeRequestVersion && (kt.value = !1); } } function jt(e) { ((Ot.value = e.page + 1), (T.value = e.rows), At()); } return ( + // 与共用列表加载器一致:销毁后拒绝新读取,并作废各列表全部在途响应。 + onScopeDispose(() => { + ((disposed = !0), q++, Y++, Ct++, freezeRequestVersion++); + }), watch( () => x.query.tab, (e) => { diff --git a/tests/review-finance.e2e.test.mjs b/tests/review-finance.e2e.test.mjs new file mode 100644 index 0000000..8a1873e --- /dev/null +++ b/tests/review-finance.e2e.test.mjs @@ -0,0 +1,166 @@ +/** 冻结记录的响应乱序回归:只控制本地 HTTP 边界,页面与请求层使用生产实现。 */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +for (const outcome of ["late success", "late failure", "newer read pending"]) { + test( + `finance freeze records ignore an older response with ${outcome}`, + { timeout: 35000 }, + (t) => + scenario(t, { loginMode: "password-only" }, async (page) => { + let freezeReads = 0; + let releaseOlder; + let releaseNewer; + const olderHeld = new Promise((resolve) => { + releaseOlder = resolve; + }); + const newerHeld = new Promise((resolve) => { + releaseNewer = resolve; + }); + const freezePath = "/console/v1/account/credit/freeze"; + const emptyPaths = [ + "/console/v1/transaction", + "/console/v1/credit", + "/console/v1/withdraw", + "/console/v1/order", + "/console/v1/refund/pending/amount", + ]; + await page.route( + (url) => + url.origin === backendOrigin && + (url.pathname === freezePath || emptyPaths.includes(url.pathname)), + async (route) => { + const path = new URL(route.request().url()).pathname; + const fulfill = (data, status = 200) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": appOrigin }, + body: JSON.stringify({ + status, + msg: status === 200 ? "" : "旧冻结请求失败", + data, + }), + }); + if (path === "/console/v1/refund/pending/amount") + return fulfill({ amount: "0.00" }); + if (path !== freezePath) + return fulfill({ list: [], count: 0, total_amount: "0.00" }); + const sequence = ++freezeReads; + if (sequence === 2) await olderHeld; + if (sequence === 3 && outcome === "newer read pending") + await newerHeld; + if (sequence === 2 && outcome === "late failure") + return fulfill({}, 400); + return fulfill({ + list: [ + { + id: 9, + amount: sequence === 2 ? "110.00" : "220.00", + client_notes: + sequence === 1 + ? "初始冻结快照" + : sequence === 2 + ? "较早冻结快照" + : "最新冻结快照", + nickname: "audit-user", + create_time: 1784619935, + }, + ], + count: 1, + }); + }, + ); + try { + await login(page, "/finance?tab=freeze"); + await page.getByText("初始冻结快照", { exact: true }).waitFor(); + const transaction = page.getByRole("tab", { + name: "交易记录", + exact: true, + }); + const freeze = page.getByRole("tab", { + name: "冻结记录", + exact: true, + }); + await transaction.click(); + await freeze.click(); + await eventually( + () => freezeReads, + (value) => value === 2, + "Older freeze read is held at the HTTP boundary", + ); + await transaction.click(); + await freeze.click(); + await eventually( + () => freezeReads, + (value) => value === 3, + "Newer freeze read has started", + ); + const panel = page.getByRole("tabpanel").filter({ visible: true }); + const loading = panel.locator(".p-datatable-mask"); + if (outcome === "newer read pending") + await loading.waitFor({ state: "visible" }); + else await page.getByText("最新冻结快照", { exact: true }).waitFor(); + const olderResponse = page.waitForResponse( + (response) => new URL(response.url()).pathname === freezePath, + ); + releaseOlder(); + await (await olderResponse).finished(); + // 等浏览器完成响应处理与绘制后再核对当前列表,不直接读取组件内部状态。 + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + if (outcome === "newer read pending") { + // 已开始淡出的遮罩仍暂时可见;等待它自身的过渡结束再判断是否仍在加载。 + await loading.evaluateAll((nodes) => + Promise.all( + nodes.flatMap((node) => + node + .getAnimations() + .map((animation) => animation.finished.catch(() => {})), + ), + ), + ); + assert.equal( + await loading.isVisible(), + true, + "An older request must not finish the newer request's loading state", + ); + releaseNewer(); + await page.getByText("最新冻结快照", { exact: true }).waitFor(); + } + assert.equal( + await page.getByText("最新冻结快照", { exact: true }).isVisible(), + true, + ); + assert.equal( + await page.getByText("较早冻结快照", { exact: true }).isVisible(), + false, + ); + assert.equal(await panel.locator(".p-message-error").count(), 0); + await loading.waitFor({ state: "hidden" }); + assert.equal(new URL(page.url()).searchParams.get("tab"), "freeze"); + return { + olderResponse: outcome, + freezeReads, + finalSnapshot: "latest", + staleErrorVisible: false, + loadingBelongsToLatestRead: true, + }; + } finally { + releaseOlder(); + releaseNewer(); + } + }), + ); +}