金币操作限制

This commit is contained in:
zhx 2026-07-05 13:45:04 +08:00
parent feefcba3c8
commit 53a178f11c
6 changed files with 263 additions and 2 deletions

View File

@ -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"

View File

@ -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 () => {

View File

@ -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() {
<DataTable
columns={tableColumns}
items={items}
minWidth="1010px"
minWidth="1230px"
pagination={
total > 0
? {
@ -275,6 +282,19 @@ export function CoinAdjustmentPage() {
</div>
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
</AdminFormSection>
<AdminFormSection title="备注">
<TextField
disabled={!abilities.canCreate || submitting}
fullWidth
label="备注"
multiline
required
rows={3}
slotProps={{ htmlInput: { maxLength: 512 } }}
value={form.remark}
onChange={(event) => changeForm({ remark: event.target.value })}
/>
</AdminFormSection>
<AdminFormSection title="金币信息">
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
<TextField
@ -358,10 +378,18 @@ function buildAdjustmentPayload(form, targetUser) {
if (!reason) {
throw new Error("请选择发放理由");
}
const remark = String(form.remark || "").trim();
if (!remark) {
throw new Error("请输入备注");
}
if (remark.length > 512) {
throw new Error("备注不能超过 512 个字符");
}
return {
amount: signedAmount,
commandId: `coin-adjustment-${Date.now()}`,
reason,
remark,
targetUserId: String(targetUser.userId),
};
}

View File

@ -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(
<AppProviders>
<MemoryRouter>
<CoinAdjustmentPage />
</MemoryRouter>
</AppProviders>,
);
}
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,
});
}

View File

@ -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"];
};

View File

@ -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;
}