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:
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDouyinMashidaClaimMessage,
|
||||
renderDouyinMashidaMessageTemplate,
|
||||
resolveDouyinMashidaSkinClaimUrl,
|
||||
} from './claim-link.js'
|
||||
import {
|
||||
createDefaultDouyinMashidaSourceConfig,
|
||||
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
normalizeDouyinMashidaSourceConfig,
|
||||
type DouyinMashidaSourceConfig,
|
||||
} from './source-config-service.js'
|
||||
|
||||
function buildConfig(
|
||||
overrides: Partial<DouyinMashidaSourceConfig> = {},
|
||||
): DouyinMashidaSourceConfig {
|
||||
return { ...createDefaultDouyinMashidaSourceConfig(), ...overrides }
|
||||
}
|
||||
|
||||
test('抖音配置默认值:claimLinkBase 空、模板有默认文案、发消息开关默认开', () => {
|
||||
const config = normalizeDouyinMashidaSourceConfig({})
|
||||
|
||||
assert.equal(config.claimLinkBase, '')
|
||||
assert.equal(config.messageTemplate, DEFAULT_CLAIM_MESSAGE_TEMPLATE)
|
||||
assert.equal(config.claimMessageEnabled, true)
|
||||
// 既有字段默认值不变
|
||||
assert.equal(config.certLink, '')
|
||||
assert.equal(config.messageType, 2)
|
||||
assert.equal(config.timeoutMs, 20000)
|
||||
})
|
||||
|
||||
test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板可覆盖', () => {
|
||||
const config = normalizeDouyinMashidaSourceConfig({
|
||||
claimLinkBase: ' https://x.ngrok-free.app/claim/// ',
|
||||
messageTemplate: ' 你好 {link} ',
|
||||
claimMessageEnabled: false,
|
||||
})
|
||||
|
||||
assert.equal(config.claimLinkBase, 'https://x.ngrok-free.app/claim')
|
||||
assert.equal(config.messageTemplate, '你好 {link}')
|
||||
assert.equal(config.claimMessageEnabled, false)
|
||||
})
|
||||
|
||||
test('皮肤领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退 hash 版领取页', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaSkinClaimUrl(
|
||||
'AbC123',
|
||||
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
|
||||
),
|
||||
'https://x.ngrok-free.app/claim/AbC123',
|
||||
)
|
||||
|
||||
assert.equal(resolveDouyinMashidaSkinClaimUrl('AbC123', buildConfig()), '')
|
||||
assert.equal(
|
||||
resolveDouyinMashidaSkinClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => {
|
||||
assert.equal(
|
||||
renderDouyinMashidaMessageTemplate(
|
||||
'订单 {orderNo} 的 {productTitle}({sku})请点 {link} 领取 ',
|
||||
{
|
||||
link: 'https://x/claim/1',
|
||||
sku: '幸运币2个',
|
||||
orderNo: '6930318258716179555',
|
||||
productTitle: '测试商品',
|
||||
},
|
||||
),
|
||||
'订单 6930318258716179555 的 测试商品(幸运币2个)请点 https://x/claim/1 领取 ',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
renderDouyinMashidaMessageTemplate('{link} {unknown}', { link: 'https://x/claim/1' }),
|
||||
'https://x/claim/1 {unknown}',
|
||||
)
|
||||
assert.equal(renderDouyinMashidaMessageTemplate('{link}', { link: '' }), '{link}')
|
||||
})
|
||||
|
||||
test('发消息正文:无领取链接则不发(返回空串),默认模板带链接', () => {
|
||||
assert.equal(buildDouyinMashidaClaimMessage({ config: buildConfig(), claimUrl: '' }), '')
|
||||
|
||||
assert.equal(
|
||||
buildDouyinMashidaClaimMessage({
|
||||
config: buildConfig(),
|
||||
claimUrl: 'https://x.ngrok-free.app/claim/AbC123',
|
||||
}),
|
||||
`您的订单已发货,请点击链接领取:https://x.ngrok-free.app/claim/AbC123`,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
buildDouyinMashidaClaimMessage({
|
||||
config: buildConfig({ messageTemplate: '{sku} 已到账,订单 {orderNo}:{link}' }),
|
||||
claimUrl: 'https://x/claim/1',
|
||||
sku: '幸运币2个',
|
||||
orderNo: '6930',
|
||||
productTitle: '测试商品',
|
||||
}),
|
||||
'幸运币2个 已到账,订单 6930:https://x/claim/1',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { buildSkinClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
getDouyinMashidaSourceConfig,
|
||||
type DouyinMashidaSourceConfig,
|
||||
} from './source-config-service.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达「皮肤自动发货」的领取链接与发货消息拼装(纯函数,便于单测)。
|
||||
*
|
||||
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
|
||||
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
|
||||
* (那是 hash 形式的打手领取页,语义完全不同)。
|
||||
*/
|
||||
|
||||
export type DouyinMashidaClaimMessageVars = {
|
||||
link: string
|
||||
sku?: string | undefined
|
||||
orderNo?: string | undefined
|
||||
productTitle?: string | undefined
|
||||
}
|
||||
|
||||
/** 皮肤领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
|
||||
export function resolveDouyinMashidaSkinClaimUrl(
|
||||
token: unknown,
|
||||
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
|
||||
): string {
|
||||
return buildSkinClaimUrl(config.claimLinkBase, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板占位符替换:支持 `{link}` / `{sku}` / `{orderNo}` / `{productTitle}`。
|
||||
* 未提供的占位符(或空值)原样保留,方便运营一眼看出漏配,而不是发出一条缺链接的消息。
|
||||
*/
|
||||
export function renderDouyinMashidaMessageTemplate(
|
||||
template: unknown,
|
||||
vars: DouyinMashidaClaimMessageVars,
|
||||
): string {
|
||||
const source = String(template ?? '')
|
||||
if (!source) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return source.replace(/\{(\w+)\}/g, (placeholder, key: string) => {
|
||||
const value = (vars as Record<string, unknown>)[key]
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
return normalized || placeholder
|
||||
})
|
||||
}
|
||||
|
||||
/** 皮肤订单付款后发给买家的正文;`claimUrl` 为空返回空串(不发消息)。 */
|
||||
export function buildDouyinMashidaClaimMessage(input: {
|
||||
config: DouyinMashidaSourceConfig
|
||||
claimUrl: unknown
|
||||
sku?: unknown
|
||||
orderNo?: unknown
|
||||
productTitle?: unknown
|
||||
}): string {
|
||||
const claimUrl = String(input.claimUrl || '').trim()
|
||||
if (!claimUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const template =
|
||||
String(input.config.messageTemplate || '').trim() || DEFAULT_CLAIM_MESSAGE_TEMPLATE
|
||||
return renderDouyinMashidaMessageTemplate(template, {
|
||||
link: claimUrl,
|
||||
sku: String(input.sku || '').trim(),
|
||||
orderNo: String(input.orderNo || '').trim(),
|
||||
productTitle: String(input.productTitle || '').trim(),
|
||||
})
|
||||
}
|
||||
@@ -3,23 +3,29 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js'
|
||||
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import { invalidateCertificatesForLocalOrder } from '../../redeem/certificate-service.js'
|
||||
import {
|
||||
ensureCertificateForOrderItem,
|
||||
invalidateCertificatesForLocalOrder,
|
||||
resolveReceiptOrderId,
|
||||
} from '../../redeem/certificate-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logIntegration } from '../../../utils/logger.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { fetchDouyinMashidaOrderDetail } from './gateway-client.js'
|
||||
import { fetchDouyinMashidaOrderDetail, sendDouyinMashidaOrderMessage } from './gateway-client.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PLATFORM,
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
buildDouyinMashidaSourceEvent,
|
||||
resolveDouyinMashidaParentOrderId,
|
||||
} from './order-mapper.js'
|
||||
import { ensureCertificateForOrderItem } from '../../redeem/certificate-service.js'
|
||||
import { resolveOrderItemSkinClaimLink } from '../../fulfillment/executors/douyin-mashida-skin-executor.js'
|
||||
import { buildDouyinMashidaClaimMessage } from './claim-link.js'
|
||||
import {
|
||||
getDouyinMashidaSourceConfig,
|
||||
type DouyinMashidaSourceConfig,
|
||||
} from './source-config-service.js'
|
||||
import { verifyMessageSignature } from './sign.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达消息推送入口(验签 → 幂等 → 分派)。
|
||||
@@ -185,6 +191,21 @@ async function handlePaidMessage(input: {
|
||||
for (const orderItem of result.orderItems || []) {
|
||||
await ensureCertificateForOrderItem({ order, orderItem, config: input.config })
|
||||
certificateCount += 1
|
||||
|
||||
// 皮肤自动发货:发券成功后按模板把领取链接发给买家。
|
||||
// best-effort:通知失败只记 warn,绝不影响建单/发券主流程(平台会重推时也不会重复发券)。
|
||||
await notifyBuyerSkinClaimLink({ order, orderItem, config: input.config }).catch((error) => {
|
||||
logIntegration(
|
||||
'[douyin-mashida/notify]',
|
||||
'皮肤领取链接通知买家失败(建单/发券不受影响)',
|
||||
{
|
||||
orderId: order.id,
|
||||
orderItemId: orderItem.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', '付款消息处理完成', {
|
||||
@@ -207,6 +228,55 @@ async function handlePaidMessage(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 皮肤自动发货:付款建单 + 发券成功后,把领取链接按模板通过 `order.message` 发给买家。
|
||||
*
|
||||
* 只在「该订单项确实有皮肤履约任务 + claimLinkBase 已配置 + 开关打开」时才发;
|
||||
* 非皮肤订单静默跳过(不打扰现有兑换流程)。
|
||||
*/
|
||||
async function notifyBuyerSkinClaimLink(input: {
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
config: DouyinMashidaSourceConfig
|
||||
}): Promise<void> {
|
||||
if (input.config.claimMessageEnabled === false) {
|
||||
return
|
||||
}
|
||||
if (!String(input.config.claimLinkBase || '').trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const skinLink = await resolveOrderItemSkinClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
config: input.config,
|
||||
})
|
||||
if (!skinLink.claimUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const content = buildDouyinMashidaClaimMessage({
|
||||
config: input.config,
|
||||
claimUrl: skinLink.claimUrl,
|
||||
sku: input.orderItem.sku_code,
|
||||
orderNo,
|
||||
productTitle: input.orderItem.sku_name,
|
||||
})
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', '已把皮肤领取链接发给买家(order.message)', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
taskId: skinLink.pickedTaskId,
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消/退款:把该订单下未核销的券作废。 */
|
||||
async function handleInvalidateMessage(input: {
|
||||
message: JsonObject
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
|
||||
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
|
||||
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
|
||||
* 皮肤自动发货订单会优先用「本站领取链接」(`claimLinkBase` + token),此时 `certLink` 退化为兜底。
|
||||
* - `claimLinkBase` 是皮肤领取页的**路径式**基址(如 `https://x.ngrok-free.app/claim`);
|
||||
* 它**不是**打手领取页(`runtimeConfig.orders.claimBaseUrl` 那个 hash 版),两者不能互相回退。
|
||||
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
|
||||
*/
|
||||
|
||||
@@ -29,6 +32,9 @@ export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanm
|
||||
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
|
||||
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
|
||||
|
||||
/** 皮肤自动发货:付款后用该模板把领取链接发给买家(可在后台覆盖)。 */
|
||||
export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击链接领取:{link}'
|
||||
|
||||
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
|
||||
'trade_TradePaid',
|
||||
'trade_TradeCanceled',
|
||||
@@ -40,8 +46,14 @@ export type DouyinMashidaSourceConfig = {
|
||||
baseUrl: string
|
||||
uid: string
|
||||
secret: string
|
||||
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem */
|
||||
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem;皮肤订单无领取链接时兜底用它 */
|
||||
certLink: string
|
||||
/** 皮肤领取页基址(路径式,如 https://x.ngrok-free.app/claim),留空则不生成皮肤领取链接 */
|
||||
claimLinkBase: string
|
||||
/** 皮肤自动发货:付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */
|
||||
messageTemplate: string
|
||||
/** 皮肤自动发货「付款后发消息」开关,默认开 */
|
||||
claimMessageEnabled: boolean
|
||||
/** order.message 的 message_type:1-订单状态 2-订单发货 4-图片 5-视频 */
|
||||
messageType: number
|
||||
/** 兑换成功后发给买家的「去使用链接」(卡密页/网盘等),留空则只发文案 */
|
||||
@@ -107,6 +119,11 @@ export function normalizeDouyinMashidaSourceConfig(rawValue: unknown): DouyinMas
|
||||
certLink: String(source.certLink || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
claimLinkBase: String(source.claimLinkBase || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
messageTemplate: String(source.messageTemplate || '').trim() || DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
claimMessageEnabled: source.claimMessageEnabled !== false,
|
||||
messageType: normalizeMessageType(source.messageType),
|
||||
afterVerifyLink: String(source.afterVerifyLink || '').trim(),
|
||||
afterVerifyMessage: String(source.afterVerifyMessage || '').trim(),
|
||||
@@ -124,6 +141,9 @@ export function createDefaultDouyinMashidaSourceConfig(): DouyinMashidaSourceCon
|
||||
uid: '',
|
||||
secret: '',
|
||||
certLink: '',
|
||||
claimLinkBase: '',
|
||||
messageTemplate: DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
claimMessageEnabled: true,
|
||||
messageType: 2,
|
||||
afterVerifyLink: '',
|
||||
afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE,
|
||||
|
||||
Reference in New Issue
Block a user