三方汇率
This commit is contained in:
parent
9405e2b7d6
commit
2125f81baf
@ -2764,6 +2764,24 @@
|
||||
"/admin/payment/third-party-rates/sync": {
|
||||
"post": {
|
||||
"operationId": "syncThirdPartyPaymentRates",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["markupPercent"],
|
||||
"properties": {
|
||||
"markupPercent": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
TemporaryPaymentLinkDto,
|
||||
ThirdPartyPaymentChannelDto,
|
||||
ThirdPartyPaymentMethodDto,
|
||||
ThirdPartyPaymentRateSyncPayload,
|
||||
ThirdPartyPaymentRateSyncDto,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@ -121,9 +122,15 @@ export function updateThirdPartyPaymentRate(
|
||||
);
|
||||
}
|
||||
|
||||
export function syncThirdPartyPaymentRates(): Promise<ThirdPartyPaymentRateSyncDto> {
|
||||
export function syncThirdPartyPaymentRates(
|
||||
payload: ThirdPartyPaymentRateSyncPayload = { markupPercent: 0 },
|
||||
): Promise<ThirdPartyPaymentRateSyncDto> {
|
||||
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
|
||||
return apiRequest<ThirdPartyPaymentRateSyncDto>(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), {
|
||||
method: endpoint.method,
|
||||
});
|
||||
return apiRequest<ThirdPartyPaymentRateSyncDto, ThirdPartyPaymentRateSyncPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,10 +6,12 @@ import {
|
||||
updateThirdPartyPaymentRate,
|
||||
} from "@/features/payment/api";
|
||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||
import { thirdPartyPaymentRateSyncSchema } from "@/features/payment/schema";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyChannels = { items: [], total: 0 };
|
||||
const defaultSyncRateForm = { markupPercent: "0" };
|
||||
|
||||
export function useThirdPartyPaymentPage() {
|
||||
const abilities = usePaymentAbilities();
|
||||
@ -17,6 +19,8 @@ export function useThirdPartyPaymentPage() {
|
||||
const [expandedChannel, setExpandedChannel] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [rateDrafts, setRateDrafts] = useState({});
|
||||
const [syncRateDialogOpen, setSyncRateDialogOpen] = useState(false);
|
||||
const [syncRateForm, setSyncRateFormState] = useState(defaultSyncRateForm);
|
||||
|
||||
// 三方支付页是配置中心入口,列表必须包含已关闭方式,H5 只展示后端判定可用的方式。
|
||||
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
|
||||
@ -66,6 +70,26 @@ export function useThirdPartyPaymentPage() {
|
||||
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
|
||||
};
|
||||
|
||||
const setSyncRateMarkupPercent = (value) => {
|
||||
setSyncRateFormState({ markupPercent: value });
|
||||
};
|
||||
|
||||
const openSyncRateDialog = () => {
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
setSyncRateFormState(defaultSyncRateForm);
|
||||
setSyncRateDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeSyncRateDialog = () => {
|
||||
// 同步请求已经开始后不允许关闭弹窗,避免运营误判请求被取消;接口返回后统一由成功/失败提示收敛状态。
|
||||
if (loadingAction === "sync-rates") {
|
||||
return;
|
||||
}
|
||||
setSyncRateDialogOpen(false);
|
||||
};
|
||||
|
||||
const toggleMethod = async (method, active) => {
|
||||
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
@ -107,16 +131,23 @@ export function useThirdPartyPaymentPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const syncRates = async () => {
|
||||
// 汇率同步只触发服务端动作;汇率源、失败切换和写库都在 admin-server 完成,前端不接收可篡改的汇率输入。
|
||||
const submitSyncRates = async (event) => {
|
||||
event?.preventDefault();
|
||||
// 上浮比例只表达运营策略,不在前端计算最终汇率;服务端会拉实时源、套用比例并按 1 位小数向上取整,避免旧页面数据参与写库。
|
||||
if (!abilities.canUpdateThirdParty) {
|
||||
return;
|
||||
}
|
||||
const parsed = thirdPartyPaymentRateSyncSchema.safeParse(syncRateForm);
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0]?.message || "汇率同步参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
setLoadingAction("sync-rates");
|
||||
try {
|
||||
const summary = await syncThirdPartyPaymentRates();
|
||||
const summary = await syncThirdPartyPaymentRates({ markupPercent: parsed.data.markupPercent });
|
||||
const fresh = await result.reload();
|
||||
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
|
||||
setSyncRateDialogOpen(false);
|
||||
const suffix = summary?.sourceNames?.length ? `(${summary.sourceNames.join("、")})` : "";
|
||||
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
|
||||
} catch (err) {
|
||||
@ -130,15 +161,20 @@ export function useThirdPartyPaymentPage() {
|
||||
abilities,
|
||||
activeChannel,
|
||||
channels,
|
||||
closeSyncRateDialog,
|
||||
error: result.error,
|
||||
expandedChannel,
|
||||
loading: result.loading,
|
||||
loadingAction,
|
||||
openSyncRateDialog,
|
||||
rateDrafts,
|
||||
reload: result.reload,
|
||||
saveRate,
|
||||
setRateDraft,
|
||||
syncRates,
|
||||
setSyncRateMarkupPercent,
|
||||
submitSyncRates,
|
||||
syncRateDialogOpen,
|
||||
syncRateForm,
|
||||
toggleChannel,
|
||||
toggleMethod,
|
||||
};
|
||||
|
||||
@ -2,10 +2,12 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
|
||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
@ -30,7 +32,7 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
|
||||
<Button
|
||||
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
|
||||
startIcon={<SyncOutlined fontSize="small" />}
|
||||
onClick={page.syncRates}
|
||||
onClick={page.openSyncRateDialog}
|
||||
>
|
||||
支付汇率全球同步
|
||||
</Button>
|
||||
@ -159,6 +161,31 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
|
||||
</div>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "sync-rates"}
|
||||
open={page.syncRateDialogOpen}
|
||||
size="narrow"
|
||||
submitLabel="确定"
|
||||
title="同步支付汇率"
|
||||
onClose={page.closeSyncRateDialog}
|
||||
onSubmit={page.submitSyncRates}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
disabled={page.loadingAction === "sync-rates" || !page.abilities.canUpdateThirdParty}
|
||||
fullWidth
|
||||
label="汇率上浮比例"
|
||||
required
|
||||
slotProps={{
|
||||
htmlInput: { inputMode: "decimal", max: 100, min: 0, step: "0.1" },
|
||||
input: {
|
||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||
},
|
||||
}}
|
||||
value={page.syncRateForm.markupPercent}
|
||||
onChange={(event) => page.setSyncRateMarkupPercent(event.target.value)}
|
||||
/>
|
||||
</AdminFormDialog>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
76
src/features/payment/pages/ThirdPartyPaymentPage.test.jsx
Normal file
76
src/features/payment/pages/ThirdPartyPaymentPage.test.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { ThirdPartyPaymentPage } from "@/features/payment/pages/ThirdPartyPaymentPage.jsx";
|
||||
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||
|
||||
vi.mock("@/features/payment/hooks/useThirdPartyPaymentPage.js", () => ({
|
||||
useThirdPartyPaymentPage: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("third-party payment sync button opens rate markup dialog", () => {
|
||||
const page = pageFixture({ openSyncRateDialog: vi.fn() });
|
||||
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
|
||||
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "支付汇率全球同步" }));
|
||||
|
||||
expect(page.openSyncRateDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("third-party payment rate dialog submits markup percent", () => {
|
||||
const page = pageFixture({
|
||||
setSyncRateMarkupPercent: vi.fn(),
|
||||
submitSyncRates: vi.fn((event) => event.preventDefault()),
|
||||
syncRateDialogOpen: true,
|
||||
});
|
||||
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
|
||||
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /汇率上浮比例/ }), { target: { value: "3" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "确定" }));
|
||||
|
||||
expect(page.setSyncRateMarkupPercent).toHaveBeenCalledWith("3");
|
||||
expect(page.submitSyncRates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ThirdPartyPaymentPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function pageFixture(overrides = {}) {
|
||||
return {
|
||||
abilities: {
|
||||
canUpdateThirdParty: true,
|
||||
},
|
||||
activeChannel: null,
|
||||
channels: [],
|
||||
closeSyncRateDialog: vi.fn(),
|
||||
error: null,
|
||||
expandedChannel: "",
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
openSyncRateDialog: vi.fn(),
|
||||
rateDrafts: {},
|
||||
reload: vi.fn(),
|
||||
saveRate: vi.fn(),
|
||||
setRateDraft: vi.fn(),
|
||||
setSyncRateMarkupPercent: vi.fn(),
|
||||
submitSyncRates: vi.fn((event) => event.preventDefault()),
|
||||
syncRateDialogOpen: false,
|
||||
syncRateForm: { markupPercent: "0" },
|
||||
toggleChannel: vi.fn(),
|
||||
toggleMethod: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -18,3 +18,15 @@ export const rechargeProductSchema = z.object({
|
||||
});
|
||||
|
||||
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>;
|
||||
|
||||
export const thirdPartyPaymentRateSyncSchema = z.object({
|
||||
markupPercent: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写汇率上浮比例")
|
||||
.regex(/^(?:\d+|\d+\.\d{1,4})$/, "汇率上浮比例最多支持 4 位小数")
|
||||
.transform(Number)
|
||||
.pipe(z.number().min(0, "汇率上浮比例不能小于 0").max(100, "汇率上浮比例不能大于 100")),
|
||||
});
|
||||
|
||||
export type ThirdPartyPaymentRateSyncForm = z.infer<typeof thirdPartyPaymentRateSyncSchema>;
|
||||
|
||||
8
src/shared/api/generated/schema.d.ts
vendored
8
src/shared/api/generated/schema.d.ts
vendored
@ -6114,7 +6114,13 @@ export interface operations {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
markupPercent: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
|
||||
@ -473,6 +473,10 @@ export interface ThirdPartyPaymentChannelDto {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ThirdPartyPaymentRateSyncPayload {
|
||||
markupPercent: number;
|
||||
}
|
||||
|
||||
export interface TemporaryPaymentLinkDto {
|
||||
appCode?: string;
|
||||
audienceType?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user