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:
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
|
||||
import { resolveOrderItemSkinClaimLink } from '../fulfillment/executors/douyin-mashida-skin-executor.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -69,6 +70,13 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 皮肤自动发货:cert_link 用「本站领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。
|
||||
const certLink = await resolveDouyinMashidaCertLink({
|
||||
order: input.order,
|
||||
orderItem: input.orderItem,
|
||||
config,
|
||||
})
|
||||
|
||||
let certificate = await findRedeemCertificateByOrder({ provider, orderId: receiptOrderId })
|
||||
|
||||
if (!certificate) {
|
||||
@@ -78,7 +86,7 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
parentOrderId: String(input.order.platform_order_id || '').trim(),
|
||||
localOrderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
certLink: config.certLink,
|
||||
certLink,
|
||||
grantTime: nowIso(),
|
||||
rawPayload: {
|
||||
source: provider,
|
||||
@@ -106,17 +114,78 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
return certificate
|
||||
}
|
||||
|
||||
return syncCertificateToPlatform(certificate, config)
|
||||
return syncCertificateToPlatform(certificate, config, certLink)
|
||||
}
|
||||
|
||||
/** 调 `coupons.syncV2` 把券码 + 固定兑换链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
|
||||
/**
|
||||
* 解析本次同步要交给平台的 `cert_link`。
|
||||
*
|
||||
* 优先级(**只对新单生效**:已同步成功的券不会再改动):
|
||||
* 1. 皮肤自动发货任务已生成 claim token,且配置了 `claimLinkBase`
|
||||
* → `claimLinkBase/token`(买家领取页,路径式);
|
||||
* 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。
|
||||
*
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,这里取最小 task id 的那个,
|
||||
* 保证「同一张券每次解析出同一条链接」,并打 warn 留痕提示运营。
|
||||
*/
|
||||
export async function resolveDouyinMashidaCertLink(input: {
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
config: DouyinMashidaSourceConfig
|
||||
}): Promise<string> {
|
||||
const fallback = String(input.config.certLink || '').trim()
|
||||
if (!String(input.config.claimLinkBase || '').trim()) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const skinLink = await resolveOrderItemSkinClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
config: input.config,
|
||||
})
|
||||
|
||||
if (!skinLink.claimUrl) {
|
||||
if (skinLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'皮肤订单尚无可用 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
skinTaskCount: skinLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (skinLink.taskCount > 1) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'同一订单项存在多个皮肤履约单元,cert_link 取首个 token',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
skinTaskCount: skinLink.taskCount,
|
||||
pickedTaskId: skinLink.pickedTaskId,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return skinLink.claimUrl
|
||||
}
|
||||
|
||||
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
|
||||
async function syncCertificateToPlatform(
|
||||
certificate: RedeemCertificateRow,
|
||||
config: DouyinMashidaSourceConfig,
|
||||
certLinkOverride?: string | undefined,
|
||||
): Promise<RedeemCertificateRow> {
|
||||
const certLink = String(config.certLink || '').trim()
|
||||
const certLink = String(certLinkOverride ?? config.certLink ?? '').trim()
|
||||
if (!certLink) {
|
||||
const message = '未配置抖音码速达兑换链接(certLink),券码暂不对外同步'
|
||||
const message = '未配置抖音码速达兑换链接(certLink / claimLinkBase),券码暂不对外同步'
|
||||
await markRedeemCertificateSyncFailed({ id: certificate.id, error: message })
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
@@ -168,6 +237,32 @@ async function syncCertificateToPlatform(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取页核销入口:按「本地订单 + 订单项」找到该订单的券码并核销(幂等)。
|
||||
* 找不到券说明发券还没成功,明确报错让买家稍后重试,不要静默放过(否则平台侧券没核销)。
|
||||
*/
|
||||
export async function verifyCertificateForOrderItem(input: {
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
config?: DouyinMashidaSourceConfig | undefined
|
||||
}): Promise<RedeemCertificateRow> {
|
||||
const receiptOrderId = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const certificate = await findRedeemCertificateByOrder({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
orderId: receiptOrderId,
|
||||
})
|
||||
|
||||
if (!certificate) {
|
||||
throw createHttpError('该订单的券码尚未生成,请稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'douyin_mashida_certificate_not_found',
|
||||
context: { orderId: input.order.id, orderItemId: input.orderItem.id, receiptOrderId },
|
||||
})
|
||||
}
|
||||
|
||||
return verifyCertificate({ certNo: certificate.cert_no, config: input.config })
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销上报(幂等):本地已核销直接返回;未同步成功的券不允许核销(否则平台侧没有这张券)。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user