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:
yml2213
2026-10-09 12:01:29 +08:00
parent 72606796b3
commit 080480f03b
9 changed files with 721 additions and 153 deletions
@@ -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'
+64
View File
@@ -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: ' ',
}),
['接入开关', '兑换链接'],
)
})