diff --git a/apps/frontend/src/components/admin/SecretFieldControl.tsx b/apps/frontend/src/components/admin/SecretFieldControl.tsx new file mode 100644 index 00000000..640661b3 --- /dev/null +++ b/apps/frontend/src/components/admin/SecretFieldControl.tsx @@ -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 ( +
+
+ {label} + {isStaged ? ( + + ) : null} +
+ + {isEditing ? ( + + setDraft(e.target.value)} + onPressEnter={handleApply} + /> + + + + ) : ( +
+
+ + {isStaged + ? '已输入新密钥(待保存)' + : masked || (configured ? '••••••••••••••••' : '未配置')} + + {isStaged ? ( + + 待保存 + + ) : configured ? ( + + 已配置 + + ) : ( + 未配置 + )} +
+ +
+ )} +
+ ) +} diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index f9fc96eb..3235fff9 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -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: , }, + { + key: 'douyinMashida', + label: '抖音码速达', + children: , + }, ]} /> @@ -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 ( -
-
- {label} - {isStaged ? ( - - ) : null} -
- - {isEditing ? ( - - setDraft(e.target.value)} - onPressEnter={handleApply} - /> - - - - ) : ( -
-
- - {isStaged - ? '已输入新密钥(待保存)' - : masked || (configured ? '••••••••••••••••' : '未配置')} - - {isStaged ? ( - - 待保存 - - ) : configured ? ( - - 已配置 - - ) : ( - 未配置 - )} -
- -
- )} -
- ) -} - function KuaishouIndustryPanel({ config, onChange, diff --git a/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx new file mode 100644 index 00000000..239edae2 --- /dev/null +++ b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx @@ -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(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( + key: K, + value: AdminDouyinMashidaConfig[K], + ) { + setConfig((prev) => ({ ...prev, [key]: value })) + } + + if (loading) { + return ( + +
+ +
+
+ ) + } + + 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 ( + + + + 抖音码速达接入配置 + + + } + extra={ + + + + + } + > + + + 接入状态: + + {config.ready ? '配置完整' : '待补齐'} + + + {config.enabled ? '接入开关已开启' : '接入开关已关闭'} + + } + /> + + + {!config.ready && missingFields.length > 0 ? ( + + ) : null} + + {isLocalDev ? ( + + ) : null} + + + {messageUrl} + + } + /> + + + + + + 接入开关 + + updateField('enabled', checked)} + /> + + {config.enabled ? '已启用' : '已停用'} + + + + + + + + 网关地址 (baseUrl) + updateField('baseUrl', event.target.value)} + placeholder={DEFAULT_BASE_URL} + /> + + 真实联调请填 https://dy-openplatform-api.wanmahui.com(本地地址收不到平台推送) + + + + + + + 抖店商户号 (uid) + updateField('uid', event.target.value)} + placeholder="平台方分配的商户 uid" + /> + + + + + updateField('secret', value)} + placeholder="输入新的用户秘钥(留空保持原值)" + /> + + + + + 兑换链接 (certLink) + updateField('certLink', event.target.value)} + placeholder="https://你的域名/redeem" + /> + + 卡券里下发给买家的固定兑换链接 + + + + + + + 去使用链接 (afterVerifyLink) + updateField('afterVerifyLink', event.target.value)} + placeholder="兑换成功后发给买家,留空则只发文案" + /> + + + + + + 通知正文 (afterVerifyMessage) + updateField('afterVerifyMessage', event.target.value)} + placeholder="您的订单已兑换成功" + /> + + + + + + 消息类型 (messageType) + updateField('handledTopics', value)} + options={DEFAULT_HANDLED_TOPICS.map((topic) => ({ value: topic, label: topic }))} + placeholder="选择或输入要处理的 topic" + /> + + + + + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/douyin-mashida.ts b/apps/frontend/src/services/admin/platform-config/douyin-mashida.ts new file mode 100644 index 00000000..0e975fdc --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/douyin-mashida.ts @@ -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('/api/v1/admin/platform-config/douyin-mashida') +} + +export function saveAdminDouyinMashidaConfig(payload: AdminDouyinMashidaConfig) { + return apiPost('/api/v1/admin/platform-config/douyin-mashida', payload) +} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 9885667b..29fc3710 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -4,3 +4,4 @@ export * from './kuaishou-industry' export * from './affiliate-dash' export * from './fulfillment-routing' export * from './pdd' +export * from './douyin-mashida' diff --git a/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts b/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts new file mode 100644 index 00000000..a9a1081b --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts @@ -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 +} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 302eae48..1477b15d 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -51,3 +51,5 @@ export type { AdminPddSyncAllResponse, AdminPddStoreSyncTokenResponse, } from './pdd' + +export type { AdminDouyinMashidaConfig } from './douyin-mashida' diff --git a/apps/frontend/src/utils/douyin-mashida.ts b/apps/frontend/src/utils/douyin-mashida.ts new file mode 100644 index 00000000..3659a17e --- /dev/null +++ b/apps/frontend/src/utils/douyin-mashida.ts @@ -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 +} diff --git a/apps/frontend/tests/douyin-mashida.test.ts b/apps/frontend/tests/douyin-mashida.test.ts new file mode 100644 index 00000000..183bd622 --- /dev/null +++ b/apps/frontend/tests/douyin-mashida.test.ts @@ -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: ' ', + }), + ['接入开关', '兑换链接'], + ) +})