feat(douyin-mashida): 皮肤自动发货(按内部 SKU 分流 + 领取链接 + 买家领取接口)

抖音码速达的皮肤类商品改为「自动发货 + 领取链接」:买家不用输订单号,点链接直接领。

**分流(按内部 SKU)**
- 平台闸门放行 `provider='douyin_mashida' && platform='doudian'`,走**独立分支**
  (原有 91/快手路径一行未动,避免外溢)
- 判据:内部 SKU(`sku_order_list[].code`,即 order_items.sku_code)命中权威表
  `AFFILIATE_DASH_SKU_MAPPING` → 新执行器 `douyin_mashida_skin`;未命中 → `manual_dispatch`
- 皮肤判定与商品图同源(都用那张权威表),保证「能路由 ⇒ 有图」
- 履约路由新增 `internal_sku` 匹配维度(`rules[].internalSku`,NFKC+去空白+小写,exact/contains);
  旧规则缺 `matchField` 时默认 `product_name`,**归一化/优先级/回退顺序完全不变**

**执行器与领取 token**
- 新增 `douyin-mashida-skin-executor`:建 claim token、置 `link_generated/pending_claim`,
  `resolveDeliveryLink` 返回本站路径式领取链接;注册进 registry、planner 分支、
  bootstrap 种子 profile(`抖音皮肤自动发货`);进 `ROUTABLE_EXECUTOR_KEYS`
- token 粒度 = 一个履约单元一个(`claim_tokens.task_id` 唯一);一单多项时
  cert_link/买家消息取 task id 最小的那个并告警

**cert_link 与发消息**
- 皮肤订单且该订单项已有 token 且配了 `claimLinkBase` → `cert_link = claimLinkBase/token`;
  其它情况一律用 `certLink`(老行为不变)
- 领取链接**只认** `claimLinkBase`,绝不回退 `buildClaimUrl`/`orders.claimBaseUrl`
  (那是 hash 形式的打手领取页,语义不同)
- 付款建单+发券成功后按 `messageTemplate`(`{link}/{sku}/{orderNo}/{productTitle}`)
  经 `order.message` 发给买家;best-effort,失败只记 warn

**买家领取接口**
- `GET/POST /api/v1/claim-skin/:token`(限流、公开);详情含内部SKU/引流标题/商品图/
  数量/金额/支付时间/动态表单字段;token 失效给明确 errorCode(404/410/409),不打 500
- 提交账号:先核销平台券再落记录(避免「记账了但券没核销」的资损),已提交幂等

**隐私**
- 买家游戏账号落 `fulfillment_tasks.claim_submission_json`(迁移 096),
  **不进** `work_orders.material_json`(那是发给打手的素材);详情接口不返回券码明文/手机号/地址/open_id
