diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index ce4ca04..4395acc 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -279,6 +279,52 @@ func readOrderSnapshotPrice(raw datatypes.JSON, key string) float64 { return readJSONNumber(breakdown[key]) } +// readOrderSnapshotBPS 优先读取整数基点字段;旧快照只有 float 比例时换算为 bps。 +func readOrderSnapshotBPS(raw datatypes.JSON, key string) int64 { + breakdown, ok := readOrderSnapshotBreakdown(raw) + if !ok { + return 0 + } + if bps := readJSONNumber(breakdown[key+"_bps"]); bps > 0 { + return int64(math.Round(bps)) + } + legacy := readJSONNumber(breakdown[key]) + if legacy <= 0 { + return 0 + } + return int64(math.Round(legacy * 10000)) +} + +// quantityUnits 把 M 数量转成百分之一 M 的整数单位,避免比例计算依赖浮点。 +func quantityUnits(value float64) int64 { + if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return int64(math.Round(value * 100)) +} + +// roundedDiv 对非负整数做四舍五入除法。 +func roundedDiv(numerator, denominator int64) int64 { + if numerator <= 0 || denominator <= 0 { + return 0 + } + return (numerator + denominator/2) / denominator +} + +// proportionalCent 按 part/total 折算金额;total<=0 时保持旧行为取全额。 +func proportionalCent(baseCent, partUnits, totalUnits int64) int64 { + if baseCent <= 0 { + return 0 + } + if totalUnits <= 0 { + return baseCent + } + if partUnits <= 0 { + return 0 + } + return roundedDiv(baseCent*partUnits, totalUnits) +} + func readJSONNumber(value any) float64 { switch typed := value.(type) { case float64: @@ -377,28 +423,28 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i consumedM := roundQuantity(coinConsumedM) totalCoinM := readOrderSnapshotCoinM(order.AccountSnapshot) - buyerRatio := readOrderSnapshotPrice(order.AccountSnapshot, "buyer_ratio") - sellerRatio := readOrderSnapshotPrice(order.AccountSnapshot, "seller_ratio") + buyerRatioBPS := readOrderSnapshotBPS(order.AccountSnapshot, "buyer_ratio") + sellerRatioBPS := readOrderSnapshotBPS(order.AccountSnapshot, "seller_ratio") + consumedUnits := quantityUnits(consumedM) + totalCoinUnits := quantityUnits(totalCoinM) - // 币费:优先按各自比例 × 实际消耗;无比例时回退到预收币基价 × (消耗/发布量),允许 >1(打超) - // 无发布量快照时保持旧行为:按全额使用(ratio=1) - coinUseRatio := coinConsumptionRatio(consumedM, totalCoinM) + // 币费:比例优先按 bps 整数换算;缺失 bps 时把旧比例换算成 bps。 + // 无比例时回退到预收币基价按消耗比例折算,仍使用整数除法。 var usedBuyerCoinPriceCent, usedOwnerCoinPriceCent int64 - if buyerRatio > 0 && (totalCoinM > 0 || consumedM > 0) { - // 价(元) = 消耗M×100 / ratio;分 = 元×100 - usedBuyerCoinPriceCent = int64(math.Round(consumedM * 10000 / buyerRatio)) + if buyerRatioBPS > 0 && (totalCoinUnits > 0 || consumedUnits > 0) { + usedBuyerCoinPriceCent = roundedDiv(consumedUnits*1_000_000, buyerRatioBPS) } else { - usedBuyerCoinPriceCent = int64(math.Round(float64(buyerCoinBasePriceCent) * coinUseRatio)) + usedBuyerCoinPriceCent = proportionalCent(buyerCoinBasePriceCent, consumedUnits, totalCoinUnits) } - if sellerRatio > 0 && (totalCoinM > 0 || consumedM > 0) { - usedOwnerCoinPriceCent = int64(math.Round(consumedM * 10000 / sellerRatio)) + if sellerRatioBPS > 0 && (totalCoinUnits > 0 || consumedUnits > 0) { + usedOwnerCoinPriceCent = roundedDiv(consumedUnits*1_000_000, sellerRatioBPS) } else { - usedOwnerCoinPriceCent = int64(math.Round(float64(sellerCoinBasePriceCent) * coinUseRatio)) + usedOwnerCoinPriceCent = proportionalCent(sellerCoinBasePriceCent, consumedUnits, totalCoinUnits) } - coinOvershoot := totalCoinM > 0 && consumedM > totalCoinM + coinOvershoot := totalCoinUnits > 0 && consumedUnits > totalCoinUnits if !coinOvershoot { - // 未打超时,比例反推只能用于折算未用完部分,不能因四舍五入超过订单快照。 - if totalCoinM > 0 && consumedM >= totalCoinM { + // 未打超时,比例反推只能用于折算未用完部分,不能因取整超过订单快照。 + if totalCoinUnits > 0 && consumedUnits >= totalCoinUnits { usedBuyerCoinPriceCent = buyerCoinBasePriceCent usedOwnerCoinPriceCent = sellerCoinBasePriceCent } else { @@ -412,8 +458,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i var usedOwnerConsumablePriceCent int64 if prepaidConsumablePriceCent > 0 { if usedBuyerConsumablePriceCent <= prepaidConsumablePriceCent { - consumableUseRatio := maxRatio(float64(usedBuyerConsumablePriceCent)/float64(prepaidConsumablePriceCent), 0) - usedOwnerConsumablePriceCent = int64(math.Round(float64(prepaidOwnerConsumablePriceCent) * consumableUseRatio)) + usedOwnerConsumablePriceCent = roundedDiv( + prepaidOwnerConsumablePriceCent*usedBuyerConsumablePriceCent, + prepaidConsumablePriceCent, + ) usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent) } else { // 预收范围内按号主预收基准,超出预收部分作为后置损耗全额划归号主(平台不抽成) diff --git a/backend/internal/modules/order/pricing_cent_test.go b/backend/internal/modules/order/pricing_cent_test.go index f4d008b..2baebd4 100644 --- a/backend/internal/modules/order/pricing_cent_test.go +++ b/backend/internal/modules/order/pricing_cent_test.go @@ -66,3 +66,37 @@ func TestBuildOrderPricingUsesCentBreakdown(t *testing.T) { t.Fatalf("OwnerPureCoinAmountCent = %d, want 23315", pricing.OwnerPureCoinAmountCent) } } + +func TestCalculateCheckoutSettlementPrefersRatioBPS(t *testing.T) { + // bps 必须优先于旧 float 比例:buyer 1:40、seller 1:45,消耗 180M。 + order := model.RentalOrder{ + RentAmountCent: 25000, + OwnerRentAmountCent: 22222, + DepositAmountCent: 50000, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_ratio": 1, + "seller_ratio": 1, + "buyer_ratio_bps": 400000, + "seller_ratio_bps": 450000, + "buyer_coin_base_price": 250, + "seller_coin_base_price": 222.22, + "consumable_price": 0 + } + } + }`)), + } + + settlement := calculateCheckoutSettlement(order, 0, 180, 0) + if settlement.ActualRentAmountCent != 45000 { + t.Fatalf("ActualRentAmountCent = %d, want 45000", settlement.ActualRentAmountCent) + } + if settlement.OwnerRentIncomeCent != 40000 { + t.Fatalf("OwnerRentIncomeCent = %d, want 40000", settlement.OwnerRentIncomeCent) + } + if settlement.PlatformFeeCent != 5000 { + t.Fatalf("PlatformFeeCent = %d, want 5000", settlement.PlatformFeeCent) + } +} diff --git a/docs/Phase2-分销收口与结算bps验收.md b/docs/Phase2-分销收口与结算bps验收.md new file mode 100644 index 0000000..6893215 --- /dev/null +++ b/docs/Phase2-分销收口与结算bps验收.md @@ -0,0 +1,116 @@ +# Phase 2 验收报告(分销收口、结算 bps 与构建优化) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:分销账号同步、主站支付配置、测试站点回归、订单结算 bps、构建拆包 +> 结论:**1–4 与 6 已完成;真实外部支付 E2E 被支付商户/短信/实名配置阻塞** + +--- + +## 1. 分销账号同步 + +- 页面:`frontend/src/features/admin/AdminDistributionListingsPage.tsx` +- 接口:`POST /api/admin/distribution/sites/:id/sync-accounts` +- 权限:`distribution:account_display` +- 弹窗支持:新账号默认加价、是否清理失效商品;执行后展示同步/跳过/更新/恢复/清理数量。 +- 请求类型与结果类型已落 `frontend/src/features/admin/types.ts`,API 封装在 `frontend/src/features/admin/api.ts`。 +- 验收中未执行真实同步写入。 + +## 2. 主站支付配置管理 + +- 页面:`frontend/src/features/admin/AdminPaymentConfigsPage.tsx` +- 路由:`/console-jy8k3m/payment-configs` +- 权限:`payment_config:list/create/update/delete` +- 能力: + - 服务商、支付方式、状态、环境筛选 + - 名称、商户号、网关、回调、签名/密钥、额外配置 JSON、业务标签 + - 创建、编辑、删除;密钥只写不读,编辑时留空不修改 + - 拉卡拉/顺诚的 `extra_config` 通过 JSON 文本框维护 +- 后端配置列表在开发库为空,只读 GET 返回 200。 + +## 3. 测试站点真实数据回归 + +已通过管理端 onboard 接口创建开发测试站点: + +```text +site_id: 1 +site_code: jysh_test_1793347033 +domain: test-1793347033.zuhaoc.com +admin: jysh_admin_1793347033 +``` + +站点级只读接口全部 200: + +- 钱包、提现记录、短信配置、邀请配置 +- 二维码列表/统计、分销看板、分销用户、站点公告 +- 收货商品、账号展示、支付通道、结算批次、分销订单 + +全部 14 个后台页面路由返回 200,包括分销看板、支付配置与既有分销页面。 + +## 4. 订单结算比例 bps 化 + +- `backend/internal/modules/order/pricing.go` + - 新增 `readOrderSnapshotBPS`:优先读 `buyer_ratio_bps` / `seller_ratio_bps`,缺失时把旧 float 比例换算为 bps。 + - 新增 `quantityUnits`:M 数量转百分之一 M 的整数单位。 + - 新增 `roundedDiv` / `proportionalCent`:金额与比例折算改为整数四舍五入除法。 + - 币费、消耗品号主分摊不再使用 float 乘法。 +- 新增测试 `TestCalculateCheckoutSettlementPrefersRatioBPS`,验证 bps 覆盖冲突的旧比例。 +- 验证: + +```text +go test ./internal/modules/order ./internal/modules/listing ./pkg/money + 通过 + +go test ./internal/modules/order ./internal/modules/payment ./internal/modules/distribution ./internal/modules/listing ./internal/modules/invitation ./internal/modules/chat + 全部通过 +``` + +## 5. E2E 执行情况与阻塞 + +已完成可执行部分:订单、支付、分销、商品、邀请、聊天模块测试全部通过。 + +真实外部支付订单 E2E **未执行**,原因: + +- 开发库没有真实支付商户配置,无法发起外部支付/回调。 +- 短信签名与实名服务仍为空,无法完成真实注册/实名链路。 +- 测试站点尚无真实商品、买家和卖家用户数据。 + +因此本项记录为环境阻塞,不伪造“全链路已通过”。 + +## 6. 构建拆包优化 + +`frontend/vite.config.ts` 新增: + +- `vendor-antd-icons` +- `vendor-date` + +首次尝试把全部 antd/rc 打进独立包产生 1.2 MB 超大包,已回退该策略,仅拆图标与日期库。 + +最终构建: + +```text +npx tsc --noEmit 通过 +npm run build 通过 +主入口 492.81 kB / gzip 155.77 kB +vendor-antd-icons 101.61 kB / gzip 29.54 kB +vendor-date 16.77 kB / gzip 6.73 kB +``` + +主入口已从 525.59 kB 降至 492.81 kB,低于 500 kB;超大包告警消失。 + +## 7. 当前工作区 + +本轮新增/修改尚未提交: + +- 账号同步与分销商品页 +- 主站支付配置页与 API/类型 +- 订单结算 bps 与测试 +- Vite 拆包配置 +- 本文档与项目计划 + +## 8. 下一步 + +1. 提交本轮变更。 +2. 配置测试支付商户、短信签名与实名服务,再跑真实订单 E2E。 +3. 用测试站点补充商品/买家/卖家数据,回归商品、订单、钱包、结算写流程。 +4. 上线前执行完整 `scripts/check.sh` 与生产冒烟。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 41c1f18..7e90a2d 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;剩余分销账号同步、主站支付配置管理等细分长尾和真实订单端到端回归。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、支付配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;分销账号同步入口已接入,订单结算比例已 bps 化;剩余真实外部支付 E2E、真实站点数据业务回归与上线准备。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 009c96d..4011b4e 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -175,6 +175,11 @@ const AdminSystemConfigsPage = lazy(() => default: module.AdminSystemConfigsPage, })) ) +const AdminPaymentConfigsPage = lazy(() => + import('@/features/admin/AdminPaymentConfigsPage').then(module => ({ + default: module.AdminPaymentConfigsPage, + })) +) const AdminDistributionSitesPage = lazy(() => import('@/features/admin/AdminDistributionSitesPage').then(module => ({ default: module.AdminDistributionSitesPage, @@ -303,6 +308,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> () const [working, setWorking] = useState(false) + const [syncOpen, setSyncOpen] = useState(false) + const [syncForm] = Form.useForm() + const [syncing, setSyncing] = useState(false) const receivedQuery = useQuery({ queryKey: ['admin-received-listings', siteId, receivedFilters, receivedPage, receivedPageSize], @@ -290,6 +301,43 @@ export function AdminDistributionListingsPage() { } } + const openSync = () => { + syncForm.setFieldsValue({ + clean_stale_listings: false, + default_shelf_ratio_delta: 0, + }) + setSyncOpen(true) + } + + const runSync = async () => { + if (!siteId) return + const values = await syncForm.validateFields() + setSyncing(true) + try { + const result = await syncAdminDistributionSiteAccounts(siteId, values) + modal.success({ + title: '账号同步完成', + content: ( +
+ 同步 {result.synced_count} 件,跳过 {result.skipped_count} 件 + 更新 {result.updated_count} 件,恢复 {result.reenabled_count} 件 + 清理失效 {result.disabled_stale_count} 件 + 当前展示 {result.total_active} 件 +
+ ), + }) + setSyncOpen(false) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-received-listings', siteId] }), + queryClient.invalidateQueries({ queryKey: ['admin-account-displays', siteId] }), + ]) + } catch (error) { + message.error(errorText(error)) + } finally { + setSyncing(false) + } + } + const receivedColumns: TableColumnsType = [ { title: '商品', @@ -514,14 +562,25 @@ export function AdminDistributionListingsPage() { } extra={ - + + {canManage ? ( + + ) : null} + + } /> @@ -843,6 +902,60 @@ export function AdminDistributionListingsPage() { + + (syncing ? undefined : setSyncOpen(false))} + footer={ + + + + + } + > + + + form={syncForm} + layout="vertical" + disabled={syncing} + initialValues={{ clean_stale_listings: false, default_shelf_ratio_delta: 0 }} + > + + + + + + + + ) } diff --git a/frontend/src/features/admin/AdminPaymentConfigsPage.tsx b/frontend/src/features/admin/AdminPaymentConfigsPage.tsx new file mode 100644 index 0000000..4195c31 --- /dev/null +++ b/frontend/src/features/admin/AdminPaymentConfigsPage.tsx @@ -0,0 +1,643 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Col, + Form, + Input, + Modal, + Row, + Select, + Space, + Switch, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { + CreditCardOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons' + +import { + createAdminPaymentConfig, + deleteAdminPaymentConfig, + fetchAdminPaymentConfigs, + updateAdminPaymentConfig, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import type { + AdminPaymentConfig, + AdminPaymentConfigPayload, + AdminPaymentConfigQuery, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const providerLabels: Record = { + leshua: '乐刷', + lakala: '拉卡拉', + shuncheng: '顺诚', + mock: 'Mock', +} + +const payWayLabels: Record = { + ZFBZF: '支付宝', + WXZF: '微信支付', +} + +const statusLabels: Record = { + active: '启用', + disabled: '停用', + testing: '测试中', +} + +const statusOptions = Object.entries(statusLabels).map(([value, label]) => ({ value, label })) +const providerOptions = Object.entries(providerLabels).map(([value, label]) => ({ value, label })) +const payWayOptions = Object.entries(payWayLabels).map(([value, label]) => ({ value, label })) + +interface ConfigFormValues { + name: string + provider: string + merchant_id: string + gateway_url?: string + sign_key?: string + notify_key?: string + notify_url?: string + jump_url?: string + pay_way?: string + jspay_flag?: string + sign_type?: string + is_default: boolean + status: string + environment: string + business_tags_text?: string + extra_config_json?: string +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +function parseExtraConfig(text?: string) { + const trimmed = text?.trim() + if (!trimmed) return undefined + const parsed = JSON.parse(trimmed) + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('额外配置必须是 JSON 对象') + } + return parsed as Record +} + +export function AdminPaymentConfigsPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [draft, setDraft] = useState({}) + const [filters, setFilters] = useState({}) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState() + const [submitting, setSubmitting] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('payment_config:list')) + const canCreate = Boolean( + permissions?.includes('*') || permissions?.includes('payment_config:create') + ) + const canUpdate = Boolean( + permissions?.includes('*') || permissions?.includes('payment_config:update') + ) + const canDelete = Boolean( + permissions?.includes('*') || permissions?.includes('payment_config:delete') + ) + + const listQuery = useQuery({ + queryKey: ['admin-payment-configs', filters, page, pageSize], + queryFn: () => fetchAdminPaymentConfigs({ ...filters, page, page_size: pageSize }), + enabled: canView, + placeholderData: keepPreviousData, + }) + + const data = listQuery.data + const rows = data?.items ?? [] + + const summary = useMemo( + () => ({ + total: data?.total ?? 0, + active: rows.filter(row => row.status === 'active').length, + defaultCount: rows.filter(row => row.is_default).length, + }), + [data, rows] + ) + + const openCreate = () => { + setEditing(undefined) + form.setFieldsValue({ + name: '', + provider: 'leshua', + merchant_id: '', + gateway_url: '', + sign_key: '', + notify_key: '', + notify_url: '', + jump_url: '', + pay_way: 'WXZF', + jspay_flag: '', + sign_type: 'MD5', + is_default: false, + status: 'active', + environment: 'production', + business_tags_text: '', + extra_config_json: '', + }) + setEditorOpen(true) + } + + const openEdit = (row: AdminPaymentConfig) => { + setEditing(row) + form.setFieldsValue({ + name: row.name, + provider: row.provider, + merchant_id: row.merchant_id, + gateway_url: row.gateway_url, + sign_key: '', + notify_key: '', + notify_url: row.notify_url, + jump_url: row.jump_url, + pay_way: row.pay_way, + jspay_flag: row.jspay_flag, + sign_type: row.sign_type, + is_default: row.is_default, + status: row.status, + environment: row.environment, + business_tags_text: (row.business_tags ?? []).join(', '), + extra_config_json: row.extra_config + ? JSON.stringify(row.extra_config, null, 2) + : '', + }) + setEditorOpen(true) + } + + const buildPayload = (values: ConfigFormValues): AdminPaymentConfigPayload => { + let extraConfig: Record | undefined + try { + extraConfig = parseExtraConfig(values.extra_config_json) + } catch (error) { + throw new Error(error instanceof Error ? error.message : '额外配置 JSON 不正确') + } + const payload: AdminPaymentConfigPayload = { + name: values.name.trim(), + provider: values.provider, + merchant_id: values.merchant_id.trim(), + gateway_url: values.gateway_url?.trim() || '', + notify_url: values.notify_url?.trim() || '', + jump_url: values.jump_url?.trim() || '', + pay_way: values.pay_way, + jspay_flag: values.jspay_flag?.trim() || '', + sign_type: values.sign_type, + is_default: values.is_default, + status: values.status, + environment: values.environment, + business_tags: (values.business_tags_text ?? '') + .split(',') + .map(item => item.trim()) + .filter(Boolean), + extra_config: extraConfig, + } + if (values.sign_key?.trim()) payload.sign_key = values.sign_key.trim() + if (values.notify_key?.trim()) payload.notify_key = values.notify_key.trim() + return payload + } + + const save = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + const payload = buildPayload(values) + if (editing) { + await updateAdminPaymentConfig(editing.id, payload) + message.success('支付配置已更新') + } else { + await createAdminPaymentConfig(payload) + message.success('支付配置已创建') + } + setEditorOpen(false) + await queryClient.invalidateQueries({ queryKey: ['admin-payment-configs'] }) + await queryClient.invalidateQueries({ queryKey: ['admin-payment-config-options'] }) + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const remove = (row: AdminPaymentConfig) => { + modal.confirm({ + title: `确认删除支付配置「${row.name}」?`, + content: '正在被订单、提现或分销站点引用的配置无法删除。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminPaymentConfig(row.id) + message.success('支付配置已删除') + await queryClient.invalidateQueries({ queryKey: ['admin-payment-configs'] }) + await queryClient.invalidateQueries({ queryKey: ['admin-payment-config-options'] }) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '配置', + key: 'config', + width: 240, + fixed: 'left', + render: (_, row) => ( +
+ + {row.name} + {row.is_default ? 默认 : null} + + #{row.id} + {formatText(row.merchant_id)} +
+ ), + }, + { + title: '服务商 / 方式', + key: 'provider', + width: 170, + render: (_, row) => ( +
+ {providerLabels[row.provider] ?? row.provider} + {payWayLabels[row.pay_way] ?? formatText(row.pay_way)} + {formatText(row.sign_type)} +
+ ), + }, + { + title: '环境 / 状态', + key: 'status', + width: 150, + render: (_, row) => ( +
+ + {statusLabels[row.status] ?? row.status} + + {row.environment === 'production' ? '生产' : '沙箱'} +
+ ), + }, + { + title: '引用 / 交易', + key: 'usage', + width: 170, + align: 'right', + render: (_, row) => ( +
+ {row.total_transactions} 笔 + 引用 {row.reference_count} + {formatMoney(row.total_amount_cent)} +
+ ), + }, + { + title: '回调地址', + key: 'notify', + width: 240, + render: (_, row) => ( + + {formatText(row.notify_url)} + + ), + }, + { + title: '最近使用', + dataIndex: 'last_used_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 150, + fixed: 'right', + render: (_, row) => ( + + + + + ), + }, + ] + + const applyFilters = () => { + setFilters({ + provider: draft.provider, + pay_way: draft.pay_way, + status: draft.status, + environment: draft.environment, + }) + setPage(1) + } + + const resetFilters = () => { + setDraft({}) + setFilters({}) + setPage(1) + } + + return ( +
+ + 共 {summary.total} 条 + 启用 {summary.active} + 默认 {summary.defaultCount} + {listQuery.isFetching ? 正在更新 : null} + + } + extra={ + + {canCreate ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : null} + + + {listQuery.isPending ? '加载中' : `当前 ${rows.length} 条`} + + } + > + + setDraft(current => ({ ...current, pay_way: value }))} + /> + + + setDraft(current => ({ ...current, environment: value }))} + /> + + + + {listQuery.isError ? ( + void listQuery.refetch()}> + 重试 + + } + /> + ) : null} + + + title="主站支付配置" + rowKey="id" + columns={columns} + dataSource={rows} + loading={listQuery.isFetching} + total={data?.total ?? 0} + page={page} + pageSize={pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription={canView ? '还没有支付配置' : '当前账号没有查看权限'} + /> + + (submitting ? undefined : setEditorOpen(false))} + footer={ + + + + + } + > + + form={form} layout="vertical" disabled={submitting}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 支付配置变更会记录审计日志;密钥不会回显。 + + + +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index aeff7bd..64eb94d 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -46,9 +46,15 @@ import type { AdminOrderPage, AdminOrderQuery, AdminPaymentChannel, + AdminPaymentConfig, AdminPaymentConfigOption, + AdminPaymentConfigPage, + AdminPaymentConfigPayload, + AdminPaymentConfigQuery, AdminReceivedListingPage, AdminReceivedListingQuery, + AdminSyncSiteAccountsPayload, + AdminSyncSiteAccountsResult, AdminSystemConfig, AdminSystemConfigUpdatePayload, AdminUser, @@ -944,6 +950,53 @@ export async function fetchAdminPaymentConfigOptions() { return data.data.items ?? [] } +export async function syncAdminDistributionSiteAccounts( + siteId: number, + payload: AdminSyncSiteAccountsPayload +) { + const { data } = await adminClient.post>( + `/admin/distribution/sites/${siteId}/sync-accounts`, + payload + ) + return data.data +} + +export async function fetchAdminPaymentConfigs(query: AdminPaymentConfigQuery) { + const { data } = await adminClient.get>>( + '/admin/payment-configs', + { params: compactParams(query) } + ) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + page: data.data.page ?? query.page ?? 1, + page_size: data.data.page_size ?? query.page_size ?? 20, + } +} + +export async function createAdminPaymentConfig(payload: AdminPaymentConfigPayload) { + const { data } = await adminClient.post>( + '/admin/payment-configs', + payload + ) + return data.data +} + +export async function updateAdminPaymentConfig( + id: number, + payload: Partial +) { + const { data } = await adminClient.put>( + `/admin/payment-configs/${id}`, + payload + ) + return data.data +} + +export async function deleteAdminPaymentConfig(id: number) { + await adminClient.delete(`/admin/payment-configs/${id}`) +} + export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) { const { data } = await adminClient.get>>( '/admin/finance/disbursements', diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index e0d030f..319a180 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -1280,3 +1280,78 @@ export interface AdminDistributionAnalytics { total_order_user_count: number trend_days: AdminDistributionTrendPoint[] } + +export interface AdminSyncSiteAccountsPayload { + listing_ids?: number[] + clean_stale_listings: boolean + default_shelf_ratio_delta: number +} + +export interface AdminSyncSiteAccountsResult { + synced_count: number + skipped_count: number + updated_count: number + reenabled_count: number + disabled_stale_count: number + total_active: number +} + +/** 主站支付配置;密钥字段后端默认不返回。 */ +export interface AdminPaymentConfig { + id: number + name: string + provider: string + merchant_id: string + gateway_url: string + notify_url: string + jump_url: string + pay_way: string + jspay_flag: string + sign_type: string + extra_config?: Record + is_default: boolean + status: string + environment: string + business_tags: string[] + reference_count: number + total_transactions: number + total_amount_cent: number + last_used_at?: string | null + created_at: string + updated_at: string +} + +export interface AdminPaymentConfigPage { + items: AdminPaymentConfig[] + total: number + page: number + page_size: number +} + +export interface AdminPaymentConfigQuery { + provider?: string + pay_way?: string + status?: string + environment?: string + page?: number + page_size?: number +} + +export interface AdminPaymentConfigPayload { + name: string + provider: string + merchant_id: string + gateway_url?: string + sign_key?: string + notify_key?: string + notify_url?: string + jump_url?: string + pay_way?: string + jspay_flag?: string + sign_type?: string + extra_config?: Record + is_default?: boolean + status?: string + environment?: string + business_tags?: string[] +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index d0586b5..b1ff8dd 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -137,6 +137,7 @@ const allNavGroups: NavGroup[] = [ icon: , children: [ { key: '/system-configs', label: '系统配置', permission: 'system_config:view' }, + { key: '/payment-configs', label: '支付配置', permission: 'payment_config:list' }, { key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' }, ], }, @@ -168,6 +169,7 @@ const sectionMeta: Record = { '/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' }, '/announcements': { title: '公告管理', description: '维护主站与分站公告。' }, '/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' }, + '/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' }, '/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' }, } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9963673..1415c93 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -39,6 +39,12 @@ export default defineConfig({ if (id.includes('@tanstack') || id.includes('/zustand/') || id.includes('/axios/')) { return 'vendor-data' } + if (id.includes('/@ant-design/icons/')) { + return 'vendor-antd-icons' + } + if (id.includes('/dayjs/')) { + return 'vendor-date' + } return undefined }, },