diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 82f2a5b5..c8c5da8c 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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) diff --git a/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql b/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql new file mode 100644 index 00000000..dc72326c --- /dev/null +++ b/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql @@ -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'; diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 2fece3cc..cf973626 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -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, diff --git a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts index 0f0705d4..f73274f4 100644 --- a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts +++ b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts @@ -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, }, diff --git a/apps/backend/src/routes/claim-skin.ts b/apps/backend/src/routes/claim-skin.ts new file mode 100644 index 00000000..56b50441 --- /dev/null +++ b/apps/backend/src/routes/claim-skin.ts @@ -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 + 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 diff --git a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts index f0f8c1ad..f475a430 100644 --- a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts +++ b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts @@ -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), } } diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index f952ef91..fae5eb35 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -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, } } diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 0b4ae571..1bb2c83d 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -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() { diff --git a/apps/backend/src/services/claim/claim-service.test.ts b/apps/backend/src/services/claim/claim-service.test.ts index f5e18d99..46211fe5 100644 --- a/apps/backend/src/services/claim/claim-service.test.ts +++ b/apps/backend/src/services/claim/claim-service.test.ts @@ -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), '') +}) diff --git a/apps/backend/src/services/claim/claim-service.ts b/apps/backend/src/services/claim/claim-service.ts index 4d4c3332..cf25c676 100644 --- a/apps/backend/src/services/claim/claim-service.ts +++ b/apps/backend/src/services/claim/claim-service.ts @@ -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}` +} diff --git a/apps/backend/src/services/claim/claim-skin-service.test.ts b/apps/backend/src/services/claim/claim-skin-service.test.ts new file mode 100644 index 00000000..26956857 --- /dev/null +++ b/apps/backend/src/services/claim/claim-skin-service.test.ts @@ -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 { + 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 { + 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 = {}): Partial { + 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') +}) diff --git a/apps/backend/src/services/claim/claim-skin-service.ts b/apps/backend/src/services/claim/claim-skin-service.ts new file mode 100644 index 00000000..ba6fc036 --- /dev/null +++ b/apps/backend/src/services/claim/claim-skin-service.ts @@ -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 & { + 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 | 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 + return { + gameAccount: String(record.gameAccount || '').trim(), + submittedAt: String(record.submittedAt || '').trim(), + } +} + +/** 待领取 / 已领取:以「任务上是否已有提交记录」为准,保证重复提交幂等。 */ +export function resolveSkinClaimStatus(task: Partial | 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 + 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 { + 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 { + 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 { + 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 } +} diff --git a/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.test.ts b/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.test.ts new file mode 100644 index 00000000..bf26d2e2 --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.test.ts @@ -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 { + 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 = {}) { + const updates: Array<{ taskId: number | string; patch: Record }> = [] + const tokenRequests: Array = [] + + 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), '') +}) diff --git a/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.ts b/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.ts new file mode 100644 index 00000000..e819db7b --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.ts @@ -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 | 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 { + 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 { + 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 { + 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 } +} diff --git a/apps/backend/src/services/fulfillment/executors/registry.ts b/apps/backend/src/services/fulfillment/executors/registry.ts index 2842807e..ad9378d6 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.ts @@ -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([ [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], diff --git a/apps/backend/src/services/fulfillment/executors/types.ts b/apps/backend/src/services/fulfillment/executors/types.ts index c7d45002..8ed1e613 100644 --- a/apps/backend/src/services/fulfillment/executors/types.ts +++ b/apps/backend/src/services/fulfillment/executors/types.ts @@ -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 ( diff --git a/apps/backend/src/services/fulfillment/planner.test.ts b/apps/backend/src/services/fulfillment/planner.test.ts index 191c480c..66d2fa14 100644 --- a/apps/backend/src/services/fulfillment/planner.test.ts +++ b/apps/backend/src/services/fulfillment/planner.test.ts @@ -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 | 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 { return { id: 1, diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts index cdc10e57..f65385c1 100644 --- a/apps/backend/src/services/fulfillment/planner.ts +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -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 { + 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, diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts index 6df1827f..fee1feab 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -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>, +): 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) +}) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 6401523d..cc91f5cd 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -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 { @@ -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 { return Object.prototype.toString.call(value) === '[object Object]' } diff --git a/apps/backend/src/services/fulfillment/routing-config-service.test.ts b/apps/backend/src/services/fulfillment/routing-config-service.test.ts index b1a6f504..a27abfb3 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.test.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.test.ts @@ -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, '荣耀套装') +}) diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts index 3c7584bf..2aafa351 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -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() diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts new file mode 100644 index 00000000..fa3fa948 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts @@ -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 { + 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', + ) +}) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts new file mode 100644 index 00000000..036fa22a --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts @@ -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)[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(), + }) +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index b6ff13b1..176f89fc 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -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 { + 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 diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index 90f5d296..d2a807cd 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -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, diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index 381c8a60..c3a9f0aa 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -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 { + 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 { - 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 { + 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 }) +} + /** * 核销上报(幂等):本地已核销直接返回;未同步成功的券不允许核销(否则平台侧没有这张券)。 */ diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 73947ff4..3cbef1ab 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -118,6 +118,8 @@ export type TaskUpdatePatch = { attempt_count?: number last_error?: string context_json?: string | Record + /** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */ + claim_submission_json?: string | Record claimed_at?: string | null role_confirmed_at?: string | null redeemed_at?: string | null diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 592df153..523a9774 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -66,6 +66,8 @@ export type TaskRow = { primary_claim_expires_at?: string | null artifacts_json: string | Record context_json: string | Record + /** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */ + claim_submission_json?: string | Record screenshot_path: string last_error: string retry_count: number diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index aed97fdb..e64ddabe 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -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: {