三方汇率

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": {
"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"

View File

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

View File

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

View File

@ -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>
</>
);

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 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;
cookie?: never;
};
requestBody?: never;
requestBody: {
content: {
"application/json": {
markupPercent: number;
};
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};

View File

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