diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index cba7b262..0ce11cce 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -97,9 +97,13 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) { } // 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。 +// +// 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底 +// (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏): +// 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。 try { - const diffOutputs = [ - execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], { + const nameStatusOutputs = [ + execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], { cwd: BACKEND_ROOT, encoding: 'utf8', }), @@ -108,14 +112,41 @@ try { encoding: 'utf8', }), ] - const changedHistoricalMigrations = diffOutputs - .flatMap((output) => output.split('\n')) - .map((line) => line.trim().split(/\s+/)) - .filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A') - .map(([status, file]) => `${status} ${file}`) + const migrationChanges = nameStatusOutputs.flatMap((output) => + output + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => { + // `git status --porcelain` = `XY path`(未跟踪为 `?? path`); + // `git diff --name-status` = `S\tpath`(重命名为 `R100\told\tnew`)。两者格式不同,分别解析。 + if (line.includes('\t')) { + const parts = line.split('\t').map((part) => part.trim()) + return { status: parts[0] || '', file: parts[parts.length - 1] || '' } + } + return { status: line.slice(0, 2).trim(), file: line.slice(3).trim() } + }) + .filter((change) => change.file.endsWith('.sql')), + ) + + const changedHistoricalMigrations = migrationChanges + .filter((change) => change.status !== 'A' && change.status !== 'D' && change.status !== '??') + .map((change) => `${change.status} ${change.file}`) if (changedHistoricalMigrations.length > 0) { failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`) } + + const deletedMigrations = migrationChanges.filter((change) => change.status === 'D') + const addedMigrations = migrationChanges.filter( + (change) => change.status === 'A' || change.status === '??', + ) + if (deletedMigrations.length > 0 && addedMigrations.length === 0) { + failures.push( + `删除历史迁移必须同时新增兜底迁移: ${deletedMigrations + .map((change) => change.file) + .join(', ')}`, + ) + } } catch (error) { failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index c8c5da8c..82f2a5b5 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,7 +7,6 @@ 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' @@ -111,8 +110,6 @@ 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 deleted file mode 100644 index dc72326c..00000000 --- a/apps/backend/src/db/migrations/096_fulfillment_tasks_claim_submission.sql +++ /dev/null @@ -1,20 +0,0 @@ --- 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/db/migrations/097_drop_claim_submission.sql b/apps/backend/src/db/migrations/097_drop_claim_submission.sql new file mode 100644 index 00000000..1968f086 --- /dev/null +++ b/apps/backend/src/db/migrations/097_drop_claim_submission.sql @@ -0,0 +1,11 @@ +-- 097_drop_claim_submission.sql —— 撤销上一轮「抖音皮肤独立领取流程」留下的买家提交列。 +-- +-- 背景(设计纠偏):抖音码速达皮肤/礼包类订单的交付改为**完全复用现成的 affiliate_dash 通道** +-- (一条链接 → 买家输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成), +-- 上一轮另建的 `douyin_mashida_skin` 执行器 / `/api/v1/claim-skin` 接口 / 独立领取页 +-- 以及「只提交账号就结束」的 `claim_submission_json`(迁移 096)整条链路作废并删除。 +-- 买家提交的游戏账号改由 affiliate_dash 领取流程的 `claimIdentity`(expectedUid/boundUid)承载。 +-- +-- 幂等:IF EXISTS;列不存在(未应用过 096 的环境)也不会报错。 + +ALTER TABLE fulfillment_tasks DROP COLUMN IF EXISTS claim_submission_json; diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index cf973626..2fece3cc 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -199,12 +199,11 @@ export async function updateTask( attempt_count = $10, last_error = $11, context_json = $12::jsonb, - claim_submission_json = $13::jsonb, - claimed_at = $14, - role_confirmed_at = $15, - redeemed_at = $16, - updated_at = $17 - WHERE id = $18 + claimed_at = $13, + role_confirmed_at = $14, + redeemed_at = $15, + updated_at = $16 + WHERE id = $17 `, [ next.task_status, @@ -219,7 +218,6 @@ 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/claim-skin.ts b/apps/backend/src/routes/claim-skin.ts deleted file mode 100644 index 56b50441..00000000 --- a/apps/backend/src/routes/claim-skin.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 7afebb0c..3046732e 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -1,6 +1,5 @@ -import { buildClaimUrl, buildSkinClaimUrl } from '../claim/claim-service.js' +import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js' import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js' -import { parseSkinClaimSubmission } from '../claim/claim-skin-service.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { listOrderItemsByOrderId, @@ -37,7 +36,7 @@ import { } from '../../domain/task-status.js' import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js' import { getDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js' -import { isDouyinMashidaSkinExecutor } from '../fulfillment/executors/types.js' +import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js' import { mergeAffiliateDashSkuMapping, resolveAffiliateDashDisplayProductName, @@ -81,7 +80,7 @@ import type { AdminTaskListQueryInput, AdminViewerSessionInput, } from '../../types/admin/read-inputs.js' -import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js' +import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js' export async function getAdminOrders( query: AdminOrderListQueryInput = {}, @@ -310,11 +309,10 @@ export async function getAdminTaskDetail( const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) const viewerContext = createAdminViewerContext(session) - // 统一对外领取入口为本站领取链接;平台 H5 仅作内部上下文。 - // ⚠️ 皮肤自动发货走**路径式**领取页(`/`): - // 全局 buildClaimUrl 用的是 orders.claimBaseUrl(默认 hash 形式 `…/#/claim`), - // 那是**打手**领取页;皮肤任务若用它,运营复制给买家会把人带进绑定 UID 的打手流程。 - const claimUrl = resolveAdminTaskClaimUrl(task, claimToken) + // 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key): + // 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程); + // 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。 + const claimUrl = resolveAdminTaskClaimUrl(claimToken, order) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() const kuaishouCloudFulfillment = @@ -390,11 +388,6 @@ export async function getAdminTaskDetail( claimUrl, } : null, - // 皮肤自动发货:买家在领取页提交的游戏账号。运营要靠它发货,但属买家隐私, - // 因此与 claimToken 的明文一样按 canViewSensitiveTaskData 门禁下发。 - claimSubmission: viewerContext.canViewSensitiveTaskData - ? mapClaimSubmissionForAdmin(task) - : null, artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, screenshotUrl, review: { @@ -443,48 +436,32 @@ export async function getAdminTaskDetail( /** * 后台展示的领取链接。 * - * - 皮肤自动发货(`douyin_mashida_skin`):`claimLinkBase` + token(路径式,买家领取页); - * 未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。 - * - 其它执行器:沿用全局 `buildClaimUrl`(hash 打手领取页),历史行为不变。 + * - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页, + * 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。 + * - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。 + * + * ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道, + * 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。 */ function resolveAdminTaskClaimUrl( - task: Pick, claimToken: { token: string } | null | undefined, + order: Pick | null | undefined, ): string { const token = String(claimToken?.token || '').trim() if (!token) { return '' } - if (isDouyinMashidaSkinExecutor(task.executor_key)) { - const skinUrl = buildSkinClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token) - if (skinUrl) { - return skinUrl + if (isDouyinMashidaProvider(order?.provider)) { + const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token) + if (claimUrl) { + return claimUrl } } return buildClaimUrl(token) } -/** - * 皮肤自动发货买家提交内容(游戏账号)。 - * - * 只回传发货必需的字段;无提交(尚未领取)返回 null,前端据此区分「待领取」。 - * 敏感数据,调用方必须先判 `canViewSensitiveTaskData`。 - */ -function mapClaimSubmissionForAdmin(task: Partial): JsonRecord | null { - const submission = parseSkinClaimSubmission(task) - const gameAccount = String(submission.gameAccount || '').trim() - if (!gameAccount) { - return null - } - - return { - gameAccount, - submittedAt: String(submission.submittedAt || '').trim(), - } -} - function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null diff --git a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts index f475a430..db3209f4 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,11 +41,11 @@ function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) { secretMasked: maskSecret(config.secret), hasSecret: Boolean(config.secret), certLink: config.certLink, - /** 皮肤自动发货领取页基址(路径式,如 https://x.ngrok-free.app/claim) */ + /** 买家领取页基址(affiliate-dash 领取流程,如 https://<域名>/claim) */ claimLinkBase: config.claimLinkBase, - /** 皮肤自动发货「付款后发消息」模板,占位符 {link}/{sku}/{orderNo}/{productTitle} */ + /** 付款后发领取链接的 message 模板,占位符 {link}/{sku}/{orderNo}/{productTitle} */ messageTemplate: config.messageTemplate, - /** 皮肤自动发货「付款后发消息」开关 */ + /** 「付款后发领取链接」开关 */ claimMessageEnabled: config.claimMessageEnabled, messageType: config.messageType, afterVerifyLink: config.afterVerifyLink, @@ -54,7 +54,7 @@ function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) { handledTopics: config.handledTopics, /** * 联调自查: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 fae5eb35..341f12bf 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -4,8 +4,14 @@ import { } from '../../../repositories/claim-token-repo.js' 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 { getOrderById } from '../../../repositories/order-repo.js' +import { + createTaskClaimToken, + buildClaimUrl, + buildConfiguredClaimUrl, +} from '../../claim/claim-service.js' +import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js' +import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.js' import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js' import { preparePaidFulfillmentTask } from '../../fulfillment/executors/registry.js' import { @@ -13,10 +19,7 @@ import { canRegenerateClaimLinkStatus, TASK_STATUS, } from '../../../domain/task-status.js' -import { - isDouyinMashidaSkinExecutor, - isRetiredFulfillmentExecutor, -} from '../../fulfillment/executors/types.js' +import { isRetiredFulfillmentExecutor } from '../../fulfillment/executors/types.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { @@ -38,6 +41,7 @@ import type { AdminTaskActionResponse, AdminTaskManualDispatchResponse, } from '../../../types/admin/write-models.js' +import type { TaskRow } from '../../../types/repository/rows.js' export async function closeAdminTask( taskId: AdminEntityIdInput, @@ -226,7 +230,7 @@ export async function retryAdminTask( } if (claimToken) { - response.claimUrl = resolveAdminTaskClaimUrl(effectiveTask, claimToken) + response.claimUrl = await resolveAdminTaskClaimUrl(effectiveTask, claimToken) response.token = claimToken } @@ -234,15 +238,32 @@ export async function retryAdminTask( } /** - * 后台回显的领取链接口径: - * - 皮肤自动发货任务:路径式皮肤领取链接(`claimLinkBase` + token),**不能**用 hash 版通用领取页; + * 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key): + * - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程); + * 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空; * - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。 */ -function resolveAdminTaskClaimUrl(task: { executor_key?: unknown }, token: string): string { - if (isDouyinMashidaSkinExecutor(task.executor_key)) { - return resolveDouyinMashidaSkinClaimUrl(token) +async function resolveAdminTaskClaimUrl( + task: Pick, + token: string, +): Promise { + const normalizedToken = String(token || '').trim() + if (!normalizedToken) { + return '' } - return buildClaimUrl(token) + + const order = await getOrderById(task.order_id) + if (isDouyinMashidaProvider(order?.provider)) { + const claimUrl = buildConfiguredClaimUrl( + getDouyinMashidaSourceConfig().claimLinkBase, + normalizedToken, + ) + if (claimUrl) { + return claimUrl + } + } + + return buildClaimUrl(normalizedToken) } export async function regenerateAdminTaskClaimLink( @@ -323,7 +344,7 @@ export async function regenerateAdminTaskClaimLink( return { task: mapTaskActionPayload(updatedTask), - claimUrl: resolveAdminTaskClaimUrl(updatedTask, claimToken.token), + claimUrl: await 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 1bb2c83d..0b4ae571 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -36,17 +36,6 @@ 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 46211fe5..a5758d9c 100644 --- a/apps/backend/src/services/claim/claim-service.test.ts +++ b/apps/backend/src/services/claim/claim-service.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { resolveClaimTokenExpiration, buildSkinClaimUrl } from './claim-service.js' +import { + buildConfiguredClaimUrl, + pickOrderItemClaimTask, + resolveClaimTokenExpiration, +} from './claim-service.js' test('领取链接默认可配置为长期有效', () => { assert.equal(resolveClaimTokenExpiration('2026-08-11T08:00:00.000Z', 0), null) @@ -14,24 +18,69 @@ test('领取链接保留正整数小时的限时配置能力', () => { ) }) -test('皮肤领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => { +test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => { assert.equal( - buildSkinClaimUrl('https://x.ngrok-free.app/claim', 'AbC123'), + buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'), + 'https://x.ngrok-free.app/#/claim/AbC123', + ) + assert.equal( + buildConfiguredClaimUrl('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'), + buildConfiguredClaimUrl('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), '') +test('配置化领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => { + assert.equal(buildConfiguredClaimUrl('', 'AbC123'), '') + assert.equal(buildConfiguredClaimUrl(undefined, 'AbC123'), '') + assert.equal(buildConfiguredClaimUrl('https://x/claim', ''), '') + assert.equal(buildConfiguredClaimUrl('https://x/claim', null), '') +}) + +test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => { + const picked = pickOrderItemClaimTask( + [ + { id: 12, order_item_id: 7, claim_token: 'token-12' }, + { id: 10, order_item_id: 7, primary_claim_token: 'token-10' }, + { id: 9, order_item_id: 7, claim_token: '' }, + { id: 11, order_item_id: 8, claim_token: 'token-11' }, + ], + 7, + ) + + assert.deepEqual(picked, { + taskId: 10, + token: 'token-10', + taskCount: 3, + tokenTaskCount: 2, + }) +}) + +test('pickOrderItemClaimTask 区分「有任务但没 token」与「压根没任务」', () => { + const withoutToken = pickOrderItemClaimTask( + [ + { id: 1, order_item_id: 7, claim_token: '' }, + { id: 2, order_item_id: 7, claim_token: '' }, + ], + 7, + ) + assert.equal(withoutToken.taskId, null) + assert.equal(withoutToken.token, '') + assert.equal(withoutToken.taskCount, 2) + assert.equal(withoutToken.tokenTaskCount, 0) + + const empty = { taskId: null, token: '', taskCount: 0, tokenTaskCount: 0 } + assert.deepEqual( + pickOrderItemClaimTask([{ id: 1, order_item_id: 8, claim_token: 'x' }], 7), + empty, + ) + assert.deepEqual(pickOrderItemClaimTask([], 7), empty) + assert.deepEqual(pickOrderItemClaimTask(null, 7), empty) + assert.deepEqual( + pickOrderItemClaimTask([{ id: 1, order_item_id: 7, claim_token: 'x' }], ''), + empty, + ) }) diff --git a/apps/backend/src/services/claim/claim-service.ts b/apps/backend/src/services/claim/claim-service.ts index cf25c676..4f7621d7 100644 --- a/apps/backend/src/services/claim/claim-service.ts +++ b/apps/backend/src/services/claim/claim-service.ts @@ -1,8 +1,11 @@ import { runtimeConfig } from '../../config/runtime.js' import { createClaimToken } from '../../repositories/claim-token-repo.js' +import { listTasksByOrderId } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' +import { logIntegration } from '../../utils/logger.js' import { addHours, nowIso } from '../../utils/time.js' import { randomToken } from '../../utils/random.js' +import type { TaskRow } from '../../types/repository/rows.js' type TaskClaimToken = NonNullable>> & { claimUrl: string @@ -45,21 +48,25 @@ export function resolveClaimTokenExpiration( return Number.isFinite(ttlHours) && ttlHours > 0 ? addHours(createdAt, ttlHours) : null } +/** + * 全局默认领取链接:`runtimeConfig.orders.claimBaseUrl` + `/` + token。 + * + * ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接 + * 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。 + */ export function buildClaimUrl(token: string): string { const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim() return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token } /** - * 「皮肤自动发货」领取链接:`${领取页基址去尾斜杠}/${token}`。 + * 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。 * - * ⚠️ 与 `buildClaimUrl` 刻意分开,绝不互相回退: - * `buildClaimUrl` 读 `runtimeConfig.orders.claimBaseUrl`,默认值是**hash 形式**的打手领取页 - * (`http://127.0.0.1:5173/#/claim`,会走游戏 UID 绑定那套流程);皮肤买家页是**路径式**独立页面 - * (`/claim/:token`),基址只能来自抖音配置的 `claimLinkBase`。 + * 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页), + * 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。 * 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。 */ -export function buildSkinClaimUrl(baseUrl: unknown, token: unknown): string { +export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string { const base = String(baseUrl || '') .trim() .replace(/\/+$/, '') @@ -69,3 +76,112 @@ export function buildSkinClaimUrl(baseUrl: unknown, token: unknown): string { } return `${base}/${normalizedToken}` } + +export type OrderItemClaimLink = { + /** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */ + claimUrl: string + /** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */ + taskId?: number + /** 该订单项下的履约任务总数(含没有 token 的单元),供调用方判断「有任务但还没有 token」 */ + taskCount: number +} + +/** 履约任务上的领取 token:主 claim token 优先,其次任务自身的 claim_token。 */ +export function resolveTaskClaimToken(task: Partial | null | undefined): string { + return String(task?.primary_claim_token || task?.claim_token || '').trim() +} + +export type OrderItemClaimTaskPick = { + /** 命中的履约单元(task id 最小且带 token);没有则为 null */ + taskId: number | null + token: string + /** 该订单项下的履约任务总数,不论有没有 token */ + taskCount: number + /** 其中带 claim token 的单元数(>1 时会有多个领取链接) */ + tokenTaskCount: number +} + +/** + * 纯函数:从订单的履约任务里挑出该订单项的领取单元。 + * + * 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token; + * 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。 + */ +export function pickOrderItemClaimTask( + tasks: Array> | null | undefined, + orderItemId: number | string, +): OrderItemClaimTaskPick { + const empty: OrderItemClaimTaskPick = { + taskId: null, + token: '', + taskCount: 0, + tokenTaskCount: 0, + } + + const normalizedOrderItemId = Number(orderItemId) + if (!Number.isFinite(normalizedOrderItemId)) { + return empty + } + + const matched = (Array.isArray(tasks) ? tasks : []) + .filter((task) => Number(task?.order_item_id) === normalizedOrderItemId) + .map((task) => ({ taskId: Number(task?.id), token: resolveTaskClaimToken(task) })) + .filter((entry) => Number.isFinite(entry.taskId)) + + const withToken = matched + .filter((entry) => Boolean(entry.token)) + .sort((left, right) => left.taskId - right.taskId) + + const picked = withToken[0] + return { + taskId: picked ? picked.taskId : null, + token: picked ? picked.token : '', + taskCount: matched.length, + tokenTaskCount: withToken.length, + } +} + +/** + * 通用领取链接解析(不再依赖任何具体执行器): + * 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。 + * + * 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token, + * 因此这里只按 `order_item_id` 找任务、**不看 executor_key**: + * 换执行器不会再让领取链接解析失效。 + */ +export async function resolveOrderItemClaimLink(input: { + orderId: number | string + orderItemId: number | string + claimLinkBase: unknown +}): Promise { + const orderId = Number(input.orderId) + if (!Number.isFinite(orderId)) { + return { claimUrl: '', taskCount: 0 } + } + + const picked = pickOrderItemClaimTask(await listTasksByOrderId(orderId), input.orderItemId) + + if (picked.tokenTaskCount > 1) { + logIntegration( + '[claim/claim-service]', + '同一订单项存在多个履约领取单元,领取链接取 task id 最小的一个', + { + orderId, + orderItemId: Number(input.orderItemId), + tokenTaskCount: picked.tokenTaskCount, + pickedTaskId: picked.taskId, + }, + { level: 'warn' }, + ) + } + + if (picked.taskId === null || !picked.token) { + return { claimUrl: '', taskCount: picked.taskCount } + } + + return { + claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token), + taskId: picked.taskId, + taskCount: picked.taskCount, + } +} diff --git a/apps/backend/src/services/claim/claim-skin-service.test.ts b/apps/backend/src/services/claim/claim-skin-service.test.ts deleted file mode 100644 index 26956857..00000000 --- a/apps/backend/src/services/claim/claim-skin-service.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -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 deleted file mode 100644 index ba6fc036..00000000 --- a/apps/backend/src/services/claim/claim-skin-service.ts +++ /dev/null @@ -1,324 +0,0 @@ -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/claim/kuaishou-cloud-claim-context.test.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.test.ts index 4e0a4931..6beba4ec 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.test.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { CLAIM_TERMINAL_STATUSES } from './kuaishou-cloud-claim-context.js' +import { + CLAIM_TERMINAL_STATUSES, + resolveClaimProductImageUrl, +} from './kuaishou-cloud-claim-context.js' import { TASK_STATUS } from '../../domain/task-status.js' test('CLAIM_TERMINAL_STATUSES 包含 closed 与 expired 状态', () => { @@ -10,3 +13,31 @@ test('CLAIM_TERMINAL_STATUSES 包含 closed 与 expired 状态', () => { assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.PAID), false) assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.LINK_GENERATED), false) }) + +test('领取页物品图:内部 SKU 落哪个字段都能找到图(多字段回退)', () => { + // 正常口径:sku_code 就是中文内部 SKU + assert.equal( + resolveClaimProductImageUrl({ sku_code: '幸运币2个', sku_name: '【测试专用】引流标题' }), + '/images/products/lucky_coin_x2.webp', + ) + + // sku_code 被 affiliate-dash 英文 sku 覆盖:靠 spec_json.code 兜回来 + assert.equal( + resolveClaimProductImageUrl({ + sku_code: 'lucky_coin_x2', + sku_name: '幸运币2个', + spec_json: JSON.stringify({ code: '幸运币2个' }), + }), + '/images/products/lucky_coin_x2.webp', + ) + + // 只剩中文 sku_name 时也能兜底 + assert.equal( + resolveClaimProductImageUrl({ sku_code: 'lucky_coin_x2', sku_name: '套装-浪漫天命' }), + '/images/products/suit_romantic_destiny.webp', + ) + + // 都映射不到 → 空串(前端展示占位块,不猜路径) + assert.equal(resolveClaimProductImageUrl({ sku_code: '未知商品', sku_name: '' }), '') + assert.equal(resolveClaimProductImageUrl({}), '') +}) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index c445a338..ac7f8f05 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -8,6 +8,7 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json import { nowIso } from '../../utils/time.js' import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' import { buildClaimUrl } from './claim-service.js' +import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js' import { buildClaimIdentityPayload } from './claim-identity.js' import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js' import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' @@ -188,6 +189,53 @@ function buildAffiliateDashClaimDetailPayload({ } } +/** + * 领取页物品图片(内部 SKU → 图片 URL)。 + * + * 内部 SKU(中文,如「幸运币2个」)在不同写入口径下可能落在不同字段:平台 `code` 先写进 + * `sku_code`,履约解析后 `sku_code` 又可能被 affiliate-dash 的英文 sku 覆盖。 + * 这里以「能映射出图片」为准则逐个回退,避免换口径就丢图;都映射不到返回空串(前端占位)。 + */ +export function resolveClaimProductImageUrl(orderItem: Partial): string { + const spec = parseJsonRecord(orderItem.spec_json) + const snapshot = parseJsonRecord(orderItem.item_snapshot_json) + + return pickFirstNonEmptyText([ + resolveProductImageUrl(spec.code), + resolveProductImageUrl(snapshot.code), + resolveProductImageUrl(orderItem.sku_name), + resolveProductImageUrl(orderItem.sku_code), + ]) +} + +function parseJsonRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + const text = String(value ?? '').trim() + if (!text) { + return {} + } + try { + const parsed: unknown = JSON.parse(text) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +function pickFirstNonEmptyText(values: unknown[]): string { + for (const value of values) { + const text = String(value ?? '').trim() + if (text) { + return text + } + } + return '' +} + function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { const executorKey = String(task.executor_key || '').trim() const context = parseTaskContext(task) @@ -217,6 +265,8 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: product: { title: productTitle, skuCode: String(orderItem.sku_code || '').trim(), + // 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp + imageUrl: resolveClaimProductImageUrl(orderItem), quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), isBundle: false, items: [ @@ -263,6 +313,8 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }: product: { title: productTitle, skuCode: String(orderItem.sku_code || '').trim(), + // 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp + imageUrl: resolveClaimProductImageUrl(orderItem), quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), isBundle: false, items: [ diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index f49d9e6d..a5eb75d4 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -1,6 +1,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js' import { getTaskById, updateTask } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' +import { logIntegration } from '../../utils/logger.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import type { JsonObject } from '../../types/json.js' @@ -14,13 +15,15 @@ import { getAffiliateDashBindResult, submitAffiliateDashDelivery, } from '../platforms/affiliate-dash/order-service.js' +import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js' +import { verifyCertificateForOrderItem } from '../redeem/certificate-service.js' import { assertValidClaimUid, canUpdateClaimUid, getClaimIdentityFromTask, } from './claim-identity.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' -import type { TaskRow } from '../../types/repository/rows.js' +import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type ClaimDetailPayload = ReturnType @@ -456,11 +459,48 @@ export async function submitAffiliateDashClaim( if (refreshedTask) { await syncAffiliateDashTaskStatus(refreshedTask, { snapshotOnly: true }) } + + // 交付完成(买家扫码绑定 + 系统比对「绑定 UID == 填的 UID」通过): + // 抖音码速达订单要在这里把**平台券**核销掉(幂等,已核销直接返回)。 + // 刻意不 await:平台核销在业务错误时要 ~10s 才返回,挂在领取响应上会让买家以为领取失败; + // 失败只记 warn,绝不影响买家侧的领取完成。 + verifyDouyinCertificateAfterClaim(context) } return getKuaishouCloudClaimDetail(token) } +/** + * 抖音订单在 affiliate-dash 交付完成时 best-effort 核销平台券。 + * + * 非抖音订单(provider 不匹配)直接跳过,保持原有行为。 + */ +function verifyDouyinCertificateAfterClaim(context: { + order: OrderRow + orderItem: OrderItemRow +}): void { + if (!isDouyinMashidaProvider(context.order.provider)) { + return + } + + void verifyCertificateForOrderItem({ + order: context.order, + orderItem: context.orderItem, + }).catch((error) => { + logIntegration( + '[claim]', + '交付完成后核销平台券失败(不影响买家领取完成,需人工跟进)', + { + orderId: context.order.id, + orderItemId: context.orderItem.id, + platformOrderId: context.order.platform_order_id, + error: error instanceof Error ? error.message : String(error), + }, + { level: 'warn' }, + ) + }) +} + type AffiliateDashFlowSnapshot = ReturnType function resolveAffiliateDashExpectedGameAccount( 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 deleted file mode 100644 index bf26d2e2..00000000 --- a/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index e819db7b..00000000 --- a/apps/backend/src/services/fulfillment/executors/douyin-mashida-skin-executor.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 ad9378d6..2842807e 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.ts @@ -1,6 +1,5 @@ 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 { @@ -15,7 +14,6 @@ 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 8ed1e613..c7d45002 100644 --- a/apps/backend/src/services/fulfillment/executors/types.ts +++ b/apps/backend/src/services/fulfillment/executors/types.ts @@ -4,8 +4,6 @@ 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 已下线,仅历史任务识别 */ @@ -70,10 +68,6 @@ 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 66d2fa14..db71d5a0 100644 --- a/apps/backend/src/services/fulfillment/planner.test.ts +++ b/apps/backend/src/services/fulfillment/planner.test.ts @@ -109,25 +109,26 @@ test('planFulfillmentTaskForOrderItem affiliate 缺 sku 时返回 null', async ( assert.equal(plan, null) }) -test('planFulfillmentTaskForOrderItem 为抖音皮肤商品生成领取链接履约计划', async () => { +test('planFulfillmentTaskForOrderItem 为抖音命中商品生成 affiliate_dash 履约计划', 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个', + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, + affiliateDash: { + matchMode: 'affiliate_dash_sku_mapping', + productNo: '幸运币2个', sku: 'lucky_coin_x2', - productTitle: '【测试专用】和平精英皮肤小店入口', + skuName: '幸运币2个', }, }, }), getProfileByKey: async (profileKey) => ({ id: 21, profile_key: profileKey, - name: '抖音皮肤自动发货', + name: 'affiliate-dash 履约', executor_key: profileKey, requires_claim: false, auto_dispatch: false, @@ -135,33 +136,31 @@ test('planFulfillmentTaskForOrderItem 为抖音皮肤商品生成领取链接履 }) assert.ok(plan) - assert.equal(plan.executorKey, 'douyin_mashida_skin') - assert.equal(plan.profileKey, 'douyin_mashida_skin') + assert.equal(plan.executorKey, 'affiliate_dash') + assert.equal(plan.profileKey, 'affiliate_dash') 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') + const affiliateDash = plan.context.affiliateDash as Record | null + assert.ok(affiliateDash) + assert.equal(affiliateDash.sku, 'lucky_coin_x2') + assert.equal(affiliateDash.productName, '幸运币2个') }) -test('planFulfillmentTaskForOrderItem 抖音皮肤缺内部 SKU 时返回 null', async () => { +test('planFulfillmentTaskForOrderItem 抖音订单缺 affiliate_dash 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' }, + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, }, }), getProfileByKey: async (profileKey) => ({ id: 21, profile_key: profileKey, - name: '抖音皮肤自动发货', + name: 'affiliate-dash 履约', executor_key: profileKey, }), }) diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts index f65385c1..63cc3ae7 100644 --- a/apps/backend/src/services/fulfillment/planner.ts +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -1,12 +1,7 @@ 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, - isDouyinMashidaSkinExecutor, -} from './executors/types.js' +import { FULFILLMENT_EXECUTOR_KEYS, isAffiliateDashExecutor } from './executors/types.js' export type FulfillmentBindingLike = { id: number @@ -96,7 +91,6 @@ function buildFulfillmentTaskContext({ fulfillmentConfig: JsonObject }) { const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash) - const douyinSkinConfig = parseJsonObject(fulfillmentConfig.douyinSkin) return { profileKey, @@ -128,17 +122,6 @@ 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, } } @@ -151,10 +134,6 @@ 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) } @@ -202,46 +181,8 @@ 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 fee1feab..2013ad24 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,7 @@ import test from 'node:test' import { matchAffiliateDashSku, matchAffiliateDashSkuPassthrough, - matchDouyinMashidaSkinSku, + matchDouyinMashidaAffiliateDashSku, normalizeAffiliateDashMappingKey, resolveOrderItemForFulfillment, } from './product-resolution-service.js' @@ -100,7 +100,7 @@ test('reverseAffiliateDashSkuToProductNo 未命中返回空串', () => { assert.equal(reverseAffiliateDashSkuToProductNo(''), '') }) -// ---- 抖音码速达(抖店)皮肤商品分流 ---- +// ---- 抖音码速达(抖店)订单分流:复用 affiliate_dash 通道 ---- const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用' @@ -126,14 +126,14 @@ function selectedExecutorKeyOf( 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('matchDouyinMashidaAffiliateDashSku 只认权威映射表并做归一化', () => { + assert.equal(matchDouyinMashidaAffiliateDashSku('幸运币2个'), 'lucky_coin_x2') + assert.equal(matchDouyinMashidaAffiliateDashSku(' 幸运币2个 '), 'lucky_coin_x2') + assert.equal(matchDouyinMashidaAffiliateDashSku('不存在的皮肤'), '') + assert.equal(matchDouyinMashidaAffiliateDashSku(''), '') }) -test('抖音订单:内部 SKU 命中映射表 → 皮肤自动发货', async () => { +test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道自动发货', async () => { const item = await resolveOrderItemForFulfillment({ provider: 'douyin_mashida', platform: 'doudian', @@ -141,16 +141,18 @@ test('抖音订单:内部 SKU 命中映射表 → 皮肤自动发货', async ( }) 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, { + assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash') + // 走 affiliate_dash 通道后与 91 同口径:skuCode 是英文 sku、skuName 是中文商品名 + assert.equal(item.skuCode, 'lucky_coin_x2') + assert.equal(item.skuName, '幸运币2个') + // 快照里的 affiliateDash 就是 affiliate_dash 执行器/规划器要用的映射结果 + assert.deepEqual(item.snapshot.affiliateDash, { matchMode: 'affiliate_dash_sku_mapping', - internalSku: '幸运币2个', + productNo: '幸运币2个', sku: 'lucky_coin_x2', - productTitle: DOUYIN_SKIN_TITLE, + skuName: '幸运币2个', }) - assert.equal(resolveProductImageUrl(item.skuCode), '/images/products/lucky_coin_x2.webp') + assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp') }) test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', async () => { @@ -161,7 +163,7 @@ test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', }) assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch') - assert.equal(item.snapshot.douyinSkin, null) + assert.equal(item.snapshot.affiliateDash, null) // 引流长标题作为 skuName,内部 SKU 作为 skuCode assert.equal(item.skuCode, '不存在的皮肤') assert.equal(item.skuName, DOUYIN_SKIN_TITLE) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 11f4bd12..0989f8c9 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -44,14 +44,6 @@ 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 @@ -59,7 +51,6 @@ type FulfillmentItemCandidate = { externalSkuNameNormalized: string resolvedSkuCode: string affiliateDashMatch: AffiliateDashSkuMatch | null - douyinSkinMatch: DouyinMashidaSkinMatch | null fulfillmentRoute: FulfillmentRouteResult | null isConfigured: boolean } @@ -91,7 +82,6 @@ export async function resolveOrderItemForFulfillment({ externalSkuName, externalSkuNameNormalized, affiliateDashMatch, - douyinSkinMatch, fulfillmentRoute, } = candidate @@ -130,14 +120,6 @@ 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, } @@ -231,7 +213,6 @@ async function resolveConfiguredItemCandidate({ externalSkuNameNormalized, resolvedSkuCode, affiliateDashMatch, - douyinSkinMatch: null, fulfillmentRoute, isConfigured: Boolean(selectedExecutorKey), } @@ -256,7 +237,6 @@ async function resolveConfiguredItemCandidate({ externalSkuNameNormalized, resolvedSkuCode, affiliateDashMatch: null, - douyinSkinMatch: null, fulfillmentRoute: null, isConfigured: false, } @@ -267,8 +247,9 @@ async function resolveConfiguredItemCandidate({ * * 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品), * `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。 - * 因此分流与图片都按内部 SKU 走: - * - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 皮肤自动发货(本站领取链接); + * 因此分流都按内部 SKU 走: + * - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 复用现成的 `affiliate_dash` 通道 + * (买家输 UID → 二维码 → 扫码绑定 → 比对 UID → 发货完成); * - 未命中 → 显式映射拿不到通道,按现有语义回退 `manual_dispatch`(接单平台/人工)。 */ function resolveDouyinMashidaItemCandidate({ @@ -286,29 +267,33 @@ function resolveDouyinMashidaItemCandidate({ }): FulfillmentItemCandidate { const internalSku = pickFirstNonEmpty([externalSkuCode, externalItemId]) const productTitle = pickFirstNonEmpty([externalSkuName, internalSku]) - const skinSku = matchDouyinMashidaSkinSku(internalSku) - const douyinSkinMatch: DouyinMashidaSkinMatch | null = skinSku + // 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。 + // 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**: + // 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。 + // 不另建执行器、不另做一套页面(与旧快手皮肤自动发货完全一致)。 + const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku) + const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku ? { - sku: skinSku, - internalSku, - productTitle, + sku: dashSku, + productNo: internalSku, + skuName: internalSku, matchMode: 'affiliate_dash_sku_mapping', } : null const fulfillmentRoute = resolveFulfillmentRoute({ productName: productTitle, internalSku, - // 命中皮肤 → 皮肤自动发货;未命中 → **显式**回落人工(接单平台)。 + // 命中 → 自动发货;未命中 → **显式**回落人工(接单平台)。 // 不依赖全局 `unmatchedExecutorKey`:线上/联调环境该值可能是空串(dev 库就是空串), // 那样未命中的抖音订单会既不建任务、也不进人工,等于静默漏单。 - preferredExecutorKey: douyinSkinMatch - ? FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN + preferredExecutorKey: affiliateDashMatch + ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH : FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, candidates: [ { - executorKey: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN, - available: Boolean(douyinSkinMatch), - reason: douyinSkinMatch ? '' : '内部 SKU 未命中皮肤商品映射', + executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, + available: Boolean(affiliateDashMatch), + reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射', }, { executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, @@ -323,20 +308,19 @@ function resolveDouyinMashidaItemCandidate({ externalSkuCode, externalSkuName, externalSkuNameNormalized, - // 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,领取页图片与展示都靠它。 + // 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,展示与商品图都靠它。 resolvedSkuCode: internalSku || pickFirstNonEmpty([externalItemId, item.itemId]), - affiliateDashMatch: null, - douyinSkinMatch, + affiliateDashMatch, fulfillmentRoute, isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey), } } /** - * 内部 SKU → 英文 sku:只查权威表(与领取页图片 `resolveProductImageUrl` 同一套来源), - * 保证「能路由到皮肤执行器」与「能取到商品图」永远一致。 + * 内部 SKU → affiliate-dash 英文 sku:只查权威表(与商品图 `resolveProductImageUrl` 同一套来源), + * 保证「能路由到 affiliate-dash 自动发货」与「能取到商品图」永远一致。 */ -export function matchDouyinMashidaSkinSku(internalSku: unknown): string { +export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string { const raw = String(internalSku ?? '').trim() if (!raw) { return '' 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 a27abfb3..28748bfd 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.test.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.test.ts @@ -12,11 +12,7 @@ test('normalizeFulfillmentRoutingConfig drops retired executors and keeps affili }) assert.deepEqual(config.defaultExecutorPriority, ['affiliate_dash']) - assert.deepEqual(Object.keys(config.executors).sort(), [ - 'affiliate_dash', - 'douyin_mashida_skin', - 'manual_dispatch', - ]) + assert.deepEqual(Object.keys(config.executors).sort(), ['affiliate_dash', 'manual_dispatch']) assert.equal(config.unmatchedExecutorKey, 'manual_dispatch') }) @@ -186,7 +182,7 @@ test('normalizeFulfillmentRoutingConfig 支持按内部 SKU 的规则维度', () rules: [ { internalSku: '幸运币2个', - executorKey: 'douyin_mashida_skin', + executorKey: 'affiliate_dash', priority: 500, }, ], @@ -201,7 +197,7 @@ test('normalizeFulfillmentRoutingConfig 支持按内部 SKU 的规则维度', () assert.equal(rule.normalizedProductName, '') assert.equal(rule.internalSku, '幸运币2个') assert.equal(rule.normalizedInternalSku, '幸运币2个') - assert.equal(rule.id, '幸运币2个:douyin_mashida_skin') + assert.equal(rule.id, '幸运币2个:affiliate_dash') }) test('旧商品名规则归一化后带 matchField=product_name,语义不变', () => { @@ -221,13 +217,13 @@ test('resolveFulfillmentRoute 命中内部 SKU 规则', () => { const result = resolveFulfillmentRoute({ productName: '【测试专用】和平精英皮肤小店入口', internalSku: '幸运币2个', - candidates: [{ executorKey: 'douyin_mashida_skin', available: true }], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({ - rules: [{ internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 500 }], + rules: [{ internalSku: '幸运币2个', executorKey: 'affiliate_dash', priority: 500 }], }), }) - assert.equal(result.selectedExecutorKey, 'douyin_mashida_skin') + assert.equal(result.selectedExecutorKey, 'affiliate_dash') assert.equal(result.reason, '命中商品路由规则') assert.equal(result.matchedRule?.productName, '幸运币2个') }) @@ -238,7 +234,7 @@ test('resolveFulfillmentRoute 内部 SKU 规则支持归一化与 contains', () { internalSku: '幸运币', matchType: 'contains', - executorKey: 'douyin_mashida_skin', + executorKey: 'affiliate_dash', priority: 500, }, ], @@ -247,15 +243,15 @@ test('resolveFulfillmentRoute 内部 SKU 规则支持归一化与 contains', () const exactFullWidth = resolveFulfillmentRoute({ productName: '', internalSku: ' 幸运币2个 ', - candidates: [{ executorKey: 'douyin_mashida_skin', available: true }], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config, }) - assert.equal(exactFullWidth.selectedExecutorKey, 'douyin_mashida_skin') + assert.equal(exactFullWidth.selectedExecutorKey, 'affiliate_dash') const miss = resolveFulfillmentRoute({ productName: '', internalSku: '荣耀勋章2个', - candidates: [{ executorKey: 'douyin_mashida_skin', available: true }], + candidates: [], config, }) assert.equal(miss.selectedExecutorKey, 'manual_dispatch') @@ -269,7 +265,7 @@ test('resolveFulfillmentRoute 未命中内部 SKU 规则时商品名规则仍然 config: normalizeFulfillmentRoutingConfig({ rules: [ { productName: '荣耀套装', executorKey: 'affiliate_dash', priority: 200 }, - { internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 900 }, + { internalSku: '幸运币2个', executorKey: 'affiliate_dash', priority: 900 }, ], }), }) diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts index 2aafa351..d8b81ace 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -75,7 +75,6 @@ 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, ] 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 index fa3fa948..fa95258e 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts @@ -4,7 +4,7 @@ import test from 'node:test' import { buildDouyinMashidaClaimMessage, renderDouyinMashidaMessageTemplate, - resolveDouyinMashidaSkinClaimUrl, + resolveDouyinMashidaClaimUrl, } from './claim-link.js' import { createDefaultDouyinMashidaSourceConfig, @@ -43,18 +43,18 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板 assert.equal(config.claimMessageEnabled, false) }) -test('皮肤领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退 hash 版领取页', () => { +test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => { assert.equal( - resolveDouyinMashidaSkinClaimUrl( + resolveDouyinMashidaClaimUrl( 'AbC123', buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }), ), 'https://x.ngrok-free.app/claim/AbC123', ) - assert.equal(resolveDouyinMashidaSkinClaimUrl('AbC123', buildConfig()), '') + assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '') assert.equal( - resolveDouyinMashidaSkinClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })), + resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })), '', ) }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts index 036fa22a..68dc88e8 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts @@ -1,4 +1,4 @@ -import { buildSkinClaimUrl } from '../../claim/claim-service.js' +import { buildConfiguredClaimUrl } from '../../claim/claim-service.js' import { DEFAULT_CLAIM_MESSAGE_TEMPLATE, getDouyinMashidaSourceConfig, @@ -6,11 +6,11 @@ import { } from './source-config-service.js' /** - * 抖音码速达「皮肤自动发货」的领取链接与发货消息拼装(纯函数,便于单测)。 + * 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。 * * 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。 * ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl` - * (那是 hash 形式的打手领取页,语义完全不同)。 + * (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。 */ export type DouyinMashidaClaimMessageVars = { @@ -20,12 +20,12 @@ export type DouyinMashidaClaimMessageVars = { productTitle?: string | undefined } -/** 皮肤领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */ -export function resolveDouyinMashidaSkinClaimUrl( +/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */ +export function resolveDouyinMashidaClaimUrl( token: unknown, config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(), ): string { - return buildSkinClaimUrl(config.claimLinkBase, token) + return buildConfiguredClaimUrl(config.claimLinkBase, token) } /** @@ -48,7 +48,7 @@ export function renderDouyinMashidaMessageTemplate( }) } -/** 皮肤订单付款后发给买家的正文;`claimUrl` 为空返回空串(不发消息)。 */ +/** 付款成功后发给买家的领取正文;`claimUrl` 为空返回空串(不发消息)。 */ export function buildDouyinMashidaClaimMessage(input: { config: DouyinMashidaSourceConfig claimUrl: unknown 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 176f89fc..8e950557 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -18,7 +18,7 @@ import { buildDouyinMashidaSourceEvent, resolveDouyinMashidaParentOrderId, } from './order-mapper.js' -import { resolveOrderItemSkinClaimLink } from '../../fulfillment/executors/douyin-mashida-skin-executor.js' +import { resolveOrderItemClaimLink } from '../../claim/claim-service.js' import { buildDouyinMashidaClaimMessage } from './claim-link.js' import { getDouyinMashidaSourceConfig, @@ -192,12 +192,12 @@ async function handlePaidMessage(input: { await ensureCertificateForOrderItem({ order, orderItem, config: input.config }) certificateCount += 1 - // 皮肤自动发货:发券成功后按模板把领取链接发给买家。 + // 发券成功后按模板把领取链接发给买家。 // best-effort:通知失败只记 warn,绝不影响建单/发券主流程(平台会重推时也不会重复发券)。 - await notifyBuyerSkinClaimLink({ order, orderItem, config: input.config }).catch((error) => { + await notifyBuyerClaimLink({ order, orderItem, config: input.config }).catch((error) => { logIntegration( '[douyin-mashida/notify]', - '皮肤领取链接通知买家失败(建单/发券不受影响)', + '领取链接通知买家失败(建单/发券不受影响)', { orderId: order.id, orderItemId: orderItem.id, @@ -229,12 +229,16 @@ async function handlePaidMessage(input: { } /** - * 皮肤自动发货:付款建单 + 发券成功后,把领取链接按模板通过 `order.message` 发给买家。 + * 付款建单 + 发券成功后,把买家领取链接按模板通过 `order.message` 发给买家。 * - * 只在「该订单项确实有皮肤履约任务 + claimLinkBase 已配置 + 开关打开」时才发; - * 非皮肤订单静默跳过(不打扰现有兑换流程)。 + * 这是买家**唯一**会收到的那条领取链接(核销后不再补发第二条,见 redeem/certificate-service.ts): + * 买家点链接 → 输 UID → 出二维码 → 扫码绑定 → 系统比对「绑定 UID == 填的 UID」→ 发货完成 + * (完全复用现成的 affiliate_dash 通道)。 + * + * 只在「该订单项确实有履约任务 claim token + claimLinkBase 已配置 + 开关打开」时才发; + * 拿不到链接的订单(例如未命中映射、走人工的订单)静默跳过,不打扰现有兑换流程。 */ -async function notifyBuyerSkinClaimLink(input: { +async function notifyBuyerClaimLink(input: { order: OrderRow orderItem: OrderItemRow config: DouyinMashidaSourceConfig @@ -246,19 +250,19 @@ async function notifyBuyerSkinClaimLink(input: { return } - const skinLink = await resolveOrderItemSkinClaimLink({ + const claimLink = await resolveOrderItemClaimLink({ orderId: input.order.id, orderItemId: input.orderItem.id, - config: input.config, + claimLinkBase: input.config.claimLinkBase, }) - if (!skinLink.claimUrl) { + if (!claimLink.claimUrl) { return } const orderNo = resolveReceiptOrderId(input.order, input.orderItem) const content = buildDouyinMashidaClaimMessage({ config: input.config, - claimUrl: skinLink.claimUrl, + claimUrl: claimLink.claimUrl, sku: input.orderItem.sku_code, orderNo, productTitle: input.orderItem.sku_name, @@ -269,11 +273,11 @@ async function notifyBuyerSkinClaimLink(input: { await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config }) - logIntegration('[douyin-mashida/notify]', '已把皮肤领取链接发给买家(order.message)', { + logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', { orderId: input.order.id, orderItemId: input.orderItem.id, orderNo, - taskId: skinLink.pickedTaskId, + taskId: claimLink.taskId, }) } diff --git a/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts index a500f0e5..294ea1df 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts @@ -18,6 +18,16 @@ import { sanitizeWellFormedUnicode } from '../../../utils/json.js' export const DOUYIN_MASHIDA_PROVIDER = 'douyin_mashida' export const DOUYIN_MASHIDA_PLATFORM = 'doudian' +/** + * 是否抖音码速达订单(provider 口径)。 + * + * 履约/领取链接的判定一律用它,**不要**按 executor_key 判断: + * 抖音皮肤/礼包单已改走现成的 `affiliate_dash` 通道,按执行器判断会失效。 + */ +export function isDouyinMashidaProvider(provider: unknown): boolean { + return String(provider || '').trim() === DOUYIN_MASHIDA_PROVIDER +} + /** 内部订单状态只有 4 个值(order-service.ts:432)——抖店状态按「多对一」降级。 */ export const DOUYIN_MASHIDA_ORDER_STATUS_MAP: Record< string, 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 d2a807cd..4ee7fc5d 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,9 +18,13 @@ import { * - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。 * - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`), * **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。 - * 皮肤自动发货订单会优先用「本站领取链接」(`claimLinkBase` + token),此时 `certLink` 退化为兜底。 - * - `claimLinkBase` 是皮肤领取页的**路径式**基址(如 `https://x.ngrok-free.app/claim`); - * 它**不是**打手领取页(`runtimeConfig.orders.claimBaseUrl` 那个 hash 版),两者不能互相回退。 + * 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token), + * 此时 `certLink` 退化为兜底。 + * - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程 + * (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。 + * 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与 + * hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl` + * (两条口径不能互相回退)。 * - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。 */ @@ -32,7 +36,7 @@ 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 = [ @@ -46,13 +50,13 @@ 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),留空则不生成皮肤领取链接 */ + /** 买家领取页基址(affiliate-dash 领取流程,如 https://<域名>/claim),留空则不生成/不发领取链接 */ claimLinkBase: string - /** 皮肤自动发货:付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */ + /** 付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */ messageTemplate: string - /** 皮肤自动发货「付款后发消息」开关,默认开 */ + /** 「付款后发领取链接」开关,默认开 */ claimMessageEnabled: boolean /** order.message 的 message_type:1-订单状态 2-订单发货 4-图片 5-视频 */ messageType: number diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index c3a9f0aa..4fcd07d5 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -13,7 +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 { resolveOrderItemClaimLink } from '../claim/claim-service.js' import { createHttpError } from '../../utils/http.js' import { logIntegration } from '../../utils/logger.js' import { nowIso } from '../../utils/time.js' @@ -40,6 +40,11 @@ import { * 闭环:建单后**我们生成券码**(DYC + Crockford Base32)→ `coupons.syncV2` 交给平台展示给买家 * → 买家在 `https://<域名>/redeem` 输入订单号兑换 → `coupons.verifyV3` 上报核销。 * + * 皮肤/礼包类订单(命中 affiliate-dash 映射)**不走**上面的输入订单号兑换: + * 付款时按 `claimLinkBase` 把买家领取链接发给买家,买家在领取页输 UID → 扫码绑定 → + * 「绑定 UID == 填的 UID」即发货完成(见 claim/kuaishou-cloud-claim-service.ts), + * 完成时由该流程 best-effort 调 `verifyCertificateForOrderItem` 把平台券核销掉。 + * * 幂等: * - 发券:`(provider, order_id)` 唯一,重复回调只会复用同一张券; * - 同步:`sync_status='success'` 直接跳过,失败可重试; @@ -70,7 +75,7 @@ export async function ensureCertificateForOrderItem(input: { }) } - // 皮肤自动发货:cert_link 用「本站领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。 + // 该订单项已有领取 token 时 cert_link 用「买家领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。 const certLink = await resolveDouyinMashidaCertLink({ order: input.order, orderItem: input.orderItem, @@ -121,12 +126,13 @@ export async function ensureCertificateForOrderItem(input: { * 解析本次同步要交给平台的 `cert_link`。 * * 优先级(**只对新单生效**:已同步成功的券不会再改动): - * 1. 皮肤自动发货任务已生成 claim token,且配置了 `claimLinkBase` - * → `claimLinkBase/token`(买家领取页,路径式); + * 1. 该订单项已有履约任务 claim token,且配置了 `claimLinkBase` + * → `claimLinkBase/token`(买家领链接); * 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。 * - * 同一个订单项按数量拆成多个履约单元时会有多个 token,这里取最小 task id 的那个, - * 保证「同一张券每次解析出同一条链接」,并打 warn 留痕提示运营。 + * 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key): + * 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。 + * 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。 */ export async function resolveDouyinMashidaCertLink(input: { order: OrderRow @@ -138,21 +144,23 @@ export async function resolveDouyinMashidaCertLink(input: { return fallback } - const skinLink = await resolveOrderItemSkinClaimLink({ + const claimLink = await resolveOrderItemClaimLink({ orderId: input.order.id, orderItemId: input.orderItem.id, - config: input.config, + claimLinkBase: input.config.claimLinkBase, }) - if (!skinLink.claimUrl) { - if (skinLink.taskCount > 0) { + if (!claimLink.claimUrl) { + // 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕; + // 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。 + if (claimLink.taskCount > 0) { logIntegration( '[douyin-mashida/certificate]', - '皮肤订单尚无可用 claim token,cert_link 回退固定兑换链接', + '该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接', { orderId: input.order.id, orderItemId: input.orderItem.id, - skinTaskCount: skinLink.taskCount, + taskCount: claimLink.taskCount, }, { level: 'warn' }, ) @@ -160,21 +168,7 @@ export async function resolveDouyinMashidaCertLink(input: { 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 + return claimLink.claimUrl } /** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */ @@ -312,20 +306,36 @@ export async function verifyCertificate(input: { orderId: found.order_id, }) - // 核销成功后把「去使用链接」发给买家(best-effort): - // 兑换本身已经成功,通知失败只记错误日志,不让用户看到兑换失败。 - await notifyBuyerAfterVerify({ certificate: verified || found, config }).catch((error) => { - logIntegration( - '[douyin-mashida/certificate]', - '兑换成功通知买家失败(兑换结果不受影响)', - { certNo, orderId: found.order_id, error }, - { level: 'error' }, - ) - }) + // 抖音码速达订单**不再**在核销后补发第二条消息: + // 买家在付款建单时已经收到「唯一那条领取链接」(见 douyin-mashida/notify-service.ts), + // 之后再发一条带链接的消息会让买家拿到第二条链接、分不清该点哪个。 + // 其它平台(本表未来接入的 provider)行为保持不变:继续按 afterVerifyMessage/afterVerifyLink 通知。 + if (shouldNotifyBuyerAfterVerify(verified || found)) { + await notifyBuyerAfterVerify({ certificate: verified || found, config }).catch((error) => { + logIntegration( + '[douyin-mashida/certificate]', + '兑换成功通知买家失败(兑换结果不受影响)', + { certNo, orderId: found.order_id, error }, + { level: 'error' }, + ) + }) + } return verified || found } +/** + * 核销后是否还要给买家发「去使用链接」消息。 + * + * 抖音码速达:**不发**(买家已拿到领取链接;抖音皮肤/礼包单的领取链接本身就是完成入口)。 + * 其它平台:保持原行为(发)。 + */ +export function shouldNotifyBuyerAfterVerify( + certificate: Pick | null | undefined, +): boolean { + return String(certificate?.provider || '').trim() !== DOUYIN_MASHIDA_PROVIDER +} + /** 拼 order.message 的正文:默认文案 +(可选的)去使用链接。 */ export function buildAfterVerifyMessage(config: DouyinMashidaSourceConfig): string { const message = String(config.afterVerifyMessage || '').trim() || DEFAULT_AFTER_VERIFY_MESSAGE diff --git a/apps/backend/src/types/admin/task-detail.ts b/apps/backend/src/types/admin/task-detail.ts index 0526bbd0..b2f232cb 100644 --- a/apps/backend/src/types/admin/task-detail.ts +++ b/apps/backend/src/types/admin/task-detail.ts @@ -12,8 +12,6 @@ export type AdminTaskDetailView = { order: JsonObject | null orderItem: JsonObject | null claimToken: JsonObject | null - /** 皮肤自动发货:买家在领取页提交的游戏账号(敏感数据,按后台角色门禁下发) */ - claimSubmission: JsonObject | null artifacts: JsonObject screenshotUrl: string review: JsonObject diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 3cbef1ab..73947ff4 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -118,8 +118,6 @@ 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 523a9774..592df153 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -66,8 +66,6 @@ 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 e64ddabe..aed97fdb 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -27,18 +27,6 @@ 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() : '' @@ -66,7 +54,6 @@ export function sendRouteError( error: unknown, fallbackMessage = '服务内部错误', scope = '[route]', - options: { messageAlias?: boolean } = {}, ): void { const classification = classifyRouteError(error) const logLevel = resolveRouteErrorLogLevel(classification) @@ -77,12 +64,7 @@ export function sendRouteError( // 4xx 属预期失败(鉴权过期、参数错误等),带上完整错误对象只会刷十几行堆栈;仅 5xx 保留排查所需详情。 ...(classification.statusCode >= 500 ? { error } : {}), }) - const payload = buildErrorPayload(error, fallbackMessage) - // 个别对外接口(皮肤领取页)约定用 `message` 而非仓库通用的 `msg`; - // 这里只做别名追加,不改变 `msg` / `errorCode`,老调用方行为不受影响。 - res - .status(classification.statusCode) - .json(options.messageAlias ? { ...payload, message: payload.msg } : payload) + res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage)) } export function resolveRouteErrorLogLevel(classification: {