金币操作限制
This commit is contained in:
parent
feefcba3c8
commit
53a178f11c
@ -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"
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
178
src/features/operations/pages/CoinAdjustmentPage.test.jsx
Normal file
178
src/features/operations/pages/CoinAdjustmentPage.test.jsx
Normal 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,
|
||||
});
|
||||
}
|
||||
13
src/shared/api/generated/schema.d.ts
vendored
13
src/shared/api/generated/schema.d.ts
vendored
@ -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"];
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user