diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index f497db6..16e2113 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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" diff --git a/src/features/payment/api.ts b/src/features/payment/api.ts index f159750..d51ef44 100644 --- a/src/features/payment/api.ts +++ b/src/features/payment/api.ts @@ -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 { +export function syncThirdPartyPaymentRates( + payload: ThirdPartyPaymentRateSyncPayload = { markupPercent: 0 }, +): Promise { const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates; - return apiRequest(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), { - method: endpoint.method, - }); + return apiRequest( + apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), + { + body: payload, + method: endpoint.method, + }, + ); } diff --git a/src/features/payment/hooks/useThirdPartyPaymentPage.js b/src/features/payment/hooks/useThirdPartyPaymentPage.js index fe07b2c..1d97e65 100644 --- a/src/features/payment/hooks/useThirdPartyPaymentPage.js +++ b/src/features/payment/hooks/useThirdPartyPaymentPage.js @@ -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, }; diff --git a/src/features/payment/pages/ThirdPartyPaymentPage.jsx b/src/features/payment/pages/ThirdPartyPaymentPage.jsx index f3e0b46..f486221 100644 --- a/src/features/payment/pages/ThirdPartyPaymentPage.jsx +++ b/src/features/payment/pages/ThirdPartyPaymentPage.jsx @@ -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 = @@ -159,6 +161,31 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton = + + %, + }, + }} + value={page.syncRateForm.markupPercent} + onChange={(event) => page.setSyncRateMarkupPercent(event.target.value)} + /> + ); diff --git a/src/features/payment/pages/ThirdPartyPaymentPage.test.jsx b/src/features/payment/pages/ThirdPartyPaymentPage.test.jsx new file mode 100644 index 0000000..d35eb16 --- /dev/null +++ b/src/features/payment/pages/ThirdPartyPaymentPage.test.jsx @@ -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( + + + , + ); +} + +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, + }; +} diff --git a/src/features/payment/schema.ts b/src/features/payment/schema.ts index 758c57f..8b5f390 100644 --- a/src/features/payment/schema.ts +++ b/src/features/payment/schema.ts @@ -18,3 +18,15 @@ export const rechargeProductSchema = z.object({ }); export type RechargeProductForm = z.infer; + +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; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index bd06faa..73ca482 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -6114,7 +6114,13 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": { + markupPercent: number; + }; + }; + }; responses: { 200: components["responses"]["EmptyResponse"]; }; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index d66e08f..ff280b6 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -473,6 +473,10 @@ export interface ThirdPartyPaymentChannelDto { status?: string; } +export interface ThirdPartyPaymentRateSyncPayload { + markupPercent: number; +} + export interface TemporaryPaymentLinkDto { appCode?: string; audienceType?: string;