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

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

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

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

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

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

**隐私**
- 买家游戏账号落 `fulfillment_tasks.claim_submission_json`(迁移 096),
  **不进** `work_orders.material_json`(那是发给打手的素材);详情接口不返回券码明文/手机号/地址/open_id
This commit is contained in:
yml2213
2026-10-09 15:06:43 +08:00
parent ecc8ffaee4
commit 56a8184576
30 changed files with 1993 additions and 32 deletions
@@ -5,6 +5,7 @@ import {
import { updateTask } from '../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createTaskClaimToken, buildClaimUrl } from '../../claim/claim-service.js'
import { resolveDouyinMashidaSkinClaimUrl } from '../../platforms/douyin-mashida/claim-link.js'
import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js'
import { preparePaidFulfillmentTask } from '../../fulfillment/executors/registry.js'
import {
@@ -12,7 +13,10 @@ import {
canRegenerateClaimLinkStatus,
TASK_STATUS,
} from '../../../domain/task-status.js'
import { isRetiredFulfillmentExecutor } from '../../fulfillment/executors/types.js'
import {
isDouyinMashidaSkinExecutor,
isRetiredFulfillmentExecutor,
} from '../../fulfillment/executors/types.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import {
@@ -222,13 +226,25 @@ export async function retryAdminTask(
}
if (claimToken) {
response.claimUrl = buildClaimUrl(claimToken)
response.claimUrl = resolveAdminTaskClaimUrl(effectiveTask, claimToken)
response.token = claimToken
}
return response
}
/**
* 后台回显的领取链接口径:
* - 皮肤自动发货任务:路径式皮肤领取链接(`claimLinkBase` + token),**不能**用 hash 版通用领取页;
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
*/
function resolveAdminTaskClaimUrl(task: { executor_key?: unknown }, token: string): string {
if (isDouyinMashidaSkinExecutor(task.executor_key)) {
return resolveDouyinMashidaSkinClaimUrl(token)
}
return buildClaimUrl(token)
}
export async function regenerateAdminTaskClaimLink(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
@@ -307,7 +323,7 @@ export async function regenerateAdminTaskClaimLink(
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimToken.claimUrl,
claimUrl: resolveAdminTaskClaimUrl(updatedTask, claimToken.token),
token: claimToken.token,
}
}