feat(admin): 后台新增「抖音码速达」配置面板
- 新增 Tab:/admin/platform-shops?tab=douyinMashida - 面板字段:enabled / baseUrl / uid / secret / certLink / afterVerifyLink / afterVerifyMessage / messageType / timeoutMs / handledTopics(tags) - 密钥脱敏:出参 secret 永远为空,输入框未编辑时对外 value 恒为 '', 掩码不会污染后端「空串=保留原值」语义 - 顶部展示 ready 状态,未就绪时用 Alert 列出缺哪几项; 始终展示平台侧要配的推送地址(可复制) - baseUrl 命中本地地址时提示「当前指向本地网关桩,不会请求真实码速达」 - 把 AdminPlatformShopsPage 内联的 SecretFieldControl 抽成共享组件 (函数体与原实现逐字节一致,8 处既有调用点参数不变)
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { EditOutlined, UndoOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Space, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 脱敏密钥输入控件。
|
||||
*
|
||||
* 已配置时只显示掩码 + 「修改」按钮,点开后才出现密码输入框;
|
||||
* 未编辑时对外 value 恒为 '',避免把掩码值当成新密钥回填提交。
|
||||
*/
|
||||
export function SecretFieldControl({
|
||||
label,
|
||||
field,
|
||||
masked,
|
||||
hasSecret,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
field: string
|
||||
masked?: string | undefined
|
||||
hasSecret?: boolean | undefined
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string | undefined
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const isStaged = Boolean(value && value.trim())
|
||||
const configured = Boolean(hasSecret || (masked && masked.trim()))
|
||||
|
||||
function handleStartEdit() {
|
||||
setDraft('')
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed) {
|
||||
onChange(trimmed)
|
||||
setIsEditing(false)
|
||||
setDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
function handleRevert() {
|
||||
onChange('')
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
{isStaged ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
onClick={handleRevert}
|
||||
>
|
||||
撤销修改
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input.Password
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
name={`secure_field_${field}`}
|
||||
id={`secure_field_${field}`}
|
||||
placeholder={placeholder || `输入新 ${label}(留空不修改)`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={handleApply}
|
||||
/>
|
||||
<Button type="primary" onClick={handleApply} disabled={!draft.trim()}>
|
||||
应用
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 11px',
|
||||
background: '#fafafa',
|
||||
border: isStaged ? '1px dashed #faad14' : '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
color: isStaged ? '#d46b08' : configured ? '#262626' : '#8c8c8c',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
{isStaged
|
||||
? '已输入新密钥(待保存)'
|
||||
: masked || (configured ? '••••••••••••••••' : '未配置')}
|
||||
</span>
|
||||
{isStaged ? (
|
||||
<Tag color="warning" style={{ margin: 0, flexShrink: 0 }}>
|
||||
待保存
|
||||
</Tag>
|
||||
) : configured ? (
|
||||
<Tag color="success" style={{ margin: 0, flexShrink: 0 }}>
|
||||
已配置
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0, flexShrink: 0 }}>未配置</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Button icon={<EditOutlined />} onClick={handleStartEdit} style={{ flexShrink: 0 }}>
|
||||
{configured ? '修改' : '设置'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
@@ -31,6 +29,8 @@ import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { SecretFieldControl } from '@/components/admin/SecretFieldControl'
|
||||
import { DouyinMashidaPanel } from './DouyinMashidaPanel'
|
||||
import { PddPlatformPanel } from './PddPlatformPanel'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type PlatformTab = 'notifications' | 'kuaishouIndustry' | 'affiliateDash' | 'pdd'
|
||||
type PlatformTab = 'notifications' | 'kuaishouIndustry' | 'affiliateDash' | 'pdd' | 'douyinMashida'
|
||||
|
||||
type NotificationConfigResponse = {
|
||||
filePath: string
|
||||
@@ -82,7 +82,13 @@ type ScheduledJobsState = {
|
||||
runtime: AdminScheduledJobRuntimeState[]
|
||||
}
|
||||
|
||||
const platformTabs: PlatformTab[] = ['notifications', 'kuaishouIndustry', 'affiliateDash', 'pdd']
|
||||
const platformTabs: PlatformTab[] = [
|
||||
'notifications',
|
||||
'kuaishouIndustry',
|
||||
'affiliateDash',
|
||||
'pdd',
|
||||
'douyinMashida',
|
||||
]
|
||||
|
||||
export default function AdminPlatformShopsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -200,6 +206,11 @@ export default function AdminPlatformShopsPage() {
|
||||
label: '拼多多 (PDD)',
|
||||
children: <PddPlatformPanel />,
|
||||
},
|
||||
{
|
||||
key: 'douyinMashida',
|
||||
label: '抖音码速达',
|
||||
children: <DouyinMashidaPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
@@ -644,155 +655,6 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) {
|
||||
return job.id || job.type || '定时任务'
|
||||
}
|
||||
|
||||
function SecretFieldControl({
|
||||
label,
|
||||
field,
|
||||
masked,
|
||||
hasSecret,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
field: string
|
||||
masked?: string | undefined
|
||||
hasSecret?: boolean | undefined
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string | undefined
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const isStaged = Boolean(value && value.trim())
|
||||
const configured = Boolean(hasSecret || (masked && masked.trim()))
|
||||
|
||||
function handleStartEdit() {
|
||||
setDraft('')
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed) {
|
||||
onChange(trimmed)
|
||||
setIsEditing(false)
|
||||
setDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
function handleRevert() {
|
||||
onChange('')
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
{isStaged ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
onClick={handleRevert}
|
||||
>
|
||||
撤销修改
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input.Password
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
name={`secure_field_${field}`}
|
||||
id={`secure_field_${field}`}
|
||||
placeholder={placeholder || `输入新 ${label}(留空不修改)`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={handleApply}
|
||||
/>
|
||||
<Button type="primary" onClick={handleApply} disabled={!draft.trim()}>
|
||||
应用
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 11px',
|
||||
background: '#fafafa',
|
||||
border: isStaged ? '1px dashed #faad14' : '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
color: isStaged ? '#d46b08' : configured ? '#262626' : '#8c8c8c',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
{isStaged
|
||||
? '已输入新密钥(待保存)'
|
||||
: masked || (configured ? '••••••••••••••••' : '未配置')}
|
||||
</span>
|
||||
{isStaged ? (
|
||||
<Tag color="warning" style={{ margin: 0, flexShrink: 0 }}>
|
||||
待保存
|
||||
</Tag>
|
||||
) : configured ? (
|
||||
<Tag color="success" style={{ margin: 0, flexShrink: 0 }}>
|
||||
已配置
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0, flexShrink: 0 }}>未配置</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Button icon={<EditOutlined />} onClick={handleStartEdit} style={{ flexShrink: 0 }}>
|
||||
{configured ? '修改' : '设置'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouIndustryPanel({
|
||||
config,
|
||||
onChange,
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Douyin Mashida Platform Admin Panel
|
||||
* 抖音码速达接入配置面板 (凭据脱敏读写、配置完整度自检与本地地址误配提示)
|
||||
*/
|
||||
|
||||
import { ApiOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { SecretFieldControl } from '@/components/admin/SecretFieldControl'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminDouyinMashidaConfig,
|
||||
saveAdminDouyinMashidaConfig,
|
||||
} from '@/services/admin/platform-config'
|
||||
import type { AdminDouyinMashidaConfig } from '@/types/admin/platform-config'
|
||||
import {
|
||||
buildDouyinMashidaMessageUrl,
|
||||
describeMissingConfigFields,
|
||||
isLocalDevBaseUrl,
|
||||
} from '@/utils/douyin-mashida'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
/** 平台侧可订阅的消息类型(与后端 message_type 取值一致) */
|
||||
const MESSAGE_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '1 - 订单状态' },
|
||||
{ value: 2, label: '2 - 订单发货' },
|
||||
{ value: 4, label: '4 - 图片' },
|
||||
{ value: 5, label: '5 - 视频' },
|
||||
]
|
||||
|
||||
/** 默认本地处理白名单,作为 Select 的候选标签 */
|
||||
const DEFAULT_HANDLED_TOPICS = ['trade_TradePaid', 'trade_TradeCanceled', 'refund_RefundSuccess']
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
|
||||
|
||||
function createEmptyConfig(): AdminDouyinMashidaConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
uid: '',
|
||||
secret: '',
|
||||
secretMasked: '',
|
||||
hasSecret: false,
|
||||
certLink: '',
|
||||
afterVerifyLink: '',
|
||||
afterVerifyMessage: '您的订单已兑换成功',
|
||||
messageType: 2,
|
||||
timeoutMs: 10000,
|
||||
handledTopics: [...DEFAULT_HANDLED_TOPICS],
|
||||
ready: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function DouyinMashidaPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [config, setConfig] = useState<AdminDouyinMashidaConfig>(createEmptyConfig)
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Load Douyin Mashida config
|
||||
* 读取抖音码速达接入配置(secret 出参恒为空串,不回显)
|
||||
*/
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetchAdminDouyinMashidaConfig()
|
||||
if (response && response.data) {
|
||||
setConfig({ ...response.data, secret: '' })
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '读取抖音码速达配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config. Empty / masked secret keeps the stored value (backend semantics).
|
||||
* 保存配置:secret 留空或保持掩码即「不修改」,不会被空串覆盖。
|
||||
*/
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: AdminDouyinMashidaConfig = {
|
||||
...config,
|
||||
secret: String(config.secret || '').trim(),
|
||||
}
|
||||
const response = await saveAdminDouyinMashidaConfig(payload)
|
||||
if (response && response.data) {
|
||||
setConfig({ ...response.data, secret: '' })
|
||||
}
|
||||
showSuccess('抖音码速达配置已保存')
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '保存抖音码速达配置失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof AdminDouyinMashidaConfig>(
|
||||
key: K,
|
||||
value: AdminDouyinMashidaConfig[K],
|
||||
) {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin tip="正在加载抖音码速达配置..." />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const missingFields = describeMissingConfigFields({
|
||||
enabled: config.enabled,
|
||||
uid: config.uid,
|
||||
hasSecret: config.hasSecret,
|
||||
certLink: config.certLink,
|
||||
})
|
||||
const isLocalDev = isLocalDevBaseUrl(config.baseUrl)
|
||||
const messageUrl = buildDouyinMashidaMessageUrl(window.location.origin)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined style={{ color: '#1677ff' }} />
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
抖音码速达接入配置
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Space wrap align="center">
|
||||
<Text strong>接入状态:</Text>
|
||||
<Tag color={config.ready ? 'success' : 'warning'}>
|
||||
{config.ready ? '配置完整' : '待补齐'}
|
||||
</Tag>
|
||||
<Badge
|
||||
status={config.enabled ? 'processing' : 'default'}
|
||||
text={
|
||||
<Text type={config.enabled ? 'success' : 'secondary'}>
|
||||
{config.enabled ? '接入开关已开启' : '接入开关已关闭'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{!config.ready && missingFields.length > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`配置尚未完整,还缺:${missingFields.join('、')}`}
|
||||
description="接入开关、商户 uid、用户秘钥、兑换链接四项齐全后,接入状态才会变为「配置完整」。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isLocalDev ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="当前指向本地地址,不会请求真实码速达"
|
||||
description="baseUrl 命中 127.0.0.1 / localhost / host.docker.internal:出网请求打不到真实平台,平台也推不进来。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="平台侧需要配置的消息推送地址"
|
||||
description={
|
||||
<Text code copyable={{ text: messageUrl }}>
|
||||
{messageUrl}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>接入开关</Text>
|
||||
<Space align="center">
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => updateField('enabled', checked)}
|
||||
/>
|
||||
<Tag color={config.enabled ? 'success' : 'default'}>
|
||||
{config.enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={18}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>网关地址 (baseUrl)</Text>
|
||||
<Input
|
||||
value={config.baseUrl}
|
||||
onChange={(event) => updateField('baseUrl', event.target.value)}
|
||||
placeholder={DEFAULT_BASE_URL}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
真实联调请填 https://dy-openplatform-api.wanmahui.com(本地地址收不到平台推送)
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>抖店商户号 (uid)</Text>
|
||||
<Input
|
||||
value={config.uid}
|
||||
onChange={(event) => updateField('uid', event.target.value)}
|
||||
placeholder="平台方分配的商户 uid"
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<SecretFieldControl
|
||||
label="用户秘钥 (secret)"
|
||||
field="douyinMashidaSecret"
|
||||
masked={config.secretMasked}
|
||||
hasSecret={config.hasSecret}
|
||||
value={config.secret}
|
||||
onChange={(value) => updateField('secret', value)}
|
||||
placeholder="输入新的用户秘钥(留空保持原值)"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>兑换链接 (certLink)</Text>
|
||||
<Input
|
||||
value={config.certLink}
|
||||
onChange={(event) => updateField('certLink', event.target.value)}
|
||||
placeholder="https://你的域名/redeem"
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
卡券里下发给买家的固定兑换链接
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>去使用链接 (afterVerifyLink)</Text>
|
||||
<Input
|
||||
value={config.afterVerifyLink}
|
||||
onChange={(event) => updateField('afterVerifyLink', event.target.value)}
|
||||
placeholder="兑换成功后发给买家,留空则只发文案"
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={16}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>通知正文 (afterVerifyMessage)</Text>
|
||||
<Input
|
||||
value={config.afterVerifyMessage}
|
||||
onChange={(event) => updateField('afterVerifyMessage', event.target.value)}
|
||||
placeholder="您的订单已兑换成功"
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>消息类型 (messageType)</Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={config.messageType}
|
||||
onChange={(value) => updateField('messageType', Number(value))}
|
||||
options={MESSAGE_TYPE_OPTIONS}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>出网超时 (timeoutMs)</Text>
|
||||
<InputNumber
|
||||
min={1000}
|
||||
step={1000}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="毫秒"
|
||||
value={config.timeoutMs}
|
||||
onChange={(value) => updateField('timeoutMs', Number(value || 10000))}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>处理 topic 白名单 (handledTopics)</Text>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
value={config.handledTopics}
|
||||
onChange={(value: string[]) => updateField('handledTopics', value)}
|
||||
options={DEFAULT_HANDLED_TOPICS.map((topic) => ({ value: topic, label: topic }))}
|
||||
placeholder="选择或输入要处理的 topic"
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Douyin Mashida Admin Platform Config Service
|
||||
* 抖音码速达后台接入配置 HTTP 服务
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminDouyinMashidaConfig } from '@/types/admin/platform-config'
|
||||
|
||||
export function fetchAdminDouyinMashidaConfig() {
|
||||
return apiGet<AdminDouyinMashidaConfig>('/api/v1/admin/platform-config/douyin-mashida')
|
||||
}
|
||||
|
||||
export function saveAdminDouyinMashidaConfig(payload: AdminDouyinMashidaConfig) {
|
||||
return apiPost<AdminDouyinMashidaConfig>('/api/v1/admin/platform-config/douyin-mashida', payload)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './kuaishou-industry'
|
||||
export * from './affiliate-dash'
|
||||
export * from './fulfillment-routing'
|
||||
export * from './pdd'
|
||||
export * from './douyin-mashida'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Douyin Mashida Admin Platform Config Types
|
||||
* 抖音码速达(抖店中转开放平台)后台配置数据结构定义
|
||||
*
|
||||
* 与后端 `services/admin/platform-config/douyin-mashida-service.ts` 的出参一一对应:
|
||||
* `secret` 出参永远为空串(不回显明文),只通过 `secretMasked` / `hasSecret` 表达「已配置」。
|
||||
*/
|
||||
|
||||
export interface AdminDouyinMashidaConfig {
|
||||
enabled: boolean
|
||||
/** 码速达开放平台网关地址(本地联调可填 http://127.0.0.1:3000 之类的桩地址) */
|
||||
baseUrl: string
|
||||
/** 抖店商户号 */
|
||||
uid: string
|
||||
/** 用户秘钥:出参永远是 '',提交时填了新值才会更新 */
|
||||
secret: string
|
||||
/** 脱敏后的秘钥,形如 abcd****wxyz */
|
||||
secretMasked: string
|
||||
hasSecret: boolean
|
||||
/** 卡券里的固定兑换链接,如 https://<域名>/redeem */
|
||||
certLink: string
|
||||
/** 兑换成功后发给买家的「去使用链接」 */
|
||||
afterVerifyLink: string
|
||||
/** 兑换成功通知正文,默认「您的订单已兑换成功」 */
|
||||
afterVerifyMessage: string
|
||||
/** 1-订单状态 2-订单发货 4-图片 5-视频 */
|
||||
messageType: number
|
||||
/** 出网超时(毫秒),默认 10000 */
|
||||
timeoutMs: number
|
||||
/** 本地处理白名单 topic */
|
||||
handledTopics: string[]
|
||||
/** enabled + uid + secret + certLink 齐全(只读) */
|
||||
ready: boolean
|
||||
}
|
||||
@@ -51,3 +51,5 @@ export type {
|
||||
AdminPddSyncAllResponse,
|
||||
AdminPddStoreSyncTokenResponse,
|
||||
} from './pdd'
|
||||
|
||||
export type { AdminDouyinMashidaConfig } from './douyin-mashida'
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 抖音码速达后台配置的纯展示 / 校验逻辑。
|
||||
*
|
||||
* 刻意写成纯函数:不依赖 React / antd,也不使用 `@/` 别名,只做 `import type`,
|
||||
* 方便 `node --experimental-strip-types --test` 直接跑单测。
|
||||
*/
|
||||
import type { AdminDouyinMashidaConfig } from '../types/admin/platform-config'
|
||||
|
||||
/** 配置完整度检查所需的配置子集 */
|
||||
type MissingConfigCheck = Pick<
|
||||
AdminDouyinMashidaConfig,
|
||||
'enabled' | 'uid' | 'hasSecret' | 'certLink'
|
||||
>
|
||||
|
||||
/** 配置完整度检查的固定展示顺序 */
|
||||
const MISSING_FIELD_LABELS = ['接入开关', '商户uid', '用户秘钥', '兑换链接'] as const
|
||||
|
||||
/** 本地/内网 host:命中即认为不会请求真实码速达 */
|
||||
const LOCAL_DEV_HOSTS = new Set(['127.0.0.1', 'localhost', 'host.docker.internal'])
|
||||
|
||||
/**
|
||||
* 判断 baseUrl 是否指向本地/内网地址(这类地址平台永远访问不到)。
|
||||
*
|
||||
* 容忍端口、结尾斜杠、大小写与缺失协议(如 `127.0.0.1:3000`)。
|
||||
* 无法解析的地址一律视为非本地。
|
||||
*/
|
||||
export function isLocalDevBaseUrl(baseUrl: string): boolean {
|
||||
const text = String(baseUrl ?? '').trim()
|
||||
if (!text) return false
|
||||
|
||||
try {
|
||||
const url = new URL(text.includes('://') ? text : `http://${text}`)
|
||||
return LOCAL_DEV_HOSTS.has(url.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接平台侧需要配置的消息推送地址(容错结尾斜杠)。
|
||||
* 例:`https://ks.khhao.com/` → `https://ks.khhao.com/api/v1/open/douyin-mashida/message`
|
||||
*/
|
||||
export function buildDouyinMashidaMessageUrl(origin: string): string {
|
||||
const normalized = String(origin ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
return `${normalized}/api/v1/open/douyin-mashida/message`
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回缺失项的中文名数组,顺序固定为:接入开关 / 商户uid / 用户秘钥 / 兑换链接。
|
||||
* 全齐时返回空数组。
|
||||
*/
|
||||
export function describeMissingConfigFields(config: MissingConfigCheck): string[] {
|
||||
const source = config ?? ({} as MissingConfigCheck)
|
||||
const missing: string[] = []
|
||||
|
||||
if (!source.enabled) missing.push(MISSING_FIELD_LABELS[0])
|
||||
if (!String(source.uid ?? '').trim()) missing.push(MISSING_FIELD_LABELS[1])
|
||||
if (!source.hasSecret) missing.push(MISSING_FIELD_LABELS[2])
|
||||
if (!String(source.certLink ?? '').trim()) missing.push(MISSING_FIELD_LABELS[3])
|
||||
|
||||
return missing
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDouyinMashidaMessageUrl,
|
||||
describeMissingConfigFields,
|
||||
isLocalDevBaseUrl,
|
||||
} from '../src/utils/douyin-mashida.ts'
|
||||
|
||||
/** 固定顺序:接入开关 / 商户uid / 用户秘钥 / 兑换链接 */
|
||||
const ALL_MISSING = ['接入开关', '商户uid', '用户秘钥', '兑换链接']
|
||||
|
||||
test('isLocalDevBaseUrl:127.0.0.1 / localhost / host.docker.internal(含端口与结尾斜杠)判为 true', () => {
|
||||
assert.equal(isLocalDevBaseUrl('http://127.0.0.1:3000'), true)
|
||||
assert.equal(isLocalDevBaseUrl('http://127.0.0.1:3000/'), true)
|
||||
assert.equal(isLocalDevBaseUrl('http://127.0.0.1'), true)
|
||||
assert.equal(isLocalDevBaseUrl('http://localhost:8080/'), true)
|
||||
assert.equal(isLocalDevBaseUrl('http://localhost'), true)
|
||||
assert.equal(isLocalDevBaseUrl('http://host.docker.internal:3000/'), true)
|
||||
assert.equal(isLocalDevBaseUrl(' http://localhost:5173// '), true)
|
||||
})
|
||||
|
||||
test('isLocalDevBaseUrl:线上地址 / 空串 / 非本地域名判为 false', () => {
|
||||
assert.equal(isLocalDevBaseUrl('https://dy-openplatform-api.wanmahui.com'), false)
|
||||
assert.equal(isLocalDevBaseUrl('https://dy-openplatform-api.wanmahui.com/'), false)
|
||||
assert.equal(isLocalDevBaseUrl(''), false)
|
||||
assert.equal(isLocalDevBaseUrl(' '), false)
|
||||
assert.equal(isLocalDevBaseUrl('http://example.com'), false)
|
||||
assert.equal(isLocalDevBaseUrl('http://notlocalhost:3000'), false)
|
||||
})
|
||||
|
||||
test('buildDouyinMashidaMessageUrl:拼接推送地址并容忍结尾斜杠', () => {
|
||||
const suffix = '/api/v1/open/douyin-mashida/message'
|
||||
assert.equal(
|
||||
buildDouyinMashidaMessageUrl('https://ks.khhao.com'),
|
||||
`https://ks.khhao.com${suffix}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaMessageUrl('https://ks.khhao.com/'),
|
||||
`https://ks.khhao.com${suffix}`,
|
||||
)
|
||||
assert.equal(
|
||||
buildDouyinMashidaMessageUrl('http://127.0.0.1:3000//'),
|
||||
`http://127.0.0.1:3000${suffix}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('describeMissingConfigFields:四项齐全返回空数组', () => {
|
||||
assert.deepEqual(
|
||||
describeMissingConfigFields({
|
||||
enabled: true,
|
||||
uid: 'uid_123',
|
||||
hasSecret: true,
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
}),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('describeMissingConfigFields:缺多项按固定顺序返回中文名', () => {
|
||||
assert.deepEqual(
|
||||
describeMissingConfigFields({ enabled: false, uid: '', hasSecret: false, certLink: '' }),
|
||||
ALL_MISSING,
|
||||
)
|
||||
})
|
||||
|
||||
test('describeMissingConfigFields:缺单项只返回对应中文名', () => {
|
||||
assert.deepEqual(
|
||||
describeMissingConfigFields({
|
||||
enabled: true,
|
||||
uid: 'uid_123',
|
||||
hasSecret: false,
|
||||
certLink: 'https://ks.khhao.com/redeem',
|
||||
}),
|
||||
['用户秘钥'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
describeMissingConfigFields({
|
||||
enabled: false,
|
||||
uid: 'uid_123',
|
||||
hasSecret: true,
|
||||
certLink: ' ',
|
||||
}),
|
||||
['接入开关', '兑换链接'],
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user