金币操作限制
This commit is contained in:
parent
feefcba3c8
commit
53a178f11c
@ -2646,6 +2646,39 @@
|
|||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "createCoinAdjustment",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
|||||||
@ -109,6 +109,7 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
|||||||
amount: 500,
|
amount: 500,
|
||||||
commandId: "coin-adjustment-test",
|
commandId: "coin-adjustment-test",
|
||||||
reason: "运营发放",
|
reason: "运营发放",
|
||||||
|
remark: "客服复核后补发",
|
||||||
targetUserId: "1001",
|
targetUserId: "1001",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -124,6 +125,13 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
|||||||
expect(lookupInit?.method).toBe("GET");
|
expect(lookupInit?.method).toBe("GET");
|
||||||
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
|
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
|
||||||
expect(createInit?.method).toBe("POST");
|
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 () => {
|
test("coin ledger API sends type filter", async () => {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const emptyForm = () => ({
|
|||||||
amount: "",
|
amount: "",
|
||||||
mode: "increase",
|
mode: "increase",
|
||||||
reason: adjustmentReasons[0],
|
reason: adjustmentReasons[0],
|
||||||
|
remark: "",
|
||||||
userKeyword: "",
|
userKeyword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,6 +61,12 @@ const columns = [
|
|||||||
render: (item) => item.reason || "-",
|
render: (item) => item.reason || "-",
|
||||||
width: "minmax(170px, 0.85fr)",
|
width: "minmax(170px, 0.85fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "remark",
|
||||||
|
label: "备注",
|
||||||
|
render: (item) => item.remark || "-",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "operator",
|
key: "operator",
|
||||||
label: "发放人",
|
label: "发放人",
|
||||||
@ -221,7 +228,7 @@ export function CoinAdjustmentPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1010px"
|
minWidth="1230px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -275,6 +282,19 @@ export function CoinAdjustmentPage() {
|
|||||||
</div>
|
</div>
|
||||||
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
|
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
|
||||||
</AdminFormSection>
|
</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="金币信息">
|
<AdminFormSection title="金币信息">
|
||||||
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField
|
||||||
@ -358,10 +378,18 @@ function buildAdjustmentPayload(form, targetUser) {
|
|||||||
if (!reason) {
|
if (!reason) {
|
||||||
throw new Error("请选择发放理由");
|
throw new Error("请选择发放理由");
|
||||||
}
|
}
|
||||||
|
const remark = String(form.remark || "").trim();
|
||||||
|
if (!remark) {
|
||||||
|
throw new Error("请输入备注");
|
||||||
|
}
|
||||||
|
if (remark.length > 512) {
|
||||||
|
throw new Error("备注不能超过 512 个字符");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
amount: signedAmount,
|
amount: signedAmount,
|
||||||
commandId: `coin-adjustment-${Date.now()}`,
|
commandId: `coin-adjustment-${Date.now()}`,
|
||||||
reason,
|
reason,
|
||||||
|
remark,
|
||||||
targetUserId: String(targetUser.userId),
|
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;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** Format: int64 */
|
||||||
|
amount: number;
|
||||||
|
commandId?: string;
|
||||||
|
reason: string;
|
||||||
|
remark: string;
|
||||||
|
targetUserId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -316,6 +316,7 @@ export interface CoinAdjustmentDto {
|
|||||||
operator?: CoinAdjustmentOperatorDto;
|
operator?: CoinAdjustmentOperatorDto;
|
||||||
operatorUserId?: string;
|
operatorUserId?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
remark?: string;
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
user: CoinLedgerUserDto;
|
user: CoinLedgerUserDto;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -325,6 +326,7 @@ export interface CoinAdjustmentPayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
commandId?: string;
|
commandId?: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
remark: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,6 +335,7 @@ export interface CoinAdjustmentCreateDto {
|
|||||||
availableDelta: number;
|
availableDelta: number;
|
||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
remark?: string;
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
user: CoinLedgerUserDto;
|
user: CoinLedgerUserDto;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user