From d8ce40e3465381d3317e4af770b3978a42660cca Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 10 Oct 2026 20:05:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyin-mashida):=20=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=80=E9=94=AE=E5=A1=AB?= =?UTF-8?q?=E5=9F=9F=E5=90=8D/=E8=AE=A2=E9=98=85=E6=8E=A8=E9=80=81/?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E8=87=AA=E6=A3=80=E5=B9=B6=E5=89=94=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 剔除历史旧卡券时代废弃的 afterVerifyLink(去使用链接)与 afterVerifyMessage(通知正文) - 面板新增一键填入当前域名、向开放平台一键订阅推送、停用推送以及网关连通与签名自检功能 - 业务配置重构为「基础接入与商户凭据」、「买家 H5 直充与发货私信」、「高级网关设置」三层清晰结构 - 后端与前端服务新增 /douyin-mashida/subscribe 与 /douyin-mashida/test-gateway 统一接口 验证方式:前端类型检查与打包构建通过,全量 132 项前端单测与 674 项后端单测全部通过 --- .../admin/platform-config/douyin-mashida.ts | 33 + .../platform-config/douyin-mashida-service.ts | 103 +++ .../douyin-mashida/gateway-client.ts | 29 + .../admin/platform/DouyinMashidaPanel.tsx | 660 +++++++++++------- .../admin/platform-config/douyin-mashida.ts | 23 + 5 files changed, 611 insertions(+), 237 deletions(-) diff --git a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts index f73274f4..14edfea2 100644 --- a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts +++ b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts @@ -3,6 +3,8 @@ import { Router } from 'express' import { getAdminDouyinMashidaConfig, saveAdminDouyinMashidaConfig, + subscribeAdminDouyinMashidaPush, + testAdminDouyinMashidaGateway, } from '../../../services/admin/platform-config/douyin-mashida-service.js' import { createJsonHandler } from '../session.js' import type { JsonRecord } from '../../../types/json.js' @@ -49,4 +51,35 @@ router.post( }), ) +router.post( + '/douyin-mashida/subscribe', + createJsonHandler((req) => subscribeAdminDouyinMashidaPush(req.body as JsonRecord), { + successMessage: '平台推送设置成功', + errorMessage: '平台推送设置失败', + scope: '[admin/platform-config/douyin-mashida/subscribe]', + audit: (req, _data) => { + const payload = req.body as JsonRecord + return { + action: 'platform_douyin_mashida_subscribe_updated', + targetType: 'platform_config', + targetId: 'douyin_mashida', + data: { + disable: Boolean(payload?.disable), + messageUrl: String(payload?.messageUrl || payload?.publicUrl || ''), + }, + } + }, + }), +) + +router.post( + '/douyin-mashida/test-gateway', + createJsonHandler(() => testAdminDouyinMashidaGateway(), { + successMessage: '网关测试完成', + errorMessage: '网关测试失败', + scope: '[admin/platform-config/douyin-mashida/test-gateway]', + }), +) + export default router + diff --git a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts index db3209f4..92b328e9 100644 --- a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts +++ b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts @@ -1,6 +1,11 @@ import type { JsonObject } from '../../../types/json.js' +import { createHttpError } from '../../../utils/http.js' import { maskSecret } from '../../../utils/masking.js' +import { + configureDouyinMashidaMessage, + fetchDouyinMashidaOrderDetail, +} from '../../platforms/douyin-mashida/gateway-client.js' import { getDouyinMashidaSourceConfig, saveDouyinMashidaSourceConfig, @@ -32,6 +37,103 @@ export async function saveAdminDouyinMashidaConfig(payload: JsonObject = {}) { return mapAdminDouyinMashidaConfig(saved) } +/** 向开放平台订阅或停用消息推送(message.config) */ +export async function subscribeAdminDouyinMashidaPush( + payload: { + publicUrl?: string + messageUrl?: string + topics?: string[] | string + disable?: boolean + } = {}, +) { + const config = getDouyinMashidaSourceConfig() + if (!config.enabled) { + throw createHttpError('抖音码速达接入未启用,请先开启接入开关', { + statusCode: 409, + errorCode: 'douyin_mashida_disabled', + }) + } + if (!config.uid || !config.secret) { + throw createHttpError('抖音码速达缺少 uid 或用户秘钥,请先在面板中配置并保存', { + statusCode: 409, + errorCode: 'douyin_mashida_credentials_missing', + }) + } + + const disable = Boolean(payload.disable) + let messageUrl = String(payload.messageUrl || '').trim() + if (!messageUrl && payload.publicUrl) { + messageUrl = `${String(payload.publicUrl).trim().replace(/\/+$/, '')}/api/v1/open/douyin-mashida/message` + } + + if (!disable && !messageUrl) { + throw createHttpError('缺少推送地址 (messageUrl),请提供公网地址', { + statusCode: 400, + errorCode: 'missing_message_url', + }) + } + + const topics = Array.isArray(payload.topics) + ? payload.topics.join(',') + : String(payload.topics || config.handledTopics.join(',')).trim() + + const response = await configureDouyinMashidaMessage({ + status: disable ? 0 : 1, + messageUrl: disable ? undefined : messageUrl, + topics: disable ? undefined : topics, + config, + }) + + return { + ok: true, + status: disable ? 0 : 1, + messageUrl: disable ? '' : messageUrl, + topics: disable ? '' : topics, + gatewayResponse: response, + } +} + +/** 测试网关连通性与签名(使用真实凭据打 order.orderDetail 探测) */ +export async function testAdminDouyinMashidaGateway() { + const config = getDouyinMashidaSourceConfig() + if (!config.enabled) { + throw createHttpError('抖音码速达接入未启用,请先开启接入开关', { + statusCode: 409, + errorCode: 'douyin_mashida_disabled', + }) + } + if (!config.uid || !config.secret) { + throw createHttpError('抖音码速达缺少 uid 或用户秘钥,请先在面板中配置并保存', { + statusCode: 409, + errorCode: 'douyin_mashida_credentials_missing', + }) + } + + try { + await fetchDouyinMashidaOrderDetail({ + shopOrderId: '0', + config, + }) + return { + ok: true, + message: '网关连通且请求成功', + } + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error) + if (errorMsg.includes('签名') || errorMsg.includes('sign')) { + throw createHttpError(`签名验证失败: ${errorMsg}`, { + statusCode: 400, + errorCode: 'signature_failed', + }) + } + // 平台先验签名,出现非签名错误(如订单不存在)说明签名和网络完全正常 + return { + ok: true, + message: `网关连通 + 签名校验通过(平台返回业务响应: ${errorMsg})`, + } + } +} + function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) { return { enabled: config.enabled, @@ -82,3 +184,4 @@ export function isMaskedSecretValue(value: unknown, currentSecret = ''): boolean } return false } + diff --git a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts index f127dec9..87b3834f 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts @@ -146,6 +146,35 @@ export async function sendDouyinMashidaOrderMessage( }) } +/** 消息推送配置(向平台订阅/停用推送):对应开放平台 message.config */ +export async function configureDouyinMashidaMessage( + input: { + status: number + messageUrl?: string | undefined + topics?: string | undefined + } & DouyinMashidaCallOptions, +): Promise { + const params: Record = { + status: String(input.status), + } + if (input.messageUrl) { + params.message_url = input.messageUrl + } + if (input.topics) { + params.topics = input.topics + } + + const response = await douyinMashidaGatewayRequest({ + method: 'message.config', + params, + config: input.config, + timeoutMs: input.timeoutMs, + }) + + return isPlainObject(response) ? response : {} +} + function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' } + diff --git a/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx index 7dccdd6c..6f416bb7 100644 --- a/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx +++ b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx @@ -1,15 +1,30 @@ /** * Douyin Mashida Platform Admin Panel - * 抖音码速达接入配置面板 (凭据脱敏读写、配置完整度自检与本地地址误配提示) + * 抖音码速达接入配置面板 (自动直发凭据、H5领取链接、私信推送、平台订阅与网关自检) */ -import { ApiOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons' +import { + ApiOutlined, + CheckCircleOutlined, + CopyOutlined, + GlobalOutlined, + LinkOutlined, + ReloadOutlined, + SafetyCertificateOutlined, + SaveOutlined, + SendOutlined, + SettingOutlined, + StopOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' import { Alert, Badge, Button, Card, Col, + Collapse, + Divider, Input, InputNumber, Row, @@ -27,6 +42,8 @@ import { showError, showSuccess } from '@/lib/feedback' import { fetchAdminDouyinMashidaConfig, saveAdminDouyinMashidaConfig, + subscribeAdminDouyinMashidaPush, + testAdminDouyinMashidaGateway, } from '@/services/admin/platform-config' import type { AdminDouyinMashidaConfig } from '@/types/admin/platform-config' import { @@ -38,14 +55,14 @@ import { isLocalDevBaseUrl, } from '@/utils/douyin-mashida' -const { Text } = Typography +const { Text, Paragraph } = Typography /** 平台侧可订阅的消息类型(与后端 message_type 取值一致) */ const MESSAGE_TYPE_OPTIONS = [ - { value: 1, label: '1 - 订单状态' }, - { value: 2, label: '2 - 订单发货' }, - { value: 4, label: '4 - 图片' }, - { value: 5, label: '5 - 视频' }, + { value: 1, label: '1 - 订单状态通知' }, + { value: 2, label: '2 - 订单发货私信 (推荐)' }, + { value: 4, label: '4 - 图片消息' }, + { value: 5, label: '5 - 视频消息' }, ] /** 默认本地处理白名单,作为 Select 的候选标签 */ @@ -63,7 +80,7 @@ function createEmptyConfig(): AdminDouyinMashidaConfig { hasSecret: false, certLink: '', claimLinkBase: '', - messageTemplate: '', + messageTemplate: '您的订单已发货,请点击链接领取:{link}', claimMessageEnabled: true, afterVerifyLink: '', afterVerifyMessage: '您的订单已兑换成功', @@ -77,16 +94,16 @@ function createEmptyConfig(): AdminDouyinMashidaConfig { export function DouyinMashidaPanel() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [subscribing, setSubscribing] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null) const [config, setConfig] = useState(createEmptyConfig) useEffect(() => { void loadConfig() }, []) - /** - * Load Douyin Mashida config - * 读取抖音码速达接入配置(secret 出参恒为空串,不回显) - */ + /** 读取抖音码速达接入配置 */ async function loadConfig() { setLoading(true) try { @@ -101,10 +118,7 @@ export function DouyinMashidaPanel() { } } - /** - * Save config. Empty / masked secret keeps the stored value (backend semantics). - * 保存配置:secret 留空或保持掩码即「不修改」,不会被空串覆盖。 - */ + /** 保存配置 */ async function handleSave() { setSaving(true) try { @@ -125,6 +139,59 @@ export function DouyinMashidaPanel() { } } + /** 一键填入当前访问域名 */ + function handleFillCurrentOrigin() { + const origin = window.location.origin + setConfig((prev) => ({ + ...prev, + claimLinkBase: `${origin}/claim`, + certLink: `${origin}/redeem`, + afterVerifyLink: `${origin}/redeem`, + })) + showSuccess('已自动填入当前域名,请点击右上角「保存配置」以生效') + } + + /** 向平台订阅或停用推送 */ + async function handleSubscribe(disable = false) { + setSubscribing(true) + try { + const targetUrl = buildDouyinMashidaMessageUrl(window.location.origin) + await subscribeAdminDouyinMashidaPush({ + publicUrl: window.location.origin, + messageUrl: targetUrl, + topics: config.handledTopics, + disable, + }) + if (disable) { + showSuccess('已向开放平台发送停用推送指令') + } else { + showSuccess(`推送订阅成功!平台将向 ${targetUrl} 推送订单事件`) + } + } catch (error) { + showError(error instanceof Error ? error.message : (disable ? '停用推送失败' : '推送订阅失败')) + } finally { + setSubscribing(false) + } + } + + /** 测试网关连通性与 MD5 签名 */ + async function handleTestGateway() { + setTesting(true) + setTestResult(null) + try { + const response = await testAdminDouyinMashidaGateway() + const msg = response?.data?.message || '网关连通且签名验证通过' + setTestResult({ ok: true, message: msg }) + showSuccess('网关连通性测试通过') + } catch (error) { + const msg = error instanceof Error ? error.message : '网关连通性测试失败' + setTestResult({ ok: false, message: msg }) + showError(msg) + } finally { + setTesting(false) + } + } + function updateField( key: K, value: AdminDouyinMashidaConfig[K], @@ -157,15 +224,20 @@ export function DouyinMashidaPanel() { return ( - - - 抖音码速达接入配置 - + + +
+ + 抖音码速达配置 + + + 抖店开放平台 · 自动发货直充闭环 + +
} extra={ - + @@ -175,28 +247,91 @@ export function DouyinMashidaPanel() { } > - - - 接入状态: - - {config.ready ? '配置完整' : '待补齐'} - - - {config.enabled ? '接入开关已开启' : '接入开关已关闭'} - - } - /> - + + {/* 顶部状态与快速通知栏 */} + + + + + 接入就绪状态: + + {config.ready ? '✅ 配置就绪' : '⚠️ 待补齐凭据'} + + + {config.enabled ? '接入已开启' : '接入已停用'} + + } + /> + + + + + + + + + + + + + {/* 平台推送回调地址展示 */} +
+ + + 平台侧消息推送地址 (message_url): + + + {messageUrl} + + +
+
+ + {/* 告警提醒区 */} {!config.ready && missingFields.length > 0 ? ( ) : null} @@ -204,210 +339,261 @@ export function DouyinMashidaPanel() { ) : null} - - {messageUrl} - + {testResult ? ( + setTestResult(null)} + /> + ) : null} + + {/* 板块一:基础凭据 */} + + + 基础接入与商户凭据 +
} + > + + + + 接入开关 + + updateField('enabled', checked)} + /> + + {config.enabled ? '已启用' : '已停用'} + + + + 控制是否接收并处理码速达进单 + + + + + + + 抖店商户号 (uid) + updateField('uid', event.target.value)} + placeholder="抖店商户号/店铺ID,例如 305760014" + /> + + 平台分配的商户店铺 ID + + + + + + updateField('secret', value)} + placeholder="输入码速达平台分配的用户秘钥(留空保持原值)" + /> + + + + + 官方网关地址 (baseUrl) + updateField('baseUrl', event.target.value)} + placeholder={DEFAULT_BASE_URL} + /> + + 码速达 OpenAPI 官方生产地址:{DEFAULT_BASE_URL} + + + + +
+ + {/* 板块二:买家 H5 自动直发与私信配置 */} + + + 买家 H5 直充领取与私信发货 + + } + > + + + + 买家领取页基址 (claimLinkBase) + updateField('claimLinkBase', event.target.value)} + placeholder="https://你的公网域名/claim" + /> + {claimLinkPreview ? ( + + 直充链接示例:{claimLinkPreview} + + ) : ( + + 买家点进此链接输入游戏 UID 即可完成直充领取与自动核销 + + )} + {claimLinkWarning ? ( + + {claimLinkWarning} + + ) : null} + + + + + + 通用兑换链接 (certLink) + updateField('certLink', event.target.value)} + placeholder="https://你的公网域名/redeem" + /> + + 通用兜底兑换链接(无商品映射或旧单卡券兜底) + + + + + + + 付款后自动下发私信 + + updateField('claimMessageEnabled', checked)} + /> + + {config.claimMessageEnabled ? '自动发私信' : '不发私信'} + + + + 买家付款后自动将直充链接通过抖店私信发给买家 + + + + + + + 发货私信文案模板 (messageTemplate) + updateField('messageTemplate', event.target.value)} + placeholder="您的订单已发货,请点击链接领取:{link}" + /> + + + 支持占位符: + + {'{link}'} 领取链接 + {'{sku}'} 内部SKU + {'{orderNo}'} 订单号 + {'{productTitle}'} 商品标题 + + {messageTemplateWarning ? ( + + {messageTemplateWarning} + + ) : null} + + + + + + {/* 板块三:高级网关与事件过滤(折叠面板) */} + + + + 高级网关与事件过滤设置(可选) + + + ), + children: ( + + + + 私信消息类型 (messageType) + updateField('handledTopics', value)} + options={DEFAULT_HANDLED_TOPICS.map((topic) => ({ + value: topic, + label: topic, + }))} + placeholder="选择或输入要处理的 topic" + /> + + 白名单外事件仅留痕,不触发业务动作 + + + + + ), + }, + ]} /> - - - - - 接入开关 - - 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" - /> - - 通用兜底兑换链接(皮肤订单解析不出领取链接时才用它) - - - - - - - 领取链接基址 (claimLinkBase) - updateField('claimLinkBase', event.target.value)} - placeholder="https://你的域名/claim" - /> - {claimLinkPreview ? ( - - 皮肤订单最终链接:{claimLinkPreview} - - ) : ( - - 皮肤自动发货订单的领取页基址,留空则回退到上面的兑换链接 - - )} - {claimLinkWarning ? ( - - {claimLinkWarning} - - ) : null} - - - - - - 发消息模板 (messageTemplate) - updateField('messageTemplate', event.target.value)} - placeholder="您的订单已发货,请点击链接领取:{link}" - /> - - 占位符:{'{link}'} 领取链接、{'{sku}'} 内部SKU、{'{orderNo}'} 订单号、 - {'{productTitle}'} 商品标题;留空用默认文案 - - {messageTemplateWarning ? ( - - {messageTemplateWarning} - - ) : null} - - - - - - 付款后发领取消息 (claimMessageEnabled) - updateField('claimMessageEnabled', checked)} - /> - - 关闭后只把领取链接写进卡券,不发订单消息 - - - - - - - 去使用链接 (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 index 0e975fdc..76e0179e 100644 --- a/apps/frontend/src/services/admin/platform-config/douyin-mashida.ts +++ b/apps/frontend/src/services/admin/platform-config/douyin-mashida.ts @@ -13,3 +13,26 @@ export function fetchAdminDouyinMashidaConfig() { export function saveAdminDouyinMashidaConfig(payload: AdminDouyinMashidaConfig) { return apiPost('/api/v1/admin/platform-config/douyin-mashida', payload) } + +export function subscribeAdminDouyinMashidaPush(payload: { + publicUrl?: string + messageUrl?: string + topics?: string[] | string + disable?: boolean +}) { + return apiPost<{ + ok: boolean + status: number + messageUrl: string + topics: string + gatewayResponse?: unknown + }>('/api/v1/admin/platform-config/douyin-mashida/subscribe', payload) +} + +export function testAdminDouyinMashidaGateway() { + return apiPost<{ + ok: boolean + message: string + }>('/api/v1/admin/platform-config/douyin-mashida/test-gateway', {}) +} +