三方汇率
This commit is contained in:
parent
9405e2b7d6
commit
2125f81baf
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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 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;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
markupPercent: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user