三方汇率

This commit is contained in:
zhx 2026-06-25 17:00:05 +08:00
parent 9405e2b7d6
commit 2125f81baf
8 changed files with 196 additions and 10 deletions

View File

@ -2764,6 +2764,24 @@
"/admin/payment/third-party-rates/sync": { "/admin/payment/third-party-rates/sync": {
"post": { "post": {
"operationId": "syncThirdPartyPaymentRates", "operationId": "syncThirdPartyPaymentRates",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["markupPercent"],
"properties": {
"markupPercent": {
"type": "number",
"minimum": 0,
"maximum": 100
}
}
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/EmptyResponse"

View File

@ -11,6 +11,7 @@ import type {
TemporaryPaymentLinkDto, TemporaryPaymentLinkDto,
ThirdPartyPaymentChannelDto, ThirdPartyPaymentChannelDto,
ThirdPartyPaymentMethodDto, ThirdPartyPaymentMethodDto,
ThirdPartyPaymentRateSyncPayload,
ThirdPartyPaymentRateSyncDto, ThirdPartyPaymentRateSyncDto,
} from "@/shared/api/types"; } 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; const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
return apiRequest<ThirdPartyPaymentRateSyncDto>(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), { return apiRequest<ThirdPartyPaymentRateSyncDto, ThirdPartyPaymentRateSyncPayload>(
method: endpoint.method, apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates),
}); {
body: payload,
method: endpoint.method,
},
);
} }

View File

@ -6,10 +6,12 @@ import {
updateThirdPartyPaymentRate, updateThirdPartyPaymentRate,
} from "@/features/payment/api"; } from "@/features/payment/api";
import { usePaymentAbilities } from "@/features/payment/permissions.js"; import { usePaymentAbilities } from "@/features/payment/permissions.js";
import { thirdPartyPaymentRateSyncSchema } from "@/features/payment/schema";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
const emptyChannels = { items: [], total: 0 }; const emptyChannels = { items: [], total: 0 };
const defaultSyncRateForm = { markupPercent: "0" };
export function useThirdPartyPaymentPage() { export function useThirdPartyPaymentPage() {
const abilities = usePaymentAbilities(); const abilities = usePaymentAbilities();
@ -17,6 +19,8 @@ export function useThirdPartyPaymentPage() {
const [expandedChannel, setExpandedChannel] = useState(""); const [expandedChannel, setExpandedChannel] = useState("");
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const [rateDrafts, setRateDrafts] = useState({}); const [rateDrafts, setRateDrafts] = useState({});
const [syncRateDialogOpen, setSyncRateDialogOpen] = useState(false);
const [syncRateForm, setSyncRateFormState] = useState(defaultSyncRateForm);
// 三方支付页是配置中心入口列表必须包含已关闭方式H5 只展示后端判定可用的方式。 // 三方支付页是配置中心入口列表必须包含已关闭方式H5 只展示后端判定可用的方式。
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。 // 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
@ -66,6 +70,26 @@ export function useThirdPartyPaymentPage() {
setRateDrafts((current) => ({ ...current, [String(methodId)]: value })); 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) => { const toggleMethod = async (method, active) => {
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。 // 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
if (!abilities.canUpdateThirdParty) { if (!abilities.canUpdateThirdParty) {
@ -107,16 +131,23 @@ export function useThirdPartyPaymentPage() {
} }
}; };
const syncRates = async () => { const submitSyncRates = async (event) => {
// 汇率同步只触发服务端动作;汇率源、失败切换和写库都在 admin-server 完成,前端不接收可篡改的汇率输入。 event?.preventDefault();
// 上浮比例只表达运营策略,不在前端计算最终汇率;服务端会拉实时源、套用比例并按 1 位小数向上取整,避免旧页面数据参与写库。
if (!abilities.canUpdateThirdParty) { if (!abilities.canUpdateThirdParty) {
return; return;
} }
const parsed = thirdPartyPaymentRateSyncSchema.safeParse(syncRateForm);
if (!parsed.success) {
showToast(parsed.error.issues[0]?.message || "汇率同步参数不正确", "error");
return;
}
setLoadingAction("sync-rates"); setLoadingAction("sync-rates");
try { try {
const summary = await syncThirdPartyPaymentRates(); const summary = await syncThirdPartyPaymentRates({ markupPercent: parsed.data.markupPercent });
const fresh = await result.reload(); const fresh = await result.reload();
setRateDrafts(rateDraftsFromChannels(fresh?.items || [])); setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
setSyncRateDialogOpen(false);
const suffix = summary?.sourceNames?.length ? `${summary.sourceNames.join("、")}` : ""; const suffix = summary?.sourceNames?.length ? `${summary.sourceNames.join("、")}` : "";
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success"); showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
} catch (err) { } catch (err) {
@ -130,15 +161,20 @@ export function useThirdPartyPaymentPage() {
abilities, abilities,
activeChannel, activeChannel,
channels, channels,
closeSyncRateDialog,
error: result.error, error: result.error,
expandedChannel, expandedChannel,
loading: result.loading, loading: result.loading,
loadingAction, loadingAction,
openSyncRateDialog,
rateDrafts, rateDrafts,
reload: result.reload, reload: result.reload,
saveRate, saveRate,
setRateDraft, setRateDraft,
syncRates, setSyncRateMarkupPercent,
submitSyncRates,
syncRateDialogOpen,
syncRateForm,
toggleChannel, toggleChannel,
toggleMethod, toggleMethod,
}; };

View File

@ -2,10 +2,12 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined"; import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SyncOutlined from "@mui/icons-material/SyncOutlined"; import SyncOutlined from "@mui/icons-material/SyncOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js"; 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 { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
@ -30,7 +32,7 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
<Button <Button
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"} disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
startIcon={<SyncOutlined fontSize="small" />} startIcon={<SyncOutlined fontSize="small" />}
onClick={page.syncRates} onClick={page.openSyncRateDialog}
> >
支付汇率全球同步 支付汇率全球同步
</Button> </Button>
@ -159,6 +161,31 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
</div> </div>
</AdminListBody> </AdminListBody>
</DataState> </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>
</> </>
); );

View 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,
};
}

View File

@ -18,3 +18,15 @@ export const rechargeProductSchema = z.object({
}); });
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>; 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>;

View File

@ -6114,7 +6114,13 @@ export interface operations {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody: {
content: {
"application/json": {
markupPercent: number;
};
};
};
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };

View File

@ -473,6 +473,10 @@ export interface ThirdPartyPaymentChannelDto {
status?: string; status?: string;
} }
export interface ThirdPartyPaymentRateSyncPayload {
markupPercent: number;
}
export interface TemporaryPaymentLinkDto { export interface TemporaryPaymentLinkDto {
appCode?: string; appCode?: string;
audienceType?: string; audienceType?: string;