feat(distribution): 收口账号同步与支付配置并优化结算

This commit is contained in:
yml2213
2026-10-30 16:03:30 +08:00
parent d37cbad323
commit 81a19431fa
11 changed files with 1122 additions and 26 deletions
+65 -17
View File
@@ -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 {
// 预收范围内按号主预收基准,超出预收部分作为后置损耗全额划归号主(平台不抽成)
@@ -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)
}
}
@@ -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` 与生产冒烟。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;剩余分销账号同步、主站支付配置管理等细分长尾和真实订单端到端回归。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、支付配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;分销账号同步入口已接入,订单结算比例已 bps 化;剩余真实外部支付 E2E、真实站点数据业务回归与上线准备。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6
View File
@@ -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() {
<Route path="announcements" element={<AdminAnnouncementsPage />} />
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
<Route path="payment-configs" element={<AdminPaymentConfigsPage />} />
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
<Route
path="distribution/dashboard"
@@ -10,6 +10,7 @@ import {
Modal,
Select,
Space,
Switch,
Tabs,
Tag,
Typography,
@@ -20,6 +21,7 @@ import {
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
SyncOutlined,
TagsOutlined,
} from '@ant-design/icons'
@@ -28,6 +30,7 @@ import {
batchUpdateAdminAccountMarkup,
fetchAdminAccountDisplays,
fetchAdminReceivedListings,
syncAdminDistributionSiteAccounts,
updateAdminAccountDisplay,
updateAdminAccountMarkup,
updateAdminReceivedListingStatus,
@@ -91,6 +94,11 @@ interface MarkupFormValues {
shelf_ratio_delta: number
}
interface SyncFormValues {
clean_stale_listings: boolean
default_shelf_ratio_delta: number
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
@@ -128,6 +136,9 @@ export function AdminDistributionListingsPage() {
const [batchMarkupOpen, setBatchMarkupOpen] = useState(false)
const [batchMarkupForm] = Form.useForm<MarkupFormValues>()
const [working, setWorking] = useState(false)
const [syncOpen, setSyncOpen] = useState(false)
const [syncForm] = Form.useForm<SyncFormValues>()
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: (
<div className="admin-stack admin-stack--tiny">
<span>同步 {result.synced_count} 件,跳过 {result.skipped_count} 件</span>
<span>更新 {result.updated_count} 件,恢复 {result.reenabled_count} 件</span>
<span>清理失效 {result.disabled_stale_count} 件</span>
<span>当前展示 {result.total_active} 件</span>
</div>
),
})
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<AdminReceivedListing> = [
{
title: '商品',
@@ -514,14 +562,25 @@ export function AdminDistributionListingsPage() {
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={receivedQuery.isFetching || accountsQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
<Space>
{canManage ? (
<Button
icon={<SyncOutlined />}
disabled={!siteId}
onClick={openSync}
>
同步账号
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={refresh}
loading={receivedQuery.isFetching || accountsQuery.isFetching}
disabled={!canView}
>
刷新
</Button>
</Space>
}
/>
@@ -843,6 +902,60 @@ export function AdminDistributionListingsPage() {
</Form.Item>
</Form>
</Modal>
<Modal
open={syncOpen}
title="同步站点账号"
width={560}
maskClosable={!syncing}
closable={!syncing}
onCancel={() => (syncing ? undefined : setSyncOpen(false))}
footer={
<Space>
<Button onClick={() => setSyncOpen(false)} disabled={syncing}>
取消
</Button>
<Button
type="primary"
icon={<SyncOutlined />}
loading={syncing}
onClick={() => void runSync()}
>
开始同步
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="同步会改动站点账号展示数据"
description="同步按主站商品重新生成站点账号映射,可恢复/停用失效商品;请确认当前站点后再执行。"
/>
<Form<SyncFormValues>
form={syncForm}
layout="vertical"
disabled={syncing}
initialValues={{ clean_stale_listings: false, default_shelf_ratio_delta: 0 }}
>
<Form.Item
name="default_shelf_ratio_delta"
label="新账号默认加价(点)"
extra="仅对本次新同步进站点的账号使用;已有账号保持原加价。"
>
<InputNumber min={-50} max={50} step={0.1} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="clean_stale_listings"
label="清理失效商品"
valuePropName="checked"
extra="开启后会停用主站已下架/失效且不再同步的商品展示。"
>
<Switch checkedChildren="清理" unCheckedChildren="保留" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -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<string, string> = {
leshua: '乐刷',
lakala: '拉卡拉',
shuncheng: '顺诚',
mock: 'Mock',
}
const payWayLabels: Record<string, string> = {
ZFBZF: '支付宝',
WXZF: '微信支付',
}
const statusLabels: Record<string, string> = {
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<string, unknown>
}
export function AdminPaymentConfigsPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfigFormValues>()
const [draft, setDraft] = useState<AdminPaymentConfigQuery>({})
const [filters, setFilters] = useState<AdminPaymentConfigQuery>({})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminPaymentConfig>()
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<string, unknown> | 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<AdminPaymentConfig> = [
{
title: '配置',
key: 'config',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<Space size={6} wrap>
<strong>{row.name}</strong>
{row.is_default ? <Tag color="gold">默认</Tag> : null}
</Space>
<span className="admin-mono">#{row.id}</span>
<span className="admin-mono">{formatText(row.merchant_id)}</span>
</div>
),
},
{
title: '服务商 / 方式',
key: 'provider',
width: 170,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<Tag color="geekblue">{providerLabels[row.provider] ?? row.provider}</Tag>
<span>{payWayLabels[row.pay_way] ?? formatText(row.pay_way)}</span>
<span>{formatText(row.sign_type)}</span>
</div>
),
},
{
title: '环境 / 状态',
key: 'status',
width: 150,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<Tag color={row.status === 'active' ? 'success' : row.status === 'testing' ? 'gold' : 'default'}>
{statusLabels[row.status] ?? row.status}
</Tag>
<span>{row.environment === 'production' ? '生产' : '沙箱'}</span>
</div>
),
},
{
title: '引用 / 交易',
key: 'usage',
width: 170,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{row.total_transactions} 笔</strong>
<span>引用 {row.reference_count}</span>
<span>{formatMoney(row.total_amount_cent)}</span>
</div>
),
},
{
title: '回调地址',
key: 'notify',
width: 240,
render: (_, row) => (
<Typography.Text ellipsis={{ tooltip: row.notify_url }} style={{ maxWidth: 220 }}>
{formatText(row.notify_url)}
</Typography.Text>
),
},
{
title: '最近使用',
dataIndex: 'last_used_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
disabled={!canUpdate}
onClick={() => openEdit(row)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={!canDelete}
onClick={() => remove(row)}
>
删除
</Button>
</Space>
),
},
]
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 支付"
title="支付配置"
description="维护主站支付商户与回调参数;密钥只写不读,编辑时留空表示不修改。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {summary.total} 条</Tag>
<Tag color="green">启用 {summary.active}</Tag>
<Tag color="gold">默认 {summary.defaultCount}</Tag>
{listQuery.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canCreate ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配置
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
loading={listQuery.isFetching}
disabled={!canView}
onClick={() => void listQuery.refetch()}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有支付配置查看权限"
description="需要 payment_config:list 权限。"
/>
) : null}
<FilterBar
onSearch={applyFilters}
onReset={
Object.values(draft).some(Boolean) || Object.values(filters).some(Boolean)
? resetFilters
: undefined
}
loading={listQuery.isFetching}
extra={
<Typography.Text type="secondary">
{listQuery.isPending ? '加载中' : `当前 ${rows.length} 条`}
</Typography.Text>
}
>
<FilterField label="服务商" width={160}>
<Select
allowClear
value={draft.provider}
placeholder="全部"
options={providerOptions}
onChange={value => setDraft(current => ({ ...current, provider: value }))}
/>
</FilterField>
<FilterField label="支付方式" width={160}>
<Select
allowClear
value={draft.pay_way}
placeholder="全部"
options={payWayOptions}
onChange={value => setDraft(current => ({ ...current, pay_way: value }))}
/>
</FilterField>
<FilterField label="状态" width={140}>
<Select
allowClear
value={draft.status}
placeholder="全部"
options={statusOptions}
onChange={value => setDraft(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="环境" width={140}>
<Select
allowClear
value={draft.environment}
placeholder="全部"
options={[
{ value: 'production', label: '生产' },
{ value: 'sandbox', label: '沙箱' },
]}
onChange={value => setDraft(current => ({ ...current, environment: value }))}
/>
</FilterField>
</FilterBar>
{listQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="支付配置没有加载出来"
description={errorText(listQuery.error)}
action={
<Button size="small" onClick={() => void listQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminPaymentConfig>
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 ? '还没有支付配置' : '当前账号没有查看权限'}
/>
<Modal
open={editorOpen}
title={editing ? `编辑支付配置 · ${editing.name}` : '新建支付配置'}
width={820}
maskClosable={!submitting}
closable={!submitting}
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
footer={
<Space>
<Button onClick={() => setEditorOpen(false)} disabled={submitting}>
取消
</Button>
<Button type="primary" loading={submitting} onClick={() => void save()}>
保存配置
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="密钥只写不读"
description={
editing
? 'SignKey / NotifyKey 留空表示保持原值;额外配置需提交完整 JSON。'
: '乐刷/拉卡拉/顺诚需要完整密钥与回调地址;创建前请确认商户环境。'
}
/>
<Form<ConfigFormValues> form={form} layout="vertical" disabled={submitting}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="配置名称" rules={[{ required: true }]}>
<Input maxLength={128} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="provider" label="服务商" rules={[{ required: true }]}>
<Select options={providerOptions} disabled={Boolean(editing)} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="merchant_id" label="商户号" rules={[{ required: true }]}>
<Input maxLength={128} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="pay_way" label="支付方式">
<Select allowClear options={payWayOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item name="gateway_url" label="网关地址">
<Input maxLength={512} />
</Form.Item>
<Form.Item name="notify_url" label="回调地址">
<Input maxLength={512} />
</Form.Item>
<Form.Item name="jump_url" label="跳转地址">
<Input maxLength={512} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="sign_key" label="SignKey" extra={editing ? '留空不修改' : '按服务商要求填写'}>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="notify_key" label="NotifyKey" extra={editing ? '留空不修改' : '按服务商要求填写'}>
<Input.Password autoComplete="new-password" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="sign_type" label="签名类型">
<Select
allowClear
options={[
{ value: 'MD5', label: 'MD5' },
{ value: 'SHA256withRSA', label: 'SHA256withRSA' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="status" label="状态">
<Select options={statusOptions} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="environment" label="环境">
<Select
options={[
{ value: 'production', label: '生产' },
{ value: 'sandbox', label: '沙箱' },
]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="jspay_flag" label="JSAPI 标识">
<Input maxLength={64} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="is_default" label="默认配置" valuePropName="checked">
<Switch checkedChildren="默认" unCheckedChildren="普通" />
</Form.Item>
</Col>
</Row>
<Form.Item name="business_tags_text" label="业务标签" extra="逗号分隔">
<Input maxLength={255} />
</Form.Item>
<Form.Item
name="extra_config_json"
label="额外配置 JSON"
extra="拉卡拉需 app_id / serial_no / term_no;顺诚需 secret_id"
>
<Input.TextArea rows={5} placeholder='{"app_id":"...","serial_no":"...","term_no":"..."}' />
</Form.Item>
<Typography.Paragraph type="secondary" className="admin-form-hint">
<CreditCardOutlined /> 支付配置变更会记录审计日志;密钥不会回显。
</Typography.Paragraph>
</Form>
</Modal>
</div>
)
}
+53
View File
@@ -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<ApiResponse<AdminSyncSiteAccountsResult>>(
`/admin/distribution/sites/${siteId}/sync-accounts`,
payload
)
return data.data
}
export async function fetchAdminPaymentConfigs(query: AdminPaymentConfigQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminPaymentConfigPage>>>(
'/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<ApiResponse<AdminPaymentConfig>>(
'/admin/payment-configs',
payload
)
return data.data
}
export async function updateAdminPaymentConfig(
id: number,
payload: Partial<AdminPaymentConfigPayload>
) {
const { data } = await adminClient.put<ApiResponse<AdminPaymentConfig>>(
`/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<ApiResponse<Partial<FinanceDisbursementPage>>>(
'/admin/finance/disbursements',
+75
View File
@@ -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<string, unknown>
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<string, unknown>
is_default?: boolean
status?: string
environment?: string
business_tags?: string[]
}
+2
View File
@@ -137,6 +137,7 @@ const allNavGroups: NavGroup[] = [
icon: <SettingOutlined />,
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<string, { title: string; description: string }> = {
'/distribution/invitation': { title: '邀请返利', description: '配置分站邀请好友的返利比例。' },
'/announcements': { title: '公告管理', description: '维护主站与分站公告。' },
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
}
+6
View File
@@ -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
},
},