diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 3369d383..7afebb0c 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -1,5 +1,6 @@ -import { buildClaimUrl } from '../claim/claim-service.js' +import { buildClaimUrl, buildSkinClaimUrl } from '../claim/claim-service.js' import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js' +import { parseSkinClaimSubmission } from '../claim/claim-skin-service.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { listOrderItemsByOrderId, @@ -35,6 +36,8 @@ import { canRetryTaskStatus, } from '../../domain/task-status.js' import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js' +import { getDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js' +import { isDouyinMashidaSkinExecutor } from '../fulfillment/executors/types.js' import { mergeAffiliateDashSkuMapping, resolveAffiliateDashDisplayProductName, @@ -307,8 +310,11 @@ export async function getAdminTaskDetail( const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) const viewerContext = createAdminViewerContext(session) - // 统一对外领取入口为本站 claim 链接;平台 H5 仅作内部上下文 - const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' + // 统一对外领取入口为本站领取链接;平台 H5 仅作内部上下文。 + // ⚠️ 皮肤自动发货走**路径式**领取页(`/`): + // 全局 buildClaimUrl 用的是 orders.claimBaseUrl(默认 hash 形式 `…/#/claim`), + // 那是**打手**领取页;皮肤任务若用它,运营复制给买家会把人带进绑定 UID 的打手流程。 + const claimUrl = resolveAdminTaskClaimUrl(task, claimToken) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() const kuaishouCloudFulfillment = @@ -384,6 +390,11 @@ export async function getAdminTaskDetail( claimUrl, } : null, + // 皮肤自动发货:买家在领取页提交的游戏账号。运营要靠它发货,但属买家隐私, + // 因此与 claimToken 的明文一样按 canViewSensitiveTaskData 门禁下发。 + claimSubmission: viewerContext.canViewSensitiveTaskData + ? mapClaimSubmissionForAdmin(task) + : null, artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, screenshotUrl, review: { @@ -429,6 +440,51 @@ export async function getAdminTaskDetail( } } +/** + * 后台展示的领取链接。 + * + * - 皮肤自动发货(`douyin_mashida_skin`):`claimLinkBase` + token(路径式,买家领取页); + * 未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。 + * - 其它执行器:沿用全局 `buildClaimUrl`(hash 打手领取页),历史行为不变。 + */ +function resolveAdminTaskClaimUrl( + task: Pick, + claimToken: { token: string } | null | undefined, +): string { + const token = String(claimToken?.token || '').trim() + if (!token) { + return '' + } + + if (isDouyinMashidaSkinExecutor(task.executor_key)) { + const skinUrl = buildSkinClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token) + if (skinUrl) { + return skinUrl + } + } + + return buildClaimUrl(token) +} + +/** + * 皮肤自动发货买家提交内容(游戏账号)。 + * + * 只回传发货必需的字段;无提交(尚未领取)返回 null,前端据此区分「待领取」。 + * 敏感数据,调用方必须先判 `canViewSensitiveTaskData`。 + */ +function mapClaimSubmissionForAdmin(task: Partial): JsonRecord | null { + const submission = parseSkinClaimSubmission(task) + const gameAccount = String(submission.gameAccount || '').trim() + if (!gameAccount) { + return null + } + + return { + gameAccount, + submittedAt: String(submission.submittedAt || '').trim(), + } +} + function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null diff --git a/apps/backend/src/types/admin/task-detail.ts b/apps/backend/src/types/admin/task-detail.ts index b2f232cb..0526bbd0 100644 --- a/apps/backend/src/types/admin/task-detail.ts +++ b/apps/backend/src/types/admin/task-detail.ts @@ -12,6 +12,8 @@ export type AdminTaskDetailView = { order: JsonObject | null orderItem: JsonObject | null claimToken: JsonObject | null + /** 皮肤自动发货:买家在领取页提交的游戏账号(敏感数据,按后台角色门禁下发) */ + claimSubmission: JsonObject | null artifacts: JsonObject screenshotUrl: string review: JsonObject diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 04d5c4aa..c4aba25f 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -361,6 +361,33 @@ export default function AdminTaskDetailPage() { ) : null} + {/* 皮肤自动发货:买家在领取页提交的游戏账号是发货依据,单独成卡,避免被领取链接卡片挤掉 */} + {resolvedDetail.claimSubmission ? ( + + + + 买家在领取页填写的游戏账号(发货依据,属买家隐私,请勿外传) + + + + {resolvedDetail.claimSubmission.gameAccount} + + + 复制 + + + {resolvedDetail.claimSubmission.submittedAt ? ( + + 提交时间:{formatAdminDateTime(resolvedDetail.claimSubmission.submittedAt)} + + ) : null} + + + ) : null} + {resolvedDetail.task.executorKey === 'manual_dispatch' || resolvedDetail.operations.canCompleteManualDispatch || resolvedDetail.manualDispatch ? ( @@ -856,6 +883,9 @@ function resolveTaskExecutorDisplay(value: unknown) { if (executorKey === 'kuaishou_feifei') { return { label: '飞飞(已下线)', color: 'default' } } + if (executorKey === 'douyin_mashida_skin') { + return { label: '皮肤自动发货', color: 'magenta' } + } if (executorKey === 'manual_dispatch') { return { label: '人工履约', color: 'orange' } } diff --git a/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx index 239edae2..7dccdd6c 100644 --- a/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx +++ b/apps/frontend/src/pages/admin/platform/DouyinMashidaPanel.tsx @@ -30,7 +30,10 @@ import { } from '@/services/admin/platform-config' import type { AdminDouyinMashidaConfig } from '@/types/admin/platform-config' import { + buildDouyinMashidaClaimLinkPreview, buildDouyinMashidaMessageUrl, + describeClaimLinkBaseWarning, + describeMessageTemplateWarning, describeMissingConfigFields, isLocalDevBaseUrl, } from '@/utils/douyin-mashida' @@ -59,10 +62,13 @@ function createEmptyConfig(): AdminDouyinMashidaConfig { secretMasked: '', hasSecret: false, certLink: '', + claimLinkBase: '', + messageTemplate: '', + claimMessageEnabled: true, afterVerifyLink: '', afterVerifyMessage: '您的订单已兑换成功', messageType: 2, - timeoutMs: 10000, + timeoutMs: 20000, handledTopics: [...DEFAULT_HANDLED_TOPICS], ready: false, } @@ -144,6 +150,9 @@ export function DouyinMashidaPanel() { }) const isLocalDev = isLocalDevBaseUrl(config.baseUrl) const messageUrl = buildDouyinMashidaMessageUrl(window.location.origin) + const claimLinkPreview = buildDouyinMashidaClaimLinkPreview(config.claimLinkBase) + const claimLinkWarning = describeClaimLinkBaseWarning(config.claimLinkBase) + const messageTemplateWarning = describeMessageTemplateWarning(config.messageTemplate) return ( - 卡券里下发给买家的固定兑换链接 + 通用兜底兑换链接(皮肤订单解析不出领取链接时才用它) + + + + + + + 领取链接基址 (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)} + /> + + 关闭后只把领取链接写进卡券,不发订单消息 diff --git a/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts b/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts index a9a1081b..17086d39 100644 --- a/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts +++ b/apps/frontend/src/types/admin/platform-config/douyin-mashida.ts @@ -17,15 +17,21 @@ export interface AdminDouyinMashidaConfig { /** 脱敏后的秘钥,形如 abcd****wxyz */ secretMasked: string hasSecret: boolean - /** 卡券里的固定兑换链接,如 https://<域名>/redeem */ + /** 通用兜底兑换链接,如 https://<域名>/redeem(皮肤订单解析不出领取链接时才用它) */ certLink: string + /** 皮肤自动发货的领取页基址,如 https://<域名>/claim;最终链接 = 基址 + '/' + token */ + claimLinkBase: string + /** 付款后发给买家的领取消息模板,支持 {link}/{sku}/{orderNo}/{productTitle};留空用默认文案 */ + messageTemplate: string + /** 付款建单+发券后是否给买家发领取消息(默认 true) */ + claimMessageEnabled: boolean /** 兑换成功后发给买家的「去使用链接」 */ afterVerifyLink: string /** 兑换成功通知正文,默认「您的订单已兑换成功」 */ afterVerifyMessage: string /** 1-订单状态 2-订单发货 4-图片 5-视频 */ messageType: number - /** 出网超时(毫秒),默认 10000 */ + /** 出网超时(毫秒),默认 20000(平台业务错误要 ~10.5s 才返回) */ timeoutMs: number /** 本地处理白名单 topic */ handledTopics: string[] diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 485d0a41..87f33a46 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -101,6 +101,11 @@ export interface AdminTaskDetail { expiredAt: string claimUrl: string } + /** 皮肤自动发货:买家在领取页提交的游戏账号(无权限时后端下发 null) */ + claimSubmission: null | { + gameAccount: string + submittedAt: string + } artifacts: Record screenshotUrl: string review: { diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 5ab38ed5..66ef25f4 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -10,10 +10,14 @@ export const adminOrderProviderOptions = [ { label: '拼多多', value: 'pdd' }, { label: '91卡券', value: '91kaquan' }, { label: '快手', value: 'kuaishou' }, + // 抖音码速达:订单落在 orders.provider='douyin_mashida'(platform='doudian') + { label: '抖音码速达', value: 'douyin_mashida' }, ] export const adminOrderExecutorOptions = [ { label: '联盟发货', value: 'affiliate_dash' }, + // 抖音码速达皮肤类:建任务 + 生成领取链接,买家自己填账号领取 + { label: '皮肤自动发货', value: 'douyin_mashida_skin' }, { label: '行业凭证', value: 'kuaishou-industry' }, { label: '人工履约', value: 'manual_dispatch' }, ] diff --git a/apps/frontend/src/utils/douyin-mashida.ts b/apps/frontend/src/utils/douyin-mashida.ts index 3659a17e..5b99e54a 100644 --- a/apps/frontend/src/utils/douyin-mashida.ts +++ b/apps/frontend/src/utils/douyin-mashida.ts @@ -62,3 +62,53 @@ export function describeMissingConfigFields(config: MissingConfigCheck): string[ return missing } + +/** + * 面板预览用的示例 token(48 位 hex,与真实 claim token 形态一致)。 + */ +const CLAIM_LINK_PREVIEW_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' + +/** + * 拼一条领取链接预览:`claimLinkBase` 去尾斜杠 + '/' + token。 + * 规则与后端拼装一致(后端用配置里的 `claimLinkBase`)。 + * 基址为空返回空串(后端会回退到通用兑换链接)。 + */ +export function buildDouyinMashidaClaimLinkPreview( + claimLinkBase: string, + token = CLAIM_LINK_PREVIEW_TOKEN, +): string { + const base = String(claimLinkBase ?? '') + .trim() + .replace(/\/+$/, '') + if (!base) return '' + return `${base}/${String(token ?? '').trim()}` +} + +/** + * 领取链接基址的误配提示(无问题返回空串)。 + * + * 重点拦 `#`:`#/claim/{token}` 是**打手**领取页(要绑游戏 UID), + * 皮肤买家链接必须是路径式,配错会把买家带到打手流程里。 + */ +export function describeClaimLinkBaseWarning(claimLinkBase: string): string { + const text = String(claimLinkBase ?? '').trim() + if (!text) return '' + if (text.includes('#')) { + return '不要带 #:#/claim 是打手领取页,皮肤买家链接请用路径式,例如 https://你的域名/claim' + } + if (!/^https?:\/\//i.test(text)) { + return '请填完整地址(http:// 或 https:// 开头),例如 https://你的域名/claim' + } + return '' +} + +/** + * 发消息模板提示(无问题返回空串):模板里没有 {link} 就等于没把领取链接给买家。 + */ +export function describeMessageTemplateWarning(messageTemplate: string): string { + const text = String(messageTemplate ?? '').trim() + if (text && !text.includes('{link}')) { + return '模板里没有 {link} 占位符,买家将拿不到领取链接' + } + return '' +} diff --git a/apps/frontend/tests/douyin-mashida.test.ts b/apps/frontend/tests/douyin-mashida.test.ts index 183bd622..fbbfae4d 100644 --- a/apps/frontend/tests/douyin-mashida.test.ts +++ b/apps/frontend/tests/douyin-mashida.test.ts @@ -2,6 +2,9 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + buildDouyinMashidaClaimLinkPreview, + describeClaimLinkBaseWarning, + describeMessageTemplateWarning, buildDouyinMashidaMessageUrl, describeMissingConfigFields, isLocalDevBaseUrl, @@ -84,3 +87,42 @@ test('describeMissingConfigFields:缺单项只返回对应中文名', () => { ['接入开关', '兑换链接'], ) }) + +test('领取链接预览 = 基址去尾斜杠 + token', () => { + assert.equal( + buildDouyinMashidaClaimLinkPreview('https://x.ngrok-free.app/claim', 'tok123'), + 'https://x.ngrok-free.app/claim/tok123', + ) + // 尾斜杠与前后空格要容忍 + assert.equal( + buildDouyinMashidaClaimLinkPreview(' https://x.example.com/claim/ ', 'tok123'), + 'https://x.example.com/claim/tok123', + ) + // 裸域名也允许(后端统一补 /token) + assert.equal( + buildDouyinMashidaClaimLinkPreview('https://x.example.com', 'tok123'), + 'https://x.example.com/tok123', + ) + // 留空 → 不生成链接(后端回退到通用兑换链接) + assert.equal(buildDouyinMashidaClaimLinkPreview(''), '') + assert.equal(buildDouyinMashidaClaimLinkPreview(' '), '') +}) + +test('领取链接基址误配提示:拦 # 与缺协议', () => { + assert.equal(describeClaimLinkBaseWarning('https://x.example.com/claim'), '') + assert.equal(describeClaimLinkBaseWarning(''), '') + + // # 是打手领取页,必须拦(真实踩坑点) + const hashWarning = describeClaimLinkBaseWarning('https://x.example.com/#/claim') + assert.match(hashWarning, /#/) + assert.match(hashWarning, /打手/) + + const protocolWarning = describeClaimLinkBaseWarning('x.example.com/claim') + assert.match(protocolWarning, /https:\/\//) +}) + +test('发消息模板提示:没有 {link} 就等于没给买家链接', () => { + assert.equal(describeMessageTemplateWarning('您的订单已发货,请点击领取:{link}'), '') + assert.equal(describeMessageTemplateWarning(''), '') + assert.match(describeMessageTemplateWarning('您的订单已发货'), /\{link\}/) +})