This commit is contained in:
yml2213
2026-10-09 15:06:43 +08:00
parent ecc8ffaee4
commit 56a8184576
30 changed files with 1993 additions and 32 deletions
+3
View File
@@ -7,6 +7,7 @@ import affiliateDashRouter from './routes/affiliate-dash.js'
import collectRouter from './routes/collect.js'
import filesRouter from './routes/files.js'
import claimsRouter from './routes/claims.js'
import claimSkinRouter from './routes/claim-skin.js'
import douyinMashidaRouter from './routes/douyin-mashida.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
import open91Router from './routes/open-91.js'
@@ -110,6 +111,8 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use('/api/v1/open/91', open91Router)
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
app.use('/api/v1/redeem', redeemRouter)
// 皮肤领取页(路径式,公开):必须挂在 /api/v1/claim 之前,避免前缀歧义。
app.use('/api/v1/claim-skin', claimSkinRouter)
app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/collect', collectRouter)
app.use('/api/v1/pdd/extension', pddExtensionRouter)
@@ -0,0 +1,20 @@
-- 096_fulfillment_tasks_claim_submission.sql —— 皮肤自动发货「买家领取页」提交内容落库。
--
-- 背景:抖音码速达的皮肤类商品改为「自动发货 + 领取链接」,买家在 /claim-skin/:token
-- 提交游戏账号后,我们要把账号存下来交给运营发货(并防重复提交)。
--
-- 为什么单开一列而不是塞进 work_orders.material_json:
-- material_json 是发给打手的工单素材(还挂了搜索索引与统计),买家游戏账号属于隐私数据,
-- 写进去等于对外泄露(仓库既有隐私红线,见 douyin-mashida/order-mapper.ts 的 buyerWords 注释)。
-- fulfillment_tasks 只被履约/后台读取,工单侧(worker-platform)明确不 JOIN 该表,
-- 因此把账号留在任务自身上是最小暴露面的做法。
--
-- 幂等:全部 IF NOT EXISTS。
ALTER TABLE fulfillment_tasks
-- 买家领取页提交内容:{"gameAccount":"...","submittedAt":"...","source":"claim_skin"};
-- 空对象 {} 表示尚未提交(待领取)。
ADD COLUMN IF NOT EXISTS claim_submission_json JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN fulfillment_tasks.claim_submission_json IS
'买家领取页提交内容(gameAccount/submittedAt);隐私字段,禁止写入 work_orders.material_json';
+7 -5
View File
@@ -199,11 +199,12 @@ export async function updateTask(
attempt_count = $10,
last_error = $11,
context_json = $12::jsonb,
claimed_at = $13,
role_confirmed_at = $14,
redeemed_at = $15,
updated_at = $16
WHERE id = $17
claim_submission_json = $13::jsonb,
claimed_at = $14,
role_confirmed_at = $15,
redeemed_at = $16,
updated_at = $17
WHERE id = $18
`,
[
next.task_status,
@@ -218,6 +219,7 @@ export async function updateTask(
next.attempt_count || 0,
next.last_error || '',
next.context_json || '{}',
next.claim_submission_json || '{}',
next.claimed_at || null,
next.role_confirmed_at || null,
next.redeemed_at || null,
@@ -31,13 +31,16 @@ router.post(
action: 'platform_douyin_mashida_config_updated',
targetType: 'platform_config',
targetId: 'douyin_mashida',
// 审计只记摘要,绝不写密钥
// 审计只记摘要,绝不写密钥;消息模板正文属于运营文案,同样只记开关与是否配置
data: {
enabled: config.enabled !== false,
hasUid: Boolean(config.uid),
hasSecret: Boolean(config.hasSecret),
hasCertLink: Boolean(config.certLink),
hasAfterVerifyLink: Boolean(config.afterVerifyLink),
hasClaimLinkBase: Boolean(config.claimLinkBase),
hasMessageTemplate: Boolean(config.messageTemplate),
claimMessageEnabled: config.claimMessageEnabled !== false,
messageType: Number(config.messageType || 0),
handledTopicCount: Array.isArray(config.handledTopics) ? config.handledTopics.length : 0,
},
+55
View File
@@ -0,0 +1,55 @@
import { Router } from 'express'
import type { Request, Response } from 'express'
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
import { getSkinClaimDetail, submitSkinClaim } from '../services/claim/claim-skin-service.js'
import { buildMessageSuccessPayload, sendRouteError } from '../utils/http.js'
/**
* 抖音码速达「皮肤自动发货」买家领取页(公开接口,无需登录)。
*
* 响应契约(前端并行开发中):
* - 成功:`{ code: 0, message, data }`
* - 失败:仓库标准错误体 `{ code: 1, msg, message, errorCode, time, data: null }`,
* 前端按 `errorCode` 展示中文提示(`claim_token_not_found` / `claim_token_expired` /
* `claim_task_closed` / `claim_skin_task_invalid` / `claim_skin_game_account_*`)。
*
* 限流与 `routes/claims.ts` 同款:按客户端 IP + token 分桶。
*/
const router = Router()
const claimSkinReadRateLimit = createRateLimitMiddleware({
scope: 'claimSkin:read',
windowMs: 60_000,
max: 120,
key: getParamRateLimitKey('token'),
})
const claimSkinWriteRateLimit = createRateLimitMiddleware({
scope: 'claimSkin:write',
windowMs: 60_000,
max: 30,
key: getParamRateLimitKey('token'),
})
router.get('/:token', claimSkinReadRateLimit, async (req: Request, res: Response) => {
try {
const data = await getSkinClaimDetail(req.params.token)
res.json(buildMessageSuccessPayload(data, 'success'))
} catch (error) {
sendRouteError(res, error, '查询领取详情失败', '[claim-skin/:token]', { messageAlias: true })
}
})
router.post('/:token', claimSkinWriteRateLimit, async (req: Request, res: Response) => {
try {
const body = (req.body || {}) as Record<string, unknown>
const data = await submitSkinClaim(req.params.token, { gameAccount: body.gameAccount })
res.json(buildMessageSuccessPayload(data, '领取成功'))
} catch (error) {
sendRouteError(res, error, '领取失败,请稍后重试', '[claim-skin/:token]', {
messageAlias: true,
})
}
})
export default router
@@ -41,12 +41,21 @@ function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) {
secretMasked: maskSecret(config.secret),
hasSecret: Boolean(config.secret),
certLink: config.certLink,
/** 皮肤自动发货领取页基址(路径式,如 https://x.ngrok-free.app/claim) */
claimLinkBase: config.claimLinkBase,
/** 皮肤自动发货「付款后发消息」模板,占位符 {link}/{sku}/{orderNo}/{productTitle} */
messageTemplate: config.messageTemplate,
/** 皮肤自动发货「付款后发消息」开关 */
claimMessageEnabled: config.claimMessageEnabled,
messageType: config.messageType,
afterVerifyLink: config.afterVerifyLink,
afterVerifyMessage: config.afterVerifyMessage,
timeoutMs: config.timeoutMs,
handledTopics: config.handledTopics,
/** 联调自查:uid / secret / 兑换链接 三项都齐了才算配置完整 */
/**
* 联调自查:uid / secret / 兑换链接 三项都齐了才算配置完整。
* 语义保持不变(不因皮肤新字段变化),避免影响既有脚本与面板判断。
*/
ready: Boolean(config.enabled && config.uid && config.secret && config.certLink),
}
}
@@ -5,6 +5,7 @@ import {
import { updateTask } from '../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createTaskClaimToken, buildClaimUrl } from '../../claim/claim-service.js'
import { resolveDouyinMashidaSkinClaimUrl } from '../../platforms/douyin-mashida/claim-link.js'
import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js'
import { preparePaidFulfillmentTask } from '../../fulfillment/executors/registry.js'
import {
@@ -12,7 +13,10 @@ import {
canRegenerateClaimLinkStatus,
TASK_STATUS,
} from '../../../domain/task-status.js'
import { isRetiredFulfillmentExecutor } from '../../fulfillment/executors/types.js'
import {
isDouyinMashidaSkinExecutor,
isRetiredFulfillmentExecutor,
} from '../../fulfillment/executors/types.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import {
@@ -222,13 +226,25 @@ export async function retryAdminTask(
}
if (claimToken) {
response.claimUrl = buildClaimUrl(claimToken)
response.claimUrl = resolveAdminTaskClaimUrl(effectiveTask, claimToken)
response.token = claimToken
}
return response
}
/**
* 后台回显的领取链接口径:
* - 皮肤自动发货任务:路径式皮肤领取链接(`claimLinkBase` + token),**不能**用 hash 版通用领取页;
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
*/
function resolveAdminTaskClaimUrl(task: { executor_key?: unknown }, token: string): string {
if (isDouyinMashidaSkinExecutor(task.executor_key)) {
return resolveDouyinMashidaSkinClaimUrl(token)
}
return buildClaimUrl(token)
}
export async function regenerateAdminTaskClaimLink(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
@@ -307,7 +323,7 @@ export async function regenerateAdminTaskClaimLink(
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimToken.claimUrl,
claimUrl: resolveAdminTaskClaimUrl(updatedTask, claimToken.token),
token: claimToken.token,
}
}
@@ -36,6 +36,17 @@ const CORE_PROFILES: CoreProfile[] = [
inventoryStrategy: 'external_platform',
requirements: [],
},
{
// 抖音码速达皮肤自动发货:不打外部平台,只发本站领取链接,等买家提交游戏账号后人工发货。
// profile_key 与 executor_key 同名,planner 按 executorKey 取 profile。
profileKey: 'douyin_mashida_skin',
name: '抖音皮肤自动发货',
executorKey: 'douyin_mashida_skin',
requiresClaim: true,
autoDispatch: false,
inventoryStrategy: 'static_pool',
requirements: [],
},
]
export async function ensureFulfillmentCatalogBootstrapped() {
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveClaimTokenExpiration } from './claim-service.js'
import { resolveClaimTokenExpiration, buildSkinClaimUrl } from './claim-service.js'
test('领取链接默认可配置为长期有效', () => {
assert.equal(resolveClaimTokenExpiration('2026-08-11T08:00:00.000Z', 0), null)
@@ -13,3 +13,25 @@ test('领取链接保留正整数小时的限时配置能力', () => {
'2026-08-12T08:00:00.000Z',
)
})
test('皮肤领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app/claim', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app', 'AbC123'),
'https://x.ngrok-free.app/AbC123',
)
})
test('皮肤领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => {
assert.equal(buildSkinClaimUrl('', 'AbC123'), '')
assert.equal(buildSkinClaimUrl(undefined, 'AbC123'), '')
assert.equal(buildSkinClaimUrl('https://x/claim', ''), '')
assert.equal(buildSkinClaimUrl('https://x/claim', null), '')
})
@@ -49,3 +49,23 @@ export function buildClaimUrl(token: string): string {
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
}
/**
* 「皮肤自动发货」领取链接:`${领取页基址去尾斜杠}/${token}`。
*
* ⚠️ 与 `buildClaimUrl` 刻意分开,绝不互相回退:
* `buildClaimUrl` 读 `runtimeConfig.orders.claimBaseUrl`,默认值是**hash 形式**的打手领取页
* (`http://127.0.0.1:5173/#/claim`,会走游戏 UID 绑定那套流程);皮肤买家页是**路径式**独立页面
* (`/claim/:token`),基址只能来自抖音配置的 `claimLinkBase`。
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
*/
export function buildSkinClaimUrl(baseUrl: unknown, token: unknown): string {
const base = String(baseUrl || '')
.trim()
.replace(/\/+$/, '')
const normalizedToken = String(token || '').trim()
if (!base || !normalizedToken) {
return ''
}
return `${base}/${normalizedToken}`
}
@@ -0,0 +1,249 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
assertValidSkinGameAccount,
buildSkinClaimDetailPayload,
normalizeSkinGameAccount,
parseSkinClaimSubmission,
resolveSkinClaimOrderNo,
resolveSkinClaimStatus,
resolveSkinClaimTokenStatus,
SKIN_CLAIM_FIELDS,
SKIN_GAME_ACCOUNT_MAX_LENGTH,
} from './claim-skin-service.js'
import { buildMessageSuccessPayload } from '../../utils/http.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
function createOrder(overrides: Partial<OrderRow> = {}): OrderRow {
return {
id: 1,
provider: 'douyin_mashida',
platform: 'doudian',
shop_id: 'shop-1',
shop_name: '测试小店',
platform_order_id: '6930318258716179555',
order_status: 'paid',
pay_status: 'paid',
buyer_id: 'open-id-should-not-leak',
buyer_name: '买家张三',
receiver_contact: '13800000000',
total_amount: 100,
currency: 'CNY',
raw_payload_json: {},
paid_at: '2026-10-09T05:18:20.000Z',
created_at: '2026-10-09T05:18:20.000Z',
updated_at: '2026-10-09T05:18:20.000Z',
...overrides,
}
}
function createOrderItem(overrides: Partial<OrderItemRow> = {}): OrderItemRow {
return {
id: 11,
order_id: 1,
sku_code: '幸运币2个',
sku_name: DOUYIN_SKIN_TITLE,
quantity: 1,
spec_json: {},
item_snapshot_json: {
douyinSubOrderId: '6930318258716179555',
code: '幸运币2个',
productName: DOUYIN_SKIN_TITLE,
},
...overrides,
}
}
function createTask(overrides: Partial<TaskRow> = {}): Partial<TaskRow> {
return {
id: 9,
executor_key: 'douyin_mashida_skin',
task_status: 'link_generated',
user_action_status: 'pending_claim',
claim_submission_json: {},
...overrides,
}
}
test('领取页详情严格贴合前端契约字段', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
assert.deepEqual(payload, {
tokenStatus: 'active',
flowType: 'douyin_mashida_skin',
orderNo: '6930318258716179555',
productName: '幸运币2个',
productTitle: DOUYIN_SKIN_TITLE,
imageUrl: '/images/products/lucky_coin_x2.webp',
quantity: 1,
amountFen: 100,
paidAt: '2026-10-09T05:18:20.000Z',
status: 'pending_claim',
claimFields: [
{
key: 'gameAccount',
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
],
submittedGameAccount: '',
})
})
test('领取页详情不泄露券码/买家手机号/地址/open_id', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
const serialized = JSON.stringify(payload)
assert.equal(serialized.includes('open-id-should-not-leak'), false)
assert.equal(serialized.includes('13800000000'), false)
assert.equal(serialized.includes('买家张三'), false)
assert.equal(serialized.includes('cert'), false)
})
test('paidAt 经响应归一化后是北京时间字符串(前端直接展示)', () => {
const response = buildMessageSuccessPayload(
buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
}),
)
assert.equal(response.code, 0)
assert.equal(response.message, 'success')
assert.equal((response.data as { paidAt: string }).paidAt, '2026-10-09 13:18:20')
})
test('已提交的任务返回 submitted + 买家填写的账号(token 显示 used 但仍可回看)', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: {
id: 1,
token: 'AbC123',
// 提交后 token 保持 status=active(否则 getClaimContext 会拦截),只标记 used_at/used_count
status: 'active',
expired_at: null,
used_at: '2026-10-10T00:00:00.000Z',
used_count: 1,
max_use_count: 1,
},
task: createTask({
task_status: 'claimed',
claim_submission_json: { gameAccount: '123456', submittedAt: '2026-10-10T00:00:00.000Z' },
}),
order: createOrder(),
orderItem: createOrderItem(),
})
assert.equal(payload.status, 'submitted')
assert.equal(payload.submittedGameAccount, '123456')
assert.equal(payload.tokenStatus, 'used')
})
test('resolveSkinClaimTokenStatus 覆盖 active/closed/revoked/expired/used', () => {
const now = Date.parse('2026-10-10T00:00:00.000Z')
assert.equal(resolveSkinClaimTokenStatus({ status: 'active', expired_at: null }, now), 'active')
assert.equal(resolveSkinClaimTokenStatus({ status: 'closed' }, now), 'closed')
assert.equal(resolveSkinClaimTokenStatus({ status: 'revoked' }, now), 'revoked')
assert.equal(resolveSkinClaimTokenStatus({ status: 'expired' }, now), 'expired')
assert.equal(
resolveSkinClaimTokenStatus({ status: 'active', expired_at: '2026-10-09T00:00:00.000Z' }, now),
'expired',
)
assert.equal(
resolveSkinClaimTokenStatus({ status: 'active', used_at: '2026-10-09T00:00:00.000Z' }, now),
'used',
)
assert.equal(
resolveSkinClaimTokenStatus(
{ status: 'active', expired_at: null, max_use_count: 1, used_count: 1 },
now,
),
'used',
)
})
test('parseSkinClaimSubmission 兼容 JSONB 对象与字符串,坏数据不抛错', () => {
assert.deepEqual(
parseSkinClaimSubmission({ claim_submission_json: { gameAccount: ' 123 ', submittedAt: 't' } }),
{ gameAccount: '123', submittedAt: 't' },
)
assert.deepEqual(parseSkinClaimSubmission({ claim_submission_json: '{"gameAccount":"abc"}' }), {
gameAccount: 'abc',
submittedAt: '',
})
assert.deepEqual(parseSkinClaimSubmission({ claim_submission_json: '{invalid' }), {
gameAccount: '',
submittedAt: '',
})
assert.deepEqual(parseSkinClaimSubmission(null), { gameAccount: '', submittedAt: '' })
})
test('resolveSkinClaimStatus 以提交记录为准,claimed 状态也算已领取', () => {
assert.equal(resolveSkinClaimStatus(createTask()), 'pending_claim')
assert.equal(resolveSkinClaimStatus(createTask({ task_status: 'claimed' })), 'submitted')
assert.equal(
resolveSkinClaimStatus(createTask({ claim_submission_json: { gameAccount: '123' } })),
'submitted',
)
})
test('游戏账号归一化与校验', () => {
assert.equal(normalizeSkinGameAccount(' 123456\u200B '), '123456')
assert.equal(assertValidSkinGameAccount(' 123456 '), '123456')
assert.throws(
() => assertValidSkinGameAccount(' '),
(error: { errorCode?: string; statusCode?: number }) =>
error.errorCode === 'claim_skin_game_account_required' && error.statusCode === 400,
)
const tooLong = 'a'.repeat(SKIN_GAME_ACCOUNT_MAX_LENGTH + 1)
assert.throws(
() => assertValidSkinGameAccount(tooLong),
(error: { errorCode?: string; statusCode?: number }) =>
error.errorCode === 'claim_skin_game_account_too_long' && error.statusCode === 400,
)
assert.equal('a'.repeat(SKIN_GAME_ACCOUNT_MAX_LENGTH).length, SKIN_GAME_ACCOUNT_MAX_LENGTH)
})
test('resolveSkinClaimOrderNo 子订单号优先、父单兜底', () => {
assert.equal(resolveSkinClaimOrderNo(createOrder(), createOrderItem()), '6930318258716179555')
assert.equal(
resolveSkinClaimOrderNo(
createOrder({ platform_order_id: 'parent-1' }),
createOrderItem({ item_snapshot_json: {} }),
),
'parent-1',
)
})
test('claimFields 每次都是副本,调用方修改不会污染常量', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
const firstField = payload.claimFields[0]
assert.ok(firstField)
firstField.label = '被改了'
assert.equal(SKIN_CLAIM_FIELDS[0]?.label, '游戏账号 / 角色ID')
})
@@ -0,0 +1,324 @@
import { TASK_STATUS } from '../../domain/task-status.js'
import { updateClaimToken } from '../../repositories/claim-token-repo.js'
import { updateTask } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { normalizeFen } from '../../utils/money.js'
import { nowIso } from '../../utils/time.js'
import { getClaimContext } from './kuaishou-cloud-claim-context.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import { FULFILLMENT_EXECUTOR_KEYS } from '../fulfillment/executors/types.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
import { extractDouyinMashidaSubOrderId } from '../platforms/douyin-mashida/order-mapper.js'
import { verifyCertificateForOrderItem } from '../redeem/certificate-service.js'
/**
* 抖音码速达「皮肤自动发货」买家领取页(公开接口,无需登录)。
*
* 契约(前端并行开发中,字段名不可改):
* - `GET /api/v1/claim-skin/:token`
* - `POST /api/v1/claim-skin/:token` body `{ gameAccount }`
*
* 隐私红线:页面只暴露脱敏信息 —— 内部 SKU、引流标题、商品图、金额、支付时间、买家自己填的账号;
* **不返回**券码明文、买家手机号/地址/open_id,也不把账号写进 work_orders.material_json
* (落 `fulfillment_tasks.claim_submission_json`,见迁移 096)。
*/
export const SKIN_CLAIM_FLOW_TYPE = 'douyin_mashida_skin'
export const SKIN_GAME_ACCOUNT_FIELD_KEY = 'gameAccount'
export const SKIN_GAME_ACCOUNT_MAX_LENGTH = 64
export const SKIN_CLAIM_SUCCESS_MESSAGE = '领取成功,我们会尽快把物品发到你的账号'
export type SkinClaimTokenStatus = 'active' | 'closed' | 'revoked' | 'expired' | 'used'
export type SkinClaimStatus = 'pending_claim' | 'submitted'
/** claim_tokens 的完整行(公共 Row 类型未列 used_* 字段,这里按仓储实际列补齐)。 */
export type SkinClaimTokenLike = Partial<ClaimTokenRow> & {
used_at?: string | null
max_use_count?: number | null
used_count?: number | null
}
export type SkinClaimField = {
key: string
label: string
placeholder: string
required: boolean
}
/** 领取页需要买家填的字段(目前只有游戏账号;前端按此渲染表单)。 */
export const SKIN_CLAIM_FIELDS: SkinClaimField[] = [
{
key: SKIN_GAME_ACCOUNT_FIELD_KEY,
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
]
export type SkinClaimSubmission = {
gameAccount: string
submittedAt: string
}
export type SkinClaimDetailPayload = {
tokenStatus: SkinClaimTokenStatus
flowType: typeof SKIN_CLAIM_FLOW_TYPE
orderNo: string
productName: string
productTitle: string
imageUrl: string
quantity: number
amountFen: number
paidAt: string | null
status: SkinClaimStatus
claimFields: SkinClaimField[]
submittedGameAccount: string
}
export type SkinClaimSubmitPayload = {
status: SkinClaimStatus
message: string
}
/** token 状态归一化:DB 里的状态 + 过期/用尽信息 → 契约里的 5 个值。 */
export function resolveSkinClaimTokenStatus(
claimToken: SkinClaimTokenLike | null | undefined,
nowMs: number = Date.now(),
): SkinClaimTokenStatus {
const status = String(claimToken?.status || '').trim()
if (status === 'closed' || status === 'revoked' || status === 'used') {
return status
}
if (status === 'expired') {
return 'expired'
}
const expiredAt = claimToken?.expired_at ? Date.parse(String(claimToken.expired_at)) : NaN
if (Number.isFinite(expiredAt) && expiredAt <= nowMs) {
return 'expired'
}
const usedAt = String(claimToken?.used_at || '').trim()
const maxUseCount = Number(claimToken?.max_use_count || 0)
const usedCount = Number(claimToken?.used_count || 0)
if (usedAt || (maxUseCount > 0 && usedCount >= maxUseCount)) {
return 'used'
}
return 'active'
}
/** 从任务的 claim_submission_json 读取买家提交内容(兼容 PG 返回对象或字符串)。 */
export function parseSkinClaimSubmission(
task: Partial<TaskRow> | null | undefined,
): SkinClaimSubmission {
const raw = task?.claim_submission_json
let parsed: unknown = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
parsed = null
}
}
if (Object.prototype.toString.call(parsed) !== '[object Object]') {
return { gameAccount: '', submittedAt: '' }
}
const record = parsed as Record<string, unknown>
return {
gameAccount: String(record.gameAccount || '').trim(),
submittedAt: String(record.submittedAt || '').trim(),
}
}
/** 待领取 / 已领取:以「任务上是否已有提交记录」为准,保证重复提交幂等。 */
export function resolveSkinClaimStatus(task: Partial<TaskRow> | null | undefined): SkinClaimStatus {
const submission = parseSkinClaimSubmission(task)
if (submission.gameAccount || submission.submittedAt) {
return 'submitted'
}
const taskStatus = String(task?.task_status || '').trim()
if (
taskStatus === TASK_STATUS.CLAIMED ||
taskStatus === TASK_STATUS.REDEEMED ||
taskStatus === TASK_STATUS.COMPLETED
) {
return 'submitted'
}
return 'pending_claim'
}
/** 游戏账号归一化:去掉首尾空白与不可见字符。 */
export function normalizeSkinGameAccount(value: unknown): string {
return String(value ?? '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
}
/** 校验并返回可入库的游戏账号;不合法直接抛 4xx(不产生 500)。 */
export function assertValidSkinGameAccount(value: unknown): string {
const gameAccount = normalizeSkinGameAccount(value)
if (!gameAccount) {
throw createHttpError('请填写游戏账号(角色ID)', {
statusCode: 400,
errorCode: 'claim_skin_game_account_required',
})
}
if (gameAccount.length > SKIN_GAME_ACCOUNT_MAX_LENGTH) {
throw createHttpError(`游戏账号过长(最多 ${SKIN_GAME_ACCOUNT_MAX_LENGTH} 个字符)`, {
statusCode: 400,
errorCode: 'claim_skin_game_account_too_long',
context: { length: gameAccount.length, maxLength: SKIN_GAME_ACCOUNT_MAX_LENGTH },
})
}
return gameAccount
}
/** 订单号口径与回执一致:子订单号优先、父单兜底。 */
export function resolveSkinClaimOrderNo(order: OrderRow, orderItem: OrderItemRow): string {
const subOrderId = extractDouyinMashidaSubOrderId(orderItem.item_snapshot_json)
return String(subOrderId || order.platform_order_id || '').trim()
}
/** 纯函数:把 token/任务/订单拼成领取页详情(便于单测,不碰数据库)。 */
export function buildSkinClaimDetailPayload(input: {
claimToken: SkinClaimTokenLike
task: Partial<TaskRow>
order: OrderRow
orderItem: OrderItemRow
nowMs?: number
}): SkinClaimDetailPayload {
const { claimToken, task, order, orderItem } = input
const submission = parseSkinClaimSubmission(task)
const productName = String(orderItem.sku_code || orderItem.sku_name || '').trim()
const productTitle = String(orderItem.sku_name || productName).trim()
return {
tokenStatus: resolveSkinClaimTokenStatus(claimToken, input.nowMs),
flowType: SKIN_CLAIM_FLOW_TYPE,
orderNo: resolveSkinClaimOrderNo(order, orderItem),
productName,
productTitle,
imageUrl: resolveProductImageUrl(productName),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
amountFen: normalizeFen(order.total_amount),
paidAt: order.paid_at || null,
status: resolveSkinClaimStatus(task),
claimFields: SKIN_CLAIM_FIELDS.map((field) => ({ ...field })),
submittedGameAccount: submission.gameAccount,
}
}
function assertSkinTask(task: Partial<TaskRow>): TaskRow {
if (String(task.executor_key || '').trim() !== FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN) {
throw createHttpError('该领取链接不是皮肤自动发货订单', {
statusCode: 409,
errorCode: 'claim_skin_task_invalid',
context: { taskId: task.id, executorKey: task.executor_key || '' },
})
}
return task as TaskRow
}
/** `GET /api/v1/claim-skin/:token`:领取页详情(token 失效/过期/关闭由 getClaimContext 抛明确 4xx)。 */
export async function getSkinClaimDetail(token: unknown): Promise<SkinClaimDetailPayload> {
const context = await getClaimContext(token)
assertSkinTask(context.task)
return buildSkinClaimDetailPayload({
claimToken: context.claimToken,
task: context.task,
order: context.order,
orderItem: context.orderItem,
})
}
/**
* `POST /api/v1/claim-skin/:token`:提交游戏账号 + 核销平台券。
*
* 幂等:已有提交记录直接返回「已领取」,不重复核销、不报错。
*/
export async function submitSkinClaim(
token: unknown,
payload: { gameAccount?: unknown } = {},
): Promise<SkinClaimSubmitPayload> {
const context = await getClaimContext(token)
const task = assertSkinTask(context.task)
if (parseSkinClaimSubmission(task).gameAccount) {
logIntegration('[claim-skin]', '重复提交已忽略(任务已领取)', {
taskId: task.id,
platformOrderId: task.platform_order_id,
})
return { status: 'submitted', message: SKIN_CLAIM_SUCCESS_MESSAGE }
}
const gameAccount = assertValidSkinGameAccount(payload.gameAccount)
// 先核销平台券(verifyCertificate 自带幂等:本地已核销直接返回);
// 核销失败不落提交记录,买家可重试,避免「我们记了已领取但平台券没核销」的资损。
await verifyCertificateForOrderItem({ order: context.order, orderItem: context.orderItem })
const now = nowIso()
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.CLAIMED,
user_action_status: 'not_required',
claimed_at: task.claimed_at || now,
// 买家账号只落任务自身字段(隐私红线:不进 work_orders.material_json)
claim_submission_json: {
gameAccount,
submittedAt: now,
source: 'claim_skin',
},
last_error: '',
updated_at: now,
})
if (!updatedTask) {
throw createHttpError('领取结果保存失败,请稍后重试', {
statusCode: 500,
errorCode: 'claim_skin_submit_failed',
context: { taskId: task.id },
})
}
logIntegration('[claim-skin]', '皮肤订单已领取(游戏账号已提交,平台券已核销)', {
taskId: task.id,
platformOrderId: task.platform_order_id,
orderId: context.order.id,
orderItemId: context.orderItem.id,
})
// 把 token 标记为「已使用」(used_at/used_count),领取页据此显示 used 状态。
// ⚠️ 刻意**不改 status**(保持 active):`getClaimContext` 会拦截非 active 的 token,
// 改成 used 会让买家重开链接看到「链接不可用」,破坏「重复打开/重复提交幂等」。
// 幂等读已成功落库,这里失败只记 warn,不把已完成的领取回报成失败。
await updateClaimToken(context.claimToken.id, {
used_at: now,
used_count: 1,
updated_at: now,
}).catch((error) => {
logIntegration(
'[claim-skin]',
'标记领取 token 已使用失败(领取结果已保存)',
{
taskId: task.id,
claimTokenId: context.claimToken.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
})
return { status: 'submitted', message: SKIN_CLAIM_SUCCESS_MESSAGE }
}
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
douyinMashidaSkinExecutor,
resolveSkinTaskClaimToken,
} from './douyin-mashida-skin-executor.js'
import { getFulfillmentExecutor } from './registry.js'
import type { FulfillmentPrepareDeps } from './types.js'
import type { TaskRow } from '../../../types/repository/rows.js'
const FIXED_NOW = '2026-10-10T00:00:00.000Z'
function createTask(overrides: Partial<TaskRow> = {}): TaskRow {
return {
id: 9,
order_id: 1,
order_item_id: 11,
unit_index: 1,
platform_order_id: '6930318258716179555',
profile_id: 3,
task_no: 'DT-1',
executor_key: 'douyin_mashida_skin',
task_status: 'paid',
delivery_status: 'pending',
result_code: '',
result_message: '',
automation_mode: 'manual',
requires_claim: true,
user_action_status: 'pending_claim',
attempt_count: 0,
runtime_session_id: '',
login_type: '',
nickname: '',
role_name: '',
role_id: '',
area: '',
partition_name: '',
claim_token: '',
claim_expires_at: null,
primary_claim_token: '',
primary_claim_token_id: null,
primary_claim_token_status: '',
primary_claim_expires_at: null,
artifacts_json: {},
context_json: {},
screenshot_path: '',
last_error: '',
retry_count: 0,
created_at: FIXED_NOW,
updated_at: FIXED_NOW,
claimed_at: null,
role_confirmed_at: null,
redeemed_at: null,
...overrides,
}
}
function createDeps(overrides: Partial<FulfillmentPrepareDeps> = {}) {
const updates: Array<{ taskId: number | string; patch: Record<string, unknown> }> = []
const tokenRequests: Array<number | string> = []
const deps: FulfillmentPrepareDeps = {
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch: { ...patch } })
return { ...createTask(), ...patch }
},
createTaskClaimToken: async (taskId) => {
tokenRequests.push(taskId)
return { token: 'skin-token', expired_at: null }
},
notifyTaskAutoManualReview: async () => null,
nowIso: () => FIXED_NOW,
...overrides,
}
return { deps, updates, tokenRequests }
}
test('皮肤执行器已注册到 registry', () => {
assert.equal(getFulfillmentExecutor('douyin_mashida_skin'), douyinMashidaSkinExecutor)
})
test('preparePaidTask:无 token 时生成 claim token 并置为待领取', async () => {
const { deps, updates, tokenRequests } = createDeps()
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(createTask(), deps)
assert.deepEqual(tokenRequests, [9])
assert.equal(updates.length, 2)
assert.deepEqual(updates[0]?.patch, {
claim_token: 'skin-token',
claim_expires_at: null,
updated_at: FIXED_NOW,
})
assert.deepEqual(updates[1]?.patch, {
task_status: 'link_generated',
user_action_status: 'pending_claim',
last_error: '',
updated_at: FIXED_NOW,
})
assert.equal(result?.task_status, 'link_generated')
})
test('preparePaidTask:已有 token 且已是待领取时完全幂等(不重复建 token)', async () => {
const { deps, updates, tokenRequests } = createDeps()
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(
createTask({
claim_token: 'skin-token',
primary_claim_token: 'skin-token',
primary_claim_token_id: 5,
task_status: 'link_generated',
user_action_status: 'pending_claim',
}),
deps,
)
assert.deepEqual(tokenRequests, [])
assert.deepEqual(updates, [])
assert.equal(result?.task_status, 'link_generated')
})
test('preparePaidTask:已有 token 但状态未就绪时只补状态', async () => {
const { deps, updates, tokenRequests } = createDeps()
await douyinMashidaSkinExecutor.preparePaidTask?.(
createTask({ claim_token: 'skin-token', primary_claim_token: 'skin-token' }),
deps,
)
assert.deepEqual(tokenRequests, [])
assert.equal(updates.length, 1)
assert.equal(updates[0]?.patch.task_status, 'link_generated')
})
test('preparePaidTask:买家已领取的任务不会被降级回待领取', async () => {
const { deps, updates, tokenRequests } = createDeps()
const claimed = createTask({
claim_token: 'skin-token',
primary_claim_token: 'skin-token',
task_status: 'claimed',
user_action_status: 'not_required',
})
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(claimed, deps)
assert.deepEqual(tokenRequests, [])
assert.deepEqual(updates, [])
assert.equal(result?.task_status, 'claimed')
})
test('resolveSkinTaskClaimToken 优先 primary_claim_token', () => {
assert.equal(resolveSkinTaskClaimToken({ claim_token: 'a' }), 'a')
assert.equal(resolveSkinTaskClaimToken({ claim_token: 'a', primary_claim_token: 'b' }), 'b')
assert.equal(resolveSkinTaskClaimToken(null), '')
})
@@ -0,0 +1,134 @@
import { TASK_STATUS } from '../../../domain/task-status.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { logWarn } from '../../../utils/logger.js'
import { resolveDouyinMashidaSkinClaimUrl } from '../../platforms/douyin-mashida/claim-link.js'
import type { DouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
type FulfillmentDeliveryLink,
type FulfillmentExecutor,
type FulfillmentPrepareDeps,
} from './types.js'
import type { TaskRow } from '../../../types/repository/rows.js'
/**
* 抖音码速达「皮肤自动发货」执行器。
*
* 与 affiliate-dash 执行器的最大区别:**不打任何外部平台**。
* 付款后我们只需要「确保任务有 claim token」并把任务置为待领取(`link_generated` + `pending_claim`),
* 领取链接(`claimLinkBase/token`)由调用方拿去做两件事:
* 1. 写进 `coupons.syncV2` 的 `cert_link`(见 redeem/certificate-service.ts);
* 2. 通过 `order.message` 按模板发给买家(见 douyin-mashida/notify-service.ts)。
*/
export const douyinMashidaSkinExecutor: FulfillmentExecutor = {
key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
preparePaidTask,
resolveDeliveryLink,
}
export function resolveSkinTaskClaimToken(task: Partial<TaskRow> | null | undefined): string {
return String(task?.primary_claim_token || task?.claim_token || '').trim()
}
export type OrderItemSkinClaimLink = {
/** 领取链接;未配置 claimLinkBase、或该订单项没有皮肤任务/token 时为空串 */
claimUrl: string
token: string
/** 该订单项下的皮肤履约单元数量(数量 > 1 时会有多个 token) */
taskCount: number
pickedTaskId: number | null
}
/**
* 取某订单项的皮肤领取链接。
*
* 一个订单项按数量会拆成 N 个履约单元(`fulfillment_tasks`),每个单元一个 token
* (`claim_tokens.task_id` 唯一);这里固定取 task id 最小的那个,保证同一张券每次解析结果一致。
* 调用方(certificate-service / notify-service)据 `taskCount` 打 warn 留痕。
*/
export async function resolveOrderItemSkinClaimLink(input: {
orderId: number | string
orderItemId: number | string
config?: DouyinMashidaSourceConfig | undefined
}): Promise<OrderItemSkinClaimLink> {
const skinTasks = (await listTasksByOrderId(input.orderId))
.filter(
(task) =>
Number(task.order_item_id) === Number(input.orderItemId) &&
String(task.executor_key || '').trim() === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
)
.sort((left, right) => Number(left.id) - Number(right.id))
const pickedTask = skinTasks[0] || null
const token = resolveSkinTaskClaimToken(pickedTask)
return {
claimUrl: resolveDouyinMashidaSkinClaimUrl(token, input.config),
token,
taskCount: skinTasks.length,
pickedTaskId: pickedTask ? Number(pickedTask.id) : null,
}
}
async function preparePaidTask(
task: TaskRow,
deps: FulfillmentPrepareDeps,
): Promise<TaskRow | null> {
let workingTask = task
// 幂等:已有 token 不重复生成(claim_tokens.task_id 唯一,重复插入只会覆盖)。
if (!resolveSkinTaskClaimToken(task)) {
const claimToken = await deps.createTaskClaimToken(task.id)
workingTask =
(await deps.updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
updated_at: deps.nowIso(),
})) || task
}
// 买家已提交过(claimed)的任务不允许被降级回待领取。
if (
workingTask.task_status === TASK_STATUS.CLAIMED ||
workingTask.task_status === TASK_STATUS.REDEEMED ||
workingTask.task_status === TASK_STATUS.COMPLETED
) {
return workingTask
}
if (
workingTask.task_status !== TASK_STATUS.LINK_GENERATED ||
workingTask.user_action_status !== 'pending_claim'
) {
workingTask =
(await deps.updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED,
user_action_status: 'pending_claim',
last_error: '',
updated_at: deps.nowIso(),
})) || workingTask
}
return workingTask
}
/** 对外统一返回本站皮肤领取链接;基址未配置时返回 null(不猜、不回退 hash 版领取页)。 */
async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLink | null> {
const token = resolveSkinTaskClaimToken(task)
if (!token) {
return null
}
const claimUrl = resolveDouyinMashidaSkinClaimUrl(token)
if (!claimUrl) {
logWarn('[douyin-mashida/skin]', '未配置领取链接基址(claimLinkBase),无法生成皮肤领取链接', {
taskId: task.id,
taskNo: task.task_no,
platformOrderId: task.platform_order_id,
})
return null
}
const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim()
return expireTime ? { claimUrl, expireTime } : { claimUrl }
}
@@ -1,5 +1,6 @@
import { isPaidPreparationStableStatus } from '../../../domain/task-status.js'
import { affiliateDashExecutor } from './affiliate-dash-executor.js'
import { douyinMashidaSkinExecutor } from './douyin-mashida-skin-executor.js'
import { legacyFeifeiExecutor, legacyLewanExecutor } from './legacy-retired-executor.js'
import { manualDispatchExecutor } from './manual-executor.js'
import {
@@ -14,6 +15,7 @@ import type { TaskRow } from '../../../types/repository/rows.js'
const EXECUTORS = new Map<string, FulfillmentExecutor>([
[FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, affiliateDashExecutor],
[FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN, douyinMashidaSkinExecutor],
[FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor],
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, legacyLewanExecutor],
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, legacyFeifeiExecutor],
@@ -4,6 +4,8 @@ import type { TaskRow } from '../../../types/repository/rows.js'
export const FULFILLMENT_EXECUTOR_KEYS = {
MANUAL_DISPATCH: 'manual_dispatch',
AFFILIATE_DASH: 'affiliate_dash',
/** 抖音码速达「皮肤自动发货」:只发本站领取链接,不打 affiliate-dash 平台 */
DOUYIN_MASHIDA_SKIN: 'douyin_mashida_skin',
/** @deprecated 已下线,仅历史任务 / stub 识别 */
KUAISHOU_CLOUD: 'kuaishou_ct_assisted',
/** @deprecated 已下线,仅历史任务识别 */
@@ -68,6 +70,10 @@ export function isManualDispatchExecutor(value: unknown): boolean {
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
}
export function isDouyinMashidaSkinExecutor(value: unknown): boolean {
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN
}
export function isRetiredFulfillmentExecutor(value: unknown): boolean {
const executorKey = normalizeExecutorKey(value)
return (
@@ -109,6 +109,66 @@ test('planFulfillmentTaskForOrderItem affiliate 缺 sku 时返回 null', async (
assert.equal(plan, null)
})
test('planFulfillmentTaskForOrderItem 为抖音皮肤商品生成领取链接履约计划', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder({ provider: 'douyin_mashida', platform: 'doudian' }),
item: createOrderItem({
sku_code: '幸运币2个',
sku_name: '【测试专用】和平精英皮肤小店入口',
item_snapshot_json: {
fulfillmentRoute: { selectedExecutorKey: 'douyin_mashida_skin' },
douyinSkin: {
internalSku: '幸运币2个',
sku: 'lucky_coin_x2',
productTitle: '【测试专用】和平精英皮肤小店入口',
},
},
}),
getProfileByKey: async (profileKey) => ({
id: 21,
profile_key: profileKey,
name: '抖音皮肤自动发货',
executor_key: profileKey,
requires_claim: false,
auto_dispatch: false,
}),
})
assert.ok(plan)
assert.equal(plan.executorKey, 'douyin_mashida_skin')
assert.equal(plan.profileKey, 'douyin_mashida_skin')
assert.equal(plan.requiresClaim, true)
assert.equal(plan.autoDispatch, false)
assert.equal(plan.context.affiliateDash, null)
const douyinSkin = plan.context.douyinSkin as Record<string, unknown> | null
assert.ok(douyinSkin)
assert.equal(douyinSkin.internalSku, '幸运币2个')
assert.equal(douyinSkin.sku, 'lucky_coin_x2')
assert.equal(douyinSkin.imageUrl, '/images/products/lucky_coin_x2.webp')
})
test('planFulfillmentTaskForOrderItem 抖音皮肤缺内部 SKU 时返回 null', async () => {
const plan = await planFulfillmentTaskForOrderItem({
order: createOrder({ provider: 'douyin_mashida', platform: 'doudian' }),
item: createOrderItem({
sku_code: '',
sku_name: '无内部 SKU',
item_snapshot_json: {
fulfillmentRoute: { selectedExecutorKey: 'douyin_mashida_skin' },
},
}),
getProfileByKey: async (profileKey) => ({
id: 21,
profile_key: profileKey,
name: '抖音皮肤自动发货',
executor_key: profileKey,
}),
})
assert.equal(plan, null)
})
function createOrder(patch: Partial<OrderRow> = {}): OrderRow {
return {
id: 1,
@@ -1,7 +1,12 @@
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { FULFILLMENT_EXECUTOR_KEYS, isAffiliateDashExecutor } from './executors/types.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
isAffiliateDashExecutor,
isDouyinMashidaSkinExecutor,
} from './executors/types.js'
export type FulfillmentBindingLike = {
id: number
@@ -91,6 +96,7 @@ function buildFulfillmentTaskContext({
fulfillmentConfig: JsonObject
}) {
const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash)
const douyinSkinConfig = parseJsonObject(fulfillmentConfig.douyinSkin)
return {
profileKey,
@@ -122,6 +128,17 @@ function buildFulfillmentTaskContext({
lastSyncedAt: null,
}
: null,
// 皮肤自动发货:任务上下文只放「怎么发货」的静态信息;买家提交的游戏账号
// 属于隐私数据,落在 fulfillment_tasks.claim_submission_json,不进这里、更不进打手工单。
douyinSkin: isDouyinMashidaSkinExecutor(executorKey)
? {
flowType: 'douyin_mashida_skin',
internalSku: String(douyinSkinConfig.internalSku || item.sku_code || '').trim(),
sku: String(douyinSkinConfig.sku || '').trim(),
productTitle: String(douyinSkinConfig.productTitle || item.sku_name || '').trim(),
imageUrl: resolveProductImageUrl(douyinSkinConfig.internalSku || item.sku_code),
}
: null,
}
}
@@ -134,6 +151,10 @@ async function resolveDynamicFulfillmentProfile(
return resolveDynamicAffiliateDashProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN) {
return resolveDynamicDouyinMashidaSkinProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
}
@@ -180,6 +201,47 @@ async function resolveDynamicAffiliateDashProfile(
}
}
/**
* 皮肤自动发货:内部 SKU 命中权威映射表时使用。
* `requires_claim=true` 让任务要求领取链接;`auto_dispatch=false` 表示不需要打手抢单。
*/
async function resolveDynamicDouyinMashidaSkinProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
): Promise<FulfillmentBindingLike | null> {
const snapshot = parseJsonObject(item.item_snapshot_json)
const douyinSkin = parseJsonObject(snapshot.douyinSkin)
const internalSku = String(douyinSkin.internalSku || item.sku_code || '').trim()
if (!internalSku) {
return null
}
const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN)
if (!profile) {
return null
}
return {
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
profile_name: String(profile.profile_name || profile.name || '抖音皮肤自动发货').trim(),
executor_key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
requires_claim: true,
auto_dispatch: false,
config_json: {
flowType: 'douyin_mashida_skin',
configId: `${FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN}:${internalSku}`,
douyinSkin: {
internalSku,
sku: String(douyinSkin.sku || '').trim(),
productTitle: String(douyinSkin.productTitle || item.sku_name || '').trim(),
},
notes: '抖音码速达皮肤商品自动发货(本站领取链接)',
},
}
}
async function resolveDynamicManualDispatchProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
@@ -4,7 +4,9 @@ import test from 'node:test'
import {
matchAffiliateDashSku,
matchAffiliateDashSkuPassthrough,
matchDouyinMashidaSkinSku,
normalizeAffiliateDashMappingKey,
resolveOrderItemForFulfillment,
} from './product-resolution-service.js'
import {
AFFILIATE_DASH_SKU_MAPPING,
@@ -12,6 +14,7 @@ import {
resolveAffiliateDashDisplayProductName,
reverseAffiliateDashSkuToProductNo,
} from '../platforms/affiliate-dash/sku-mapping.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
test('权威表覆盖核心中文商品', () => {
assert.equal(AFFILIATE_DASH_SKU_MAPPING['幸运币90个'], 'lucky_coin_x90')
@@ -96,3 +99,100 @@ test('reverseAffiliateDashSkuToProductNo 未命中返回空串', () => {
assert.equal(reverseAffiliateDashSkuToProductNo('not_a_real_sku'), '')
assert.equal(reverseAffiliateDashSkuToProductNo(''), '')
})
// ---- 抖音码速达(抖店)皮肤商品分流 ----
const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
function buildDouyinItem(internalSku: string, productName = DOUYIN_SKIN_TITLE) {
return {
itemId: '6930318258716179555',
externalItemId: '6930318258716179555',
externalSkuCode: internalSku,
externalSkuName: productName,
skuCode: internalSku,
skuName: productName,
quantity: 1,
}
}
function selectedExecutorKeyOf(
item: Awaited<ReturnType<typeof resolveOrderItemForFulfillment>>,
): string {
const route = item.snapshot.fulfillmentRoute as
| { selectedExecutorKey?: string }
| null
| undefined
return String(route?.selectedExecutorKey || '')
}
test('matchDouyinMashidaSkinSku 只认权威映射表并做归一化', () => {
assert.equal(matchDouyinMashidaSkinSku('幸运币2个'), 'lucky_coin_x2')
assert.equal(matchDouyinMashidaSkinSku(' 幸运币2个 '), 'lucky_coin_x2')
assert.equal(matchDouyinMashidaSkinSku('不存在的皮肤'), '')
assert.equal(matchDouyinMashidaSkinSku(''), '')
})
test('抖音订单:内部 SKU 命中映射表 → 皮肤自动发货', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(item.isConfigured, true)
assert.equal(selectedExecutorKeyOf(item), 'douyin_mashida_skin')
assert.equal(item.skuCode, '幸运币2个')
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
assert.deepEqual(item.snapshot.douyinSkin, {
matchMode: 'affiliate_dash_sku_mapping',
internalSku: '幸运币2个',
sku: 'lucky_coin_x2',
productTitle: DOUYIN_SKIN_TITLE,
})
assert.equal(resolveProductImageUrl(item.skuCode), '/images/products/lucky_coin_x2.webp')
})
test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('不存在的皮肤'),
})
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
assert.equal(item.snapshot.douyinSkin, null)
// 引流长标题作为 skuName,内部 SKU 作为 skuCode
assert.equal(item.skuCode, '不存在的皮肤')
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
})
test('抖音订单:平台闸门只认 douyin_mashida + doudian,其它组合仍不配置', async () => {
const wrongPlatform = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'kuaishou',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(wrongPlatform.isConfigured, false)
assert.equal(wrongPlatform.snapshot.fulfillmentRoute, null)
const wrongProvider = await resolveOrderItemForFulfillment({
provider: 'other',
platform: 'doudian',
item: buildDouyinItem('幸运币2个'),
})
assert.equal(wrongProvider.isConfigured, false)
assert.equal(wrongProvider.snapshot.fulfillmentRoute, null)
})
test('抖音订单:不触发 affiliate-dash 平台的 sku 透传(严格按内部 SKU 分流)', async () => {
const item = await resolveOrderItemForFulfillment({
provider: 'douyin_mashida',
platform: 'doudian',
item: buildDouyinItem('lucky_coin_x2'),
})
// 英文 sku 不是内部 SKU,抖音侧不做透传匹配,走人工
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
assert.equal(item.snapshot.affiliateDash, null)
})
@@ -1,7 +1,14 @@
import { resolveFulfillmentRoute, type FulfillmentRouteResult } from './routing-config-service.js'
import {
normalizeInternalSkuMatchKey,
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js'
import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-mapping.js'
import {
AFFILIATE_DASH_SKU_MAPPING,
mergeAffiliateDashSkuMapping,
} from '../platforms/affiliate-dash/sku-mapping.js'
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
import { normalizeProductMatchName } from '../order/product-match-utils.js'
@@ -37,6 +44,14 @@ export type AffiliateDashSkuMatch = {
matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough'
}
/** 抖音码速达「皮肤」命中信息:内部 SKU → 英文 sku(图片文件名同一套映射)。 */
export type DouyinMashidaSkinMatch = {
sku: string
internalSku: string
productTitle: string
matchMode: 'affiliate_dash_sku_mapping'
}
type FulfillmentItemCandidate = {
externalItemId: string
externalSkuCode: string
@@ -44,6 +59,7 @@ type FulfillmentItemCandidate = {
externalSkuNameNormalized: string
resolvedSkuCode: string
affiliateDashMatch: AffiliateDashSkuMatch | null
douyinSkinMatch: DouyinMashidaSkinMatch | null
fulfillmentRoute: FulfillmentRouteResult | null
isConfigured: boolean
}
@@ -75,6 +91,7 @@ export async function resolveOrderItemForFulfillment({
externalSkuName,
externalSkuNameNormalized,
affiliateDashMatch,
douyinSkinMatch,
fulfillmentRoute,
} = candidate
@@ -113,6 +130,14 @@ export async function resolveOrderItemForFulfillment({
skuName: affiliateDashMatch.skuName,
}
: null,
douyinSkin: douyinSkinMatch
? {
matchMode: douyinSkinMatch.matchMode,
internalSku: douyinSkinMatch.internalSku,
sku: douyinSkinMatch.sku,
productTitle: douyinSkinMatch.productTitle,
}
: null,
isConfigured: candidate.isConfigured,
}
@@ -206,11 +231,22 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized,
resolvedSkuCode,
affiliateDashMatch,
douyinSkinMatch: null,
fulfillmentRoute,
isConfigured: Boolean(selectedExecutorKey),
}
}
if (isDouyinMashidaOrder(provider, platform)) {
return resolveDouyinMashidaItemCandidate({
item,
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
})
}
const resolvedSkuCode = pickFirstNonEmpty([externalSkuCode, externalItemId])
return {
@@ -220,11 +256,84 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized,
resolvedSkuCode,
affiliateDashMatch: null,
douyinSkinMatch: null,
fulfillmentRoute: null,
isConfigured: false,
}
}
/**
* 抖音码速达(抖店)订单分流。
*
* 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品),
* `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。
* 因此分流与图片都按内部 SKU 走:
* - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 皮肤自动发货(本站领取链接);
* - 未命中 → 显式映射拿不到通道,按现有语义回退 `manual_dispatch`(接单平台/人工)。
*/
function resolveDouyinMashidaItemCandidate({
item,
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
}: {
item: FulfillmentItem
externalItemId: string
externalSkuCode: string
externalSkuName: string
externalSkuNameNormalized: string
}): FulfillmentItemCandidate {
const internalSku = pickFirstNonEmpty([externalSkuCode, externalItemId])
const productTitle = pickFirstNonEmpty([externalSkuName, internalSku])
const skinSku = matchDouyinMashidaSkinSku(internalSku)
const douyinSkinMatch: DouyinMashidaSkinMatch | null = skinSku
? {
sku: skinSku,
internalSku,
productTitle,
matchMode: 'affiliate_dash_sku_mapping',
}
: null
const fulfillmentRoute = resolveFulfillmentRoute({
productName: productTitle,
internalSku,
preferredExecutorKey: douyinSkinMatch ? FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN : '',
candidates: [
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
available: Boolean(douyinSkinMatch),
reason: douyinSkinMatch ? '' : '内部 SKU 未命中皮肤商品映射',
},
],
})
return {
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
// 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,领取页图片与展示都靠它。
resolvedSkuCode: internalSku || pickFirstNonEmpty([externalItemId, item.itemId]),
affiliateDashMatch: null,
douyinSkinMatch,
fulfillmentRoute,
isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey),
}
}
/**
* 内部 SKU → 英文 sku:只查权威表(与领取页图片 `resolveProductImageUrl` 同一套来源),
* 保证「能路由到皮肤执行器」与「能取到商品图」永远一致。
*/
export function matchDouyinMashidaSkinSku(internalSku: unknown): string {
const raw = String(internalSku ?? '').trim()
if (!raw) {
return ''
}
return matchAffiliateDashSku(AFFILIATE_DASH_SKU_MAPPING, raw)
}
async function resolveAffiliateDashSkuByProductNo(
item: FulfillmentItem,
): Promise<AffiliateDashSkuMatch | null> {
@@ -337,10 +446,7 @@ export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productN
}
export function normalizeAffiliateDashMappingKey(value: unknown): string {
return String(value || '')
.normalize('NFKC')
.replace(/\s+/g, '')
.toLowerCase()
return normalizeInternalSkuMatchKey(value)
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
@@ -349,6 +455,14 @@ function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
)
}
/** 抖音码速达:provider=douyin_mashida / platform=doudian(见 douyin-mashida/order-mapper.ts)。 */
function isDouyinMashidaOrder(provider: unknown, platform: unknown) {
return (
String(provider || '').trim() === 'douyin_mashida' &&
String(platform || '').trim() === 'doudian'
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -12,7 +12,11 @@ test('normalizeFulfillmentRoutingConfig drops retired executors and keeps affili
})
assert.deepEqual(config.defaultExecutorPriority, ['affiliate_dash'])
assert.deepEqual(Object.keys(config.executors).sort(), ['affiliate_dash', 'manual_dispatch'])
assert.deepEqual(Object.keys(config.executors).sort(), [
'affiliate_dash',
'douyin_mashida_skin',
'manual_dispatch',
])
assert.equal(config.unmatchedExecutorKey, 'manual_dispatch')
})
@@ -176,3 +180,100 @@ test('resolveFulfillmentRoute 无指定匹配时按全局优先级选择', () =>
assert.equal(result.selectedExecutorKey, 'affiliate_dash')
assert.equal(result.reason, '按全局优先级命中')
})
test('normalizeFulfillmentRoutingConfig 支持按内部 SKU 的规则维度', () => {
const config = normalizeFulfillmentRoutingConfig({
rules: [
{
internalSku: '幸运币2个',
executorKey: 'douyin_mashida_skin',
priority: 500,
},
],
})
assert.equal(config.rules.length, 1)
const rule = config.rules[0]
assert.ok(rule)
assert.equal(rule.matchField, 'internal_sku')
// 展示字段沿用 productName(值为内部 SKU),但商品名匹配键留空,避免被商品名规则误命中
assert.equal(rule.productName, '幸运币2个')
assert.equal(rule.normalizedProductName, '')
assert.equal(rule.internalSku, '幸运币2个')
assert.equal(rule.normalizedInternalSku, '幸运币2个')
assert.equal(rule.id, '幸运币2个:douyin_mashida_skin')
})
test('旧商品名规则归一化后带 matchField=product_name,语义不变', () => {
const config = normalizeFulfillmentRoutingConfig({
rules: [{ productName: '荣耀套装', executorKey: 'affiliate_dash', priority: 200 }],
})
const rule = config.rules[0]
assert.ok(rule)
assert.equal(rule.matchField, 'product_name')
assert.equal(rule.normalizedProductName, '荣耀套装')
assert.equal(rule.internalSku, '')
assert.equal(rule.id, '荣耀套装:affiliate_dash')
})
test('resolveFulfillmentRoute 命中内部 SKU 规则', () => {
const result = resolveFulfillmentRoute({
productName: '【测试专用】和平精英皮肤小店入口',
internalSku: '幸运币2个',
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
config: normalizeFulfillmentRoutingConfig({
rules: [{ internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 500 }],
}),
})
assert.equal(result.selectedExecutorKey, 'douyin_mashida_skin')
assert.equal(result.reason, '命中商品路由规则')
assert.equal(result.matchedRule?.productName, '幸运币2个')
})
test('resolveFulfillmentRoute 内部 SKU 规则支持归一化与 contains', () => {
const config = normalizeFulfillmentRoutingConfig({
rules: [
{
internalSku: '幸运币',
matchType: 'contains',
executorKey: 'douyin_mashida_skin',
priority: 500,
},
],
})
const exactFullWidth = resolveFulfillmentRoute({
productName: '',
internalSku: ' 幸运币2个 ',
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
config,
})
assert.equal(exactFullWidth.selectedExecutorKey, 'douyin_mashida_skin')
const miss = resolveFulfillmentRoute({
productName: '',
internalSku: '荣耀勋章2个',
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
config,
})
assert.equal(miss.selectedExecutorKey, 'manual_dispatch')
})
test('resolveFulfillmentRoute 未命中内部 SKU 规则时商品名规则仍然优先生效', () => {
const result = resolveFulfillmentRoute({
productName: '荣耀套装',
internalSku: '荣耀勋章2个',
candidates: [{ executorKey: 'affiliate_dash', available: true }],
config: normalizeFulfillmentRoutingConfig({
rules: [
{ productName: '荣耀套装', executorKey: 'affiliate_dash', priority: 200 },
{ internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 900 },
],
}),
})
assert.equal(result.selectedExecutorKey, 'affiliate_dash')
assert.equal(result.matchedRule?.productName, '荣耀套装')
})
@@ -9,6 +9,15 @@ import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
export type FulfillmentRoutingRuleMatchType = 'exact' | 'contains'
/**
* 规则匹配维度:
* - `product_name`:按**商品名**匹配(历史规则,语义完全不变);
* - `internal_sku`:按**内部 SKU**匹配(如抖店 `sku_order_list[].code` = 「幸运币2个」)。
* 内部 SKU 用 `normalizeInternalSkuMatchKey` 归一(NFKC + 去空白 + 小写),
* 这样全角数字/多余空格也能命中权威映射表里的写法。
*/
export type FulfillmentRoutingRuleMatchField = 'product_name' | 'internal_sku'
export type FulfillmentRoutingExecutorConfig = {
enabled: boolean
}
@@ -16,8 +25,11 @@ export type FulfillmentRoutingExecutorConfig = {
export type FulfillmentRoutingRule = {
id: string
enabled: boolean
matchField: FulfillmentRoutingRuleMatchField
productName: string
normalizedProductName: string
internalSku: string
normalizedInternalSku: string
matchType: FulfillmentRoutingRuleMatchType
executorKey: string
priority: number
@@ -63,6 +75,7 @@ const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join(
const ROUTABLE_EXECUTOR_KEYS: string[] = [
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
]
@@ -131,11 +144,13 @@ export function normalizeFulfillmentRoutingConfig(rawValue: unknown): Fulfillmen
export function resolveFulfillmentRoute({
productName,
internalSku = '',
candidates,
config = getFulfillmentRoutingConfig(),
preferredExecutorKey = '',
}: {
productName: unknown
internalSku?: unknown
candidates: FulfillmentRoutingCandidate[]
config?: FulfillmentRoutingConfig
preferredExecutorKey?: string
@@ -167,7 +182,10 @@ export function resolveFulfillmentRoute({
}
}
const matchedRule = findMatchedRoutingRule(productName, normalizedConfig.rules)
// 匹配顺序刻意保持:先按商品名(历史规则,行为完全不变),再按内部 SKU(新增维度)。
const matchedRule =
findMatchedRoutingRule(productName, normalizedConfig.rules) ||
findMatchedInternalSkuRoutingRule(internalSku, normalizedConfig.rules)
if (matchedRule) {
const selected = resolveExecutorSelection({
executorKey: matchedRule.executorKey,
@@ -381,7 +399,10 @@ function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingR
return (
rules.find((rule) => {
if (rule.enabled === false || !rule.normalizedProductName) {
if (rule.enabled === false || rule.matchField !== 'product_name') {
return false
}
if (!rule.normalizedProductName) {
return false
}
@@ -394,13 +415,87 @@ function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingR
)
}
/** 内部 SKU 维度的规则匹配:与商品名规则同样的 exact/contains 语义。 */
function findMatchedInternalSkuRoutingRule(internalSku: unknown, rules: FulfillmentRoutingRule[]) {
const normalizedInternalSku = normalizeInternalSkuMatchKey(internalSku)
if (!normalizedInternalSku) {
return null
}
return (
rules.find((rule) => {
if (rule.enabled === false || rule.matchField !== 'internal_sku') {
return false
}
if (!rule.normalizedInternalSku) {
return false
}
if (rule.matchType === 'contains') {
return normalizedInternalSku.includes(rule.normalizedInternalSku)
}
return normalizedInternalSku === rule.normalizedInternalSku
}) || null
)
}
/**
* 内部 SKU 归一化:NFKC(全角→半角)→ 去所有空白 → 小写。
* 与权威映射表 `AFFILIATE_DASH_SKU_MAPPING` 的查询归一化保持同一套语义。
*/
export function normalizeInternalSkuMatchKey(value: unknown): string {
return String(value || '')
.normalize('NFKC')
.replace(/\s+/g, '')
.toLowerCase()
}
/**
* 规则归一化。
*
* 兼容旧配置:没有 `matchField` / `internalSku` 的规则一律视为**商品名**规则,行为与之前完全一致
* (id、归一化键、matchType、priority 的算法都没动)。
* 新维度通过 `internalSku`(或显式 `matchField: 'internal_sku'`)声明。
*/
function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null {
const source = isPlainObject(rawValue) ? rawValue : {}
const matchField = resolveRuleMatchField(source)
const executorKey = normalizeExecutorKey(source.executorKey)
if (matchField === 'internal_sku') {
const internalSku = String(source.internalSku ?? source.sku ?? '').trim()
const normalizedInternalSku =
normalizeInternalSkuMatchKey(source.normalizedInternalSku) ||
normalizeInternalSkuMatchKey(internalSku)
if (!internalSku || !normalizedInternalSku || !executorKey) {
return null
}
return {
id:
String(source.id || `${normalizedInternalSku}:${executorKey}`).trim() ||
`${normalizedInternalSku}:${executorKey}`,
enabled: source.enabled !== false,
matchField: 'internal_sku',
// 展示与诊断仍走 productName 字段(值为内部 SKU);normalizedProductName 留空,
// 保证商品名匹配器永远不会把它当成商品名规则命中。
productName: internalSku,
normalizedProductName: '',
internalSku,
normalizedInternalSku,
matchType: source.matchType === 'contains' ? 'contains' : 'exact',
executorKey,
priority: normalizeInteger(source.priority, 100),
notes: String(source.notes || '').trim(),
}
}
const productName = String(source.productName || source.name || '').trim()
const normalizedProductName =
normalizeProductMatchName(source.normalizedProductName) ||
normalizeProductMatchName(productName)
const executorKey = normalizeExecutorKey(source.executorKey)
if (!productName || !normalizedProductName || !executorKey) {
return null
@@ -411,8 +506,11 @@ function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingR
String(source.id || `${normalizedProductName}:${executorKey}`).trim() ||
`${normalizedProductName}:${executorKey}`,
enabled: source.enabled !== false,
matchField: 'product_name',
productName,
normalizedProductName,
internalSku: '',
normalizedInternalSku: '',
matchType: source.matchType === 'contains' ? 'contains' : 'exact',
executorKey,
priority: normalizeInteger(source.priority, 100),
@@ -420,6 +518,16 @@ function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingR
}
}
function resolveRuleMatchField(source: JsonObject): FulfillmentRoutingRuleMatchField {
if (String(source.matchField || '').trim() === 'internal_sku') {
return 'internal_sku'
}
const hasProductName = Boolean(String(source.productName || source.name || '').trim())
const hasInternalSku = Boolean(String(source.internalSku ?? source.sku ?? '').trim())
return !hasProductName && hasInternalSku ? 'internal_sku' : 'product_name'
}
function normalizeRoutingCandidates(candidates: FulfillmentRoutingCandidate[]) {
const result = new Map<string, FulfillmentRoutingCandidate>()
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildDouyinMashidaClaimMessage,
renderDouyinMashidaMessageTemplate,
resolveDouyinMashidaSkinClaimUrl,
} from './claim-link.js'
import {
createDefaultDouyinMashidaSourceConfig,
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
normalizeDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from './source-config-service.js'
function buildConfig(
overrides: Partial<DouyinMashidaSourceConfig> = {},
): DouyinMashidaSourceConfig {
return { ...createDefaultDouyinMashidaSourceConfig(), ...overrides }
}
test('抖音配置默认值:claimLinkBase 空、模板有默认文案、发消息开关默认开', () => {
const config = normalizeDouyinMashidaSourceConfig({})
assert.equal(config.claimLinkBase, '')
assert.equal(config.messageTemplate, DEFAULT_CLAIM_MESSAGE_TEMPLATE)
assert.equal(config.claimMessageEnabled, true)
// 既有字段默认值不变
assert.equal(config.certLink, '')
assert.equal(config.messageType, 2)
assert.equal(config.timeoutMs, 20000)
})
test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板可覆盖', () => {
const config = normalizeDouyinMashidaSourceConfig({
claimLinkBase: ' https://x.ngrok-free.app/claim/// ',
messageTemplate: ' 你好 {link} ',
claimMessageEnabled: false,
})
assert.equal(config.claimLinkBase, 'https://x.ngrok-free.app/claim')
assert.equal(config.messageTemplate, '你好 {link}')
assert.equal(config.claimMessageEnabled, false)
})
test('皮肤领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退 hash 版领取页', () => {
assert.equal(
resolveDouyinMashidaSkinClaimUrl(
'AbC123',
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(resolveDouyinMashidaSkinClaimUrl('AbC123', buildConfig()), '')
assert.equal(
resolveDouyinMashidaSkinClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
'',
)
})
test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => {
assert.equal(
renderDouyinMashidaMessageTemplate(
'订单 {orderNo} 的 {productTitle}({sku})请点 {link} 领取 ',
{
link: 'https://x/claim/1',
sku: '幸运币2个',
orderNo: '6930318258716179555',
productTitle: '测试商品',
},
),
'订单 6930318258716179555 的 测试商品(幸运币2个)请点 https://x/claim/1 领取 ',
)
assert.equal(
renderDouyinMashidaMessageTemplate('{link} {unknown}', { link: 'https://x/claim/1' }),
'https://x/claim/1 {unknown}',
)
assert.equal(renderDouyinMashidaMessageTemplate('{link}', { link: '' }), '{link}')
})
test('发消息正文:无领取链接则不发(返回空串),默认模板带链接', () => {
assert.equal(buildDouyinMashidaClaimMessage({ config: buildConfig(), claimUrl: '' }), '')
assert.equal(
buildDouyinMashidaClaimMessage({
config: buildConfig(),
claimUrl: 'https://x.ngrok-free.app/claim/AbC123',
}),
`您的订单已发货,请点击链接领取:https://x.ngrok-free.app/claim/AbC123`,
)
assert.equal(
buildDouyinMashidaClaimMessage({
config: buildConfig({ messageTemplate: '{sku} 已到账,订单 {orderNo}:{link}' }),
claimUrl: 'https://x/claim/1',
sku: '幸运币2个',
orderNo: '6930',
productTitle: '测试商品',
}),
'幸运币2个 已到账,订单 6930:https://x/claim/1',
)
})
@@ -0,0 +1,72 @@
import { buildSkinClaimUrl } from '../../claim/claim-service.js'
import {
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
getDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from './source-config-service.js'
/**
* 抖音码速达「皮肤自动发货」的领取链接与发货消息拼装(纯函数,便于单测)。
*
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
* (那是 hash 形式的打手领取页,语义完全不同)。
*/
export type DouyinMashidaClaimMessageVars = {
link: string
sku?: string | undefined
orderNo?: string | undefined
productTitle?: string | undefined
}
/** 皮肤领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
export function resolveDouyinMashidaSkinClaimUrl(
token: unknown,
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
): string {
return buildSkinClaimUrl(config.claimLinkBase, token)
}
/**
* 模板占位符替换:支持 `{link}` / `{sku}` / `{orderNo}` / `{productTitle}`。
* 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。
*/
export function renderDouyinMashidaMessageTemplate(
template: unknown,
vars: DouyinMashidaClaimMessageVars,
): string {
const source = String(template ?? '')
if (!source) {
return ''
}
return source.replace(/\{(\w+)\}/g, (placeholder, key: string) => {
const value = (vars as Record<string, unknown>)[key]
const normalized = typeof value === 'string' ? value.trim() : ''
return normalized || placeholder
})
}
/** 皮肤订单付款后发给买家的正文;`claimUrl` 为空返回空串(不发消息)。 */
export function buildDouyinMashidaClaimMessage(input: {
config: DouyinMashidaSourceConfig
claimUrl: unknown
sku?: unknown
orderNo?: unknown
productTitle?: unknown
}): string {
const claimUrl = String(input.claimUrl || '').trim()
if (!claimUrl) {
return ''
}
const template =
String(input.config.messageTemplate || '').trim() || DEFAULT_CLAIM_MESSAGE_TEMPLATE
return renderDouyinMashidaMessageTemplate(template, {
link: claimUrl,
sku: String(input.sku || '').trim(),
orderNo: String(input.orderNo || '').trim(),
productTitle: String(input.productTitle || '').trim(),
})
}
@@ -3,23 +3,29 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js'
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { upsertOrderFromSource } from '../../order/order-service.js'
import { invalidateCertificatesForLocalOrder } from '../../redeem/certificate-service.js'
import {
ensureCertificateForOrderItem,
invalidateCertificatesForLocalOrder,
resolveReceiptOrderId,
} from '../../redeem/certificate-service.js'
import { createHttpError } from '../../../utils/http.js'
import { logIntegration } from '../../../utils/logger.js'
import { createHash } from 'node:crypto'
import { fetchDouyinMashidaOrderDetail } from './gateway-client.js'
import { fetchDouyinMashidaOrderDetail, sendDouyinMashidaOrderMessage } from './gateway-client.js'
import {
DOUYIN_MASHIDA_PLATFORM,
DOUYIN_MASHIDA_PROVIDER,
buildDouyinMashidaSourceEvent,
resolveDouyinMashidaParentOrderId,
} from './order-mapper.js'
import { ensureCertificateForOrderItem } from '../../redeem/certificate-service.js'
import { resolveOrderItemSkinClaimLink } from '../../fulfillment/executors/douyin-mashida-skin-executor.js'
import { buildDouyinMashidaClaimMessage } from './claim-link.js'
import {
getDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from './source-config-service.js'
import { verifyMessageSignature } from './sign.js'
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
/**
* 抖音码速达消息推送入口(验签 → 幂等 → 分派)。
@@ -185,6 +191,21 @@ async function handlePaidMessage(input: {
for (const orderItem of result.orderItems || []) {
await ensureCertificateForOrderItem({ order, orderItem, config: input.config })
certificateCount += 1
// 皮肤自动发货:发券成功后按模板把领取链接发给买家。
// best-effort:通知失败只记 warn,绝不影响建单/发券主流程(平台会重推时也不会重复发券)。
await notifyBuyerSkinClaimLink({ order, orderItem, config: input.config }).catch((error) => {
logIntegration(
'[douyin-mashida/notify]',
'皮肤领取链接通知买家失败(建单/发券不受影响)',
{
orderId: order.id,
orderItemId: orderItem.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
})
}
logIntegration('[douyin-mashida/notify]', '付款消息处理完成', {
@@ -207,6 +228,55 @@ async function handlePaidMessage(input: {
}
}
/**
* 皮肤自动发货:付款建单 + 发券成功后,把领取链接按模板通过 `order.message` 发给买家。
*
* 只在「该订单项确实有皮肤履约任务 + claimLinkBase 已配置 + 开关打开」时才发;
* 非皮肤订单静默跳过(不打扰现有兑换流程)。
*/
async function notifyBuyerSkinClaimLink(input: {
order: OrderRow
orderItem: OrderItemRow
config: DouyinMashidaSourceConfig
}): Promise<void> {
if (input.config.claimMessageEnabled === false) {
return
}
if (!String(input.config.claimLinkBase || '').trim()) {
return
}
const skinLink = await resolveOrderItemSkinClaimLink({
orderId: input.order.id,
orderItemId: input.orderItem.id,
config: input.config,
})
if (!skinLink.claimUrl) {
return
}
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
const content = buildDouyinMashidaClaimMessage({
config: input.config,
claimUrl: skinLink.claimUrl,
sku: input.orderItem.sku_code,
orderNo,
productTitle: input.orderItem.sku_name,
})
if (!content) {
return
}
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
logIntegration('[douyin-mashida/notify]', '已把皮肤领取链接发给买家(order.message)', {
orderId: input.order.id,
orderItemId: input.orderItem.id,
orderNo,
taskId: skinLink.pickedTaskId,
})
}
/** 取消/退款:把该订单下未核销的券作废。 */
async function handleInvalidateMessage(input: {
message: JsonObject
@@ -18,6 +18,9 @@ import {
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
* 皮肤自动发货订单会优先用「本站领取链接」(`claimLinkBase` + token),此时 `certLink` 退化为兜底。
* - `claimLinkBase` 是皮肤领取页的**路径式**基址(如 `https://x.ngrok-free.app/claim`);
* 它**不是**打手领取页(`runtimeConfig.orders.claimBaseUrl` 那个 hash 版),两者不能互相回退。
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
*/
@@ -29,6 +32,9 @@ export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanm
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
/** 皮肤自动发货:付款后用该模板把领取链接发给买家(可在后台覆盖)。 */
export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击链接领取:{link}'
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
'trade_TradePaid',
'trade_TradeCanceled',
@@ -40,8 +46,14 @@ export type DouyinMashidaSourceConfig = {
baseUrl: string
uid: string
secret: string
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem */
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem;皮肤订单无领取链接时兜底用它 */
certLink: string
/** 皮肤领取页基址(路径式,如 https://x.ngrok-free.app/claim),留空则不生成皮肤领取链接 */
claimLinkBase: string
/** 皮肤自动发货:付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */
messageTemplate: string
/** 皮肤自动发货「付款后发消息」开关,默认开 */
claimMessageEnabled: boolean
/** order.message 的 message_type:1-订单状态 2-订单发货 4-图片 5-视频 */
messageType: number
/** 兑换成功后发给买家的「去使用链接」(卡密页/网盘等),留空则只发文案 */
@@ -107,6 +119,11 @@ export function normalizeDouyinMashidaSourceConfig(rawValue: unknown): DouyinMas
certLink: String(source.certLink || '')
.trim()
.replace(/\/+$/, ''),
claimLinkBase: String(source.claimLinkBase || '')
.trim()
.replace(/\/+$/, ''),
messageTemplate: String(source.messageTemplate || '').trim() || DEFAULT_CLAIM_MESSAGE_TEMPLATE,
claimMessageEnabled: source.claimMessageEnabled !== false,
messageType: normalizeMessageType(source.messageType),
afterVerifyLink: String(source.afterVerifyLink || '').trim(),
afterVerifyMessage: String(source.afterVerifyMessage || '').trim(),
@@ -124,6 +141,9 @@ export function createDefaultDouyinMashidaSourceConfig(): DouyinMashidaSourceCon
uid: '',
secret: '',
certLink: '',
claimLinkBase: '',
messageTemplate: DEFAULT_CLAIM_MESSAGE_TEMPLATE,
claimMessageEnabled: true,
messageType: 2,
afterVerifyLink: '',
afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE,
@@ -13,6 +13,7 @@ import {
} from '../../repositories/redeem-certificate-repo.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { resolveOrderItemSkinClaimLink } from '../fulfillment/executors/douyin-mashida-skin-executor.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
@@ -69,6 +70,13 @@ export async function ensureCertificateForOrderItem(input: {
})
}
// 皮肤自动发货:cert_link 用「本站领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。
const certLink = await resolveDouyinMashidaCertLink({
order: input.order,
orderItem: input.orderItem,
config,
})
let certificate = await findRedeemCertificateByOrder({ provider, orderId: receiptOrderId })
if (!certificate) {
@@ -78,7 +86,7 @@ export async function ensureCertificateForOrderItem(input: {
parentOrderId: String(input.order.platform_order_id || '').trim(),
localOrderId: input.order.id,
orderItemId: input.orderItem.id,
certLink: config.certLink,
certLink,
grantTime: nowIso(),
rawPayload: {
source: provider,
@@ -106,17 +114,78 @@ export async function ensureCertificateForOrderItem(input: {
return certificate
}
return syncCertificateToPlatform(certificate, config)
return syncCertificateToPlatform(certificate, config, certLink)
}
/** 调 `coupons.syncV2` 把券码 + 固定兑换链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
/**
* 解析本次同步要交给平台的 `cert_link`。
*
* 优先级(**只对新单生效**:已同步成功的券不会再改动):
* 1. 皮肤自动发货任务已生成 claim token,且配置了 `claimLinkBase`
* → `claimLinkBase/token`(买家领取页,路径式);
* 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。
*
* 同一个订单项按数量拆成多个履约单元时会有多个 token,这里取最小 task id 的那个,
* 保证「同一张券每次解析出同一条链接」,并打 warn 留痕提示运营。
*/
export async function resolveDouyinMashidaCertLink(input: {
order: OrderRow
orderItem: OrderItemRow
config: DouyinMashidaSourceConfig
}): Promise<string> {
const fallback = String(input.config.certLink || '').trim()
if (!String(input.config.claimLinkBase || '').trim()) {
return fallback
}
const skinLink = await resolveOrderItemSkinClaimLink({
orderId: input.order.id,
orderItemId: input.orderItem.id,
config: input.config,
})
if (!skinLink.claimUrl) {
if (skinLink.taskCount > 0) {
logIntegration(
'[douyin-mashida/certificate]',
'皮肤订单尚无可用 claim token,cert_link 回退固定兑换链接',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
skinTaskCount: skinLink.taskCount,
},
{ level: 'warn' },
)
}
return fallback
}
if (skinLink.taskCount > 1) {
logIntegration(
'[douyin-mashida/certificate]',
'同一订单项存在多个皮肤履约单元,cert_link 取首个 token',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
skinTaskCount: skinLink.taskCount,
pickedTaskId: skinLink.pickedTaskId,
},
{ level: 'warn' },
)
}
return skinLink.claimUrl
}
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
async function syncCertificateToPlatform(
certificate: RedeemCertificateRow,
config: DouyinMashidaSourceConfig,
certLinkOverride?: string | undefined,
): Promise<RedeemCertificateRow> {
const certLink = String(config.certLink || '').trim()
const certLink = String(certLinkOverride ?? config.certLink ?? '').trim()
if (!certLink) {
const message = '未配置抖音码速达兑换链接(certLink),券码暂不对外同步'
const message = '未配置抖音码速达兑换链接(certLink / claimLinkBase),券码暂不对外同步'
await markRedeemCertificateSyncFailed({ id: certificate.id, error: message })
logIntegration(
'[douyin-mashida/certificate]',
@@ -168,6 +237,32 @@ async function syncCertificateToPlatform(
}
}
/**
* 领取页核销入口:按「本地订单 + 订单项」找到该订单的券码并核销(幂等)。
* 找不到券说明发券还没成功,明确报错让买家稍后重试,不要静默放过(否则平台侧券没核销)。
*/
export async function verifyCertificateForOrderItem(input: {
order: OrderRow
orderItem: OrderItemRow
config?: DouyinMashidaSourceConfig | undefined
}): Promise<RedeemCertificateRow> {
const receiptOrderId = resolveReceiptOrderId(input.order, input.orderItem)
const certificate = await findRedeemCertificateByOrder({
provider: DOUYIN_MASHIDA_PROVIDER,
orderId: receiptOrderId,
})
if (!certificate) {
throw createHttpError('该订单的券码尚未生成,请稍后重试', {
statusCode: 409,
errorCode: 'douyin_mashida_certificate_not_found',
context: { orderId: input.order.id, orderItemId: input.orderItem.id, receiptOrderId },
})
}
return verifyCertificate({ certNo: certificate.cert_no, config: input.config })
}
/**
* 核销上报(幂等):本地已核销直接返回;未同步成功的券不允许核销(否则平台侧没有这张券)。
*/
@@ -118,6 +118,8 @@ export type TaskUpdatePatch = {
attempt_count?: number
last_error?: string
context_json?: string | Record<string, unknown>
/** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */
claim_submission_json?: string | Record<string, unknown>
claimed_at?: string | null
role_confirmed_at?: string | null
redeemed_at?: string | null
@@ -66,6 +66,8 @@ export type TaskRow = {
primary_claim_expires_at?: string | null
artifacts_json: string | Record<string, unknown>
context_json: string | Record<string, unknown>
/** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */
claim_submission_json?: string | Record<string, unknown>
screenshot_path: string
last_error: string
retry_count: number
+19 -1
View File
@@ -27,6 +27,18 @@ export function buildSuccessPayload(data: unknown, msg: string = 'ok') {
}
}
/**
* 与 `buildSuccessPayload` 同源,但用 `message` 作为文案字段(抖音入站/皮肤领取页等对外契约要求)。
* 不额外带 `time`,严格贴合这些接口的响应契约。
*/
export function buildMessageSuccessPayload(data: unknown, message: string = 'success') {
return {
code: 0,
message,
data: normalizeResponseDateTime(data),
}
}
export function buildErrorPayload(error: unknown, fallbackMessage: string) {
const classification = classifyRouteError(error)
const errorMessage = error instanceof Error ? String(error.message || '').trim() : ''
@@ -54,6 +66,7 @@ export function sendRouteError(
error: unknown,
fallbackMessage = '服务内部错误',
scope = '[route]',
options: { messageAlias?: boolean } = {},
): void {
const classification = classifyRouteError(error)
const logLevel = resolveRouteErrorLogLevel(classification)
@@ -64,7 +77,12 @@ export function sendRouteError(
// 4xx 属预期失败(鉴权过期、参数错误等),带上完整错误对象只会刷十几行堆栈;仅 5xx 保留排查所需详情。
...(classification.statusCode >= 500 ? { error } : {}),
})
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
const payload = buildErrorPayload(error, fallbackMessage)
// 个别对外接口(皮肤领取页)约定用 `message` 而非仓库通用的 `msg`;
// 这里只做别名追加,不改变 `msg` / `errorCode`,老调用方行为不受影响。
res
.status(classification.statusCode)
.json(options.messageAlias ? { ...payload, message: payload.msg } : payload)
}
export function resolveRouteErrorLogLevel(classification: {