feat(admin): 后台可配领取链接/发消息模板 + 可见买家账号 + 修正皮肤领取链接
**面板新增三项(抖音码速达接入配置)**
- 领取链接基址 (claimLinkBase):带实时预览「皮肤订单最终链接」,并拦截两类误配 ——
带 `#`(`#/claim` 是打手领取页,会把买家带进绑定 UID 流程)、缺 http(s) 协议
- 发消息模板 (messageTemplate):提示可用占位符;模板里没有 `{link}` 直接报错提示
- 付款后发领取消息开关 (claimMessageEnabled)
- 顺带把默认出网超时的展示值对齐后端(10s → 20s)
**后台可见买家提交的账号(运营发货依据)**
- `getAdminTaskDetail` 新增 `claimSubmission`,按 `canViewSensitiveTaskData` 门禁下发
(与 claimToken 明文同一门禁);任务详情新增「买家提交信息」卡片(可一键复制账号)
**修正:皮肤任务的领取链接**
- 详情接口原来用全局 `buildClaimUrl`(默认 hash 形式 `…/#/claim/…`),那是**打手**领取页;
皮肤任务改用 `claimLinkBase + '/' + token`,运营复制给买家的链接才是买家页
- 顺带把「履约渠道」筛选项加上「皮肤自动发货」、任务详情执行器标签加上它、
「来源渠道」加上「抖音码速达」(此前筛不出来抖音订单)
This commit is contained in:
@@ -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 仅作内部上下文。
|
||||
// ⚠️ 皮肤自动发货走**路径式**领取页(`<claimLinkBase>/<token>`):
|
||||
// 全局 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<TaskRow, 'executor_key'>,
|
||||
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<TaskRow>): 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -361,6 +361,33 @@ export default function AdminTaskDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* 皮肤自动发货:买家在领取页提交的游戏账号是发货依据,单独成卡,避免被领取链接卡片挤掉 */}
|
||||
{resolvedDetail.claimSubmission ? (
|
||||
<Card title="买家提交信息">
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text type="secondary">
|
||||
买家在领取页填写的游戏账号(发货依据,属买家隐私,请勿外传)
|
||||
</Typography.Text>
|
||||
<Space wrap size={8}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{resolvedDetail.claimSubmission.gameAccount}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
copyable={{ text: resolvedDetail.claimSubmission.gameAccount }}
|
||||
>
|
||||
复制
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{resolvedDetail.claimSubmission.submittedAt ? (
|
||||
<Typography.Text type="secondary">
|
||||
提交时间:{formatAdminDateTime(resolvedDetail.claimSubmission.submittedAt)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
) : 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' }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card
|
||||
@@ -274,7 +283,65 @@ export function DouyinMashidaPanel() {
|
||||
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>领取链接基址 (claimLinkBase)</Text>
|
||||
<Input
|
||||
value={config.claimLinkBase}
|
||||
onChange={(event) => updateField('claimLinkBase', event.target.value)}
|
||||
placeholder="https://你的域名/claim"
|
||||
/>
|
||||
{claimLinkPreview ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
皮肤订单最终链接:{claimLinkPreview}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
皮肤自动发货订单的领取页基址,留空则回退到上面的兑换链接
|
||||
</Text>
|
||||
)}
|
||||
{claimLinkWarning ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{claimLinkWarning}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>发消息模板 (messageTemplate)</Text>
|
||||
<Input
|
||||
value={config.messageTemplate}
|
||||
onChange={(event) => updateField('messageTemplate', event.target.value)}
|
||||
placeholder="您的订单已发货,请点击链接领取:{link}"
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
占位符:{'{link}'} 领取链接、{'{sku}'} 内部SKU、{'{orderNo}'} 订单号、
|
||||
{'{productTitle}'} 商品标题;留空用默认文案
|
||||
</Text>
|
||||
{messageTemplateWarning ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{messageTemplateWarning}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text strong>付款后发领取消息 (claimMessageEnabled)</Text>
|
||||
<Switch
|
||||
checked={config.claimMessageEnabled}
|
||||
onChange={(checked) => updateField('claimMessageEnabled', checked)}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
关闭后只把领取链接写进卡券,不发订单消息
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -101,6 +101,11 @@ export interface AdminTaskDetail {
|
||||
expiredAt: string
|
||||
claimUrl: string
|
||||
}
|
||||
/** 皮肤自动发货:买家在领取页提交的游戏账号(无权限时后端下发 null) */
|
||||
claimSubmission: null | {
|
||||
gameAccount: string
|
||||
submittedAt: string
|
||||
}
|
||||
artifacts: Record<string, unknown>
|
||||
screenshotUrl: string
|
||||
review: {
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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\}/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user