From 53a178f11c99672cc1ab063d117298ac048d236f Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 5 Jul 2026 13:45:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=91=E5=B8=81=E6=93=8D=E4=BD=9C=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 33 ++++ src/features/operations/api.test.ts | 8 + .../operations/pages/CoinAdjustmentPage.jsx | 30 ++- .../pages/CoinAdjustmentPage.test.jsx | 178 ++++++++++++++++++ src/shared/api/generated/schema.d.ts | 13 +- src/shared/api/types.ts | 3 + 6 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/features/operations/pages/CoinAdjustmentPage.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 7e5872c..183999a 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2646,6 +2646,39 @@ }, "post": { "operationId": "createCoinAdjustment", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["amount", "targetUserId", "reason", "remark"], + "properties": { + "amount": { + "type": "integer", + "format": "int64" + }, + "commandId": { + "type": "string" + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "remark": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "targetUserId": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, "responses": { "200": { "$ref": "#/components/responses/EmptyResponse" diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index e10cfde..7826f4d 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -109,6 +109,7 @@ test("coin adjustment APIs use generated admin paths", async () => { amount: 500, commandId: "coin-adjustment-test", reason: "运营发放", + remark: "客服复核后补发", targetUserId: "1001", }); @@ -124,6 +125,13 @@ test("coin adjustment APIs use generated admin paths", async () => { expect(lookupInit?.method).toBe("GET"); expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments"); expect(createInit?.method).toBe("POST"); + expect(JSON.parse(String(createInit?.body))).toMatchObject({ + amount: 500, + commandId: "coin-adjustment-test", + reason: "运营发放", + remark: "客服复核后补发", + targetUserId: "1001", + }); }); test("coin ledger API sends type filter", async () => { diff --git a/src/features/operations/pages/CoinAdjustmentPage.jsx b/src/features/operations/pages/CoinAdjustmentPage.jsx index 9022337..9c794ee 100644 --- a/src/features/operations/pages/CoinAdjustmentPage.jsx +++ b/src/features/operations/pages/CoinAdjustmentPage.jsx @@ -38,6 +38,7 @@ const emptyForm = () => ({ amount: "", mode: "increase", reason: adjustmentReasons[0], + remark: "", userKeyword: "", }); @@ -60,6 +61,12 @@ const columns = [ render: (item) => item.reason || "-", width: "minmax(170px, 0.85fr)", }, + { + key: "remark", + label: "备注", + render: (item) => item.remark || "-", + width: "minmax(220px, 1fr)", + }, { key: "operator", label: "发放人", @@ -221,7 +228,7 @@ export function CoinAdjustmentPage() { 0 ? { @@ -275,6 +282,19 @@ export function CoinAdjustmentPage() { {targetUser ? : null} + + changeForm({ remark: event.target.value })} + /> + 512) { + throw new Error("备注不能超过 512 个字符"); + } return { amount: signedAmount, commandId: `coin-adjustment-${Date.now()}`, reason, + remark, targetUserId: String(targetUser.userId), }; } diff --git a/src/features/operations/pages/CoinAdjustmentPage.test.jsx b/src/features/operations/pages/CoinAdjustmentPage.test.jsx new file mode 100644 index 0000000..f554a20 --- /dev/null +++ b/src/features/operations/pages/CoinAdjustmentPage.test.jsx @@ -0,0 +1,178 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, test, vi } from "vitest"; +import { AppProviders } from "@/app/providers.jsx"; +import { queryClient } from "@/shared/query/client"; +import { CoinAdjustmentPage } from "./CoinAdjustmentPage.jsx"; + +vi.mock("@/app/auth/AuthProvider.jsx", () => ({ + AuthProvider: ({ children }) => children, + useAuth: () => ({ can: () => true }), +})); + +afterEach(() => { + queryClient.clear(); + vi.unstubAllGlobals(); +}); + +test("coin adjustment does not submit when remark is blank", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createCoinAdjustmentFetch()); + + renderPage(); + await openReadyDialog(user); + await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); + await user.click(screen.getByRole("button", { name: "搜索" })); + await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); + await user.type(screen.getByRole("textbox", { name: "备注" }), " "); + await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); + await user.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => expect(screen.getByText("请输入备注")).toBeInTheDocument()); + expect(coinAdjustmentPostBodies()).toHaveLength(0); +}); + +test("coin adjustment request body includes trimmed remark", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createCoinAdjustmentFetch()); + + renderPage(); + await openReadyDialog(user); + await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); + await user.click(screen.getByRole("button", { name: "搜索" })); + await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); + await user.type(screen.getByRole("textbox", { name: "备注" }), " 客服复核后补发 "); + await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); + await user.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => + expect(coinAdjustmentPostBodies()).toEqual([ + expect.objectContaining({ + amount: 500, + reason: "运营发放", + remark: "客服复核后补发", + targetUserId: "1001", + }), + ]), + ); +}); + +test("coin adjustment does not submit when remark exceeds 512 characters", async () => { + const user = userEvent.setup(); + vi.stubGlobal("fetch", createCoinAdjustmentFetch()); + + renderPage(); + await openReadyDialog(user); + await user.type(screen.getByRole("textbox", { name: "用户 ID" }), "1001"); + await user.click(screen.getByRole("button", { name: "搜索" })); + await waitFor(() => expect(screen.getByText("测试用户")).toBeInTheDocument()); + fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补".repeat(513) } }); + await user.type(screen.getByRole("textbox", { name: "发放数量" }), "500"); + await user.click(screen.getByRole("button", { name: "确认" })); + + await waitFor(() => expect(screen.getByText("备注不能超过 512 个字符")).toBeInTheDocument()); + expect(coinAdjustmentPostBodies()).toHaveLength(0); +}); + +test("coin adjustment list displays remark and falls back for empty remark", async () => { + vi.stubGlobal( + "fetch", + createCoinAdjustmentFetch({ + items: [ + coinAdjustmentFixture({ entryId: 1, remark: "客服复核后补发", transactionId: "tx-remark" }), + coinAdjustmentFixture({ entryId: 2, remark: "", transactionId: "tx-empty" }), + ], + }), + ); + + renderPage(); + + await waitFor(() => expect(screen.getByText("客服复核后补发")).toBeInTheDocument()); + expect(screen.getByText("备注")).toBeInTheDocument(); + const rows = document.querySelectorAll(".admin-row:not(.admin-row--head)"); + expect(within(rows[1]).getByText("-")).toBeInTheDocument(); +}); + +function renderPage() { + return render( + + + + + , + ); +} + +async function openReadyDialog(user) { + await waitFor(() => expect(screen.getByText("当前无数据")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "金币增减" })); + await waitFor(() => expect(screen.getByRole("dialog", { name: "金币增减" })).toBeInTheDocument()); +} + +function coinAdjustmentPostBodies() { + return vi + .mocked(fetch) + .mock.calls.filter(([url, init]) => String(url).includes("/api/v1/admin/operations/coin-adjustments") && init?.method === "POST") + .map(([, init]) => JSON.parse(String(init?.body))); +} + +function createCoinAdjustmentFetch({ items = [] } = {}) { + return vi.fn(async (input, init = {}) => { + const url = String(input); + const method = init.method || "GET"; + + if (url.includes("/api/v1/admin/operations/coin-adjustments/target")) { + return jsonResponse({ + code: 0, + data: { + displayUserId: "1001", + userId: "1001", + username: "测试用户", + }, + }); + } + if (url.includes("/api/v1/admin/operations/coin-adjustments") && method === "POST") { + return jsonResponse({ + code: 0, + data: { + amount: 500, + availableDelta: 500, + balanceAfter: 1500, + transactionId: "tx-created", + user: { displayUserId: "1001", userId: "1001", username: "测试用户" }, + }, + }); + } + if (url.includes("/api/v1/admin/operations/coin-adjustments")) { + return jsonResponse({ code: 0, data: { items, page: 1, pageSize: 50, total: items.length } }); + } + + return jsonResponse({ code: 0, data: {} }); + }); +} + +function coinAdjustmentFixture(patch = {}) { + return { + amount: 500, + availableAfter: 1500, + availableDelta: 500, + createdAtMs: 1762300800000, + direction: "income", + entryId: 1, + operator: { name: "运营" }, + reason: "运营发放", + remark: "", + transactionId: "tx-1", + user: { displayUserId: "1001", userId: "1001", username: "测试用户" }, + userId: "1001", + ...patch, + }; +} + +function jsonResponse(body) { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index e98d72d..518790f 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -6855,7 +6855,18 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + /** Format: int64 */ + amount: number; + commandId?: string; + reason: string; + remark: string; + targetUserId: string; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; }; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 58a45bb..6b46454 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -316,6 +316,7 @@ export interface CoinAdjustmentDto { operator?: CoinAdjustmentOperatorDto; operatorUserId?: string; reason?: string; + remark?: string; transactionId: string; user: CoinLedgerUserDto; userId: string; @@ -325,6 +326,7 @@ export interface CoinAdjustmentPayload { amount: number; commandId?: string; reason: string; + remark: string; targetUserId: string; } @@ -333,6 +335,7 @@ export interface CoinAdjustmentCreateDto { availableDelta: number; balanceAfter: number; reason?: string; + remark?: string; transactionId: string; user: CoinLedgerUserDto; }