refactor(douyin-mashida)!: 纠偏——删掉自建的皮肤通道,改回复用 affiliate_dash

上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」,
跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 UID == 买家填的 UID」),
而且核销还触发了老通知逻辑,给买家**又发了一条 /redeem** —— 买家收到两条链接。
本次按用户要求改回**完全复用仓库现成的 affiliate_dash 通道**(= 旧的快手皮肤自动发货)。

**删掉**
- 执行器 `douyin_mashida_skin`(含 registry/types/planner/bootstrap/路由白名单注册)
- `services/claim/claim-skin-service.ts`、`routes/claim-skin.ts`、`/api/v1/claim-skin` 挂载
- 迁移 096 的 `claim_submission_json` 全链路(task-repo / rows / inputs / task-detail /
  admin-read-service 的 claimSubmission),并新增 **097 兜底迁移**把该列 DROP 掉
- 顺带清掉上一轮专为已删接口加的死代码(utils/http.ts 的 buildMessageSuccessPayload 等)

**改回复用**
- `resolveOrderItemClaimLink`:通用解析(只看订单项、不看执行器)→
  `buildConfiguredClaimUrl(claimLinkBase, token)`;多履约单元取 task id 最小并告警;
  **绝不回退** hash 形态的 `buildClaimUrl`
- `cert_link` 与付款后那条**唯一**消息共用同一个解析结果
- **核销后不再给抖音买家发第二条消息**(`shouldNotifyBuyerAfterVerify` 只放行非抖音 provider)
- 买家侧走既有流程:输 UID → 二维码 → 扫码绑定 → 比对 UID → delivered/REDEEMED;
  在 delivered 分支 best-effort 核销平台券(幂等、失败只 warn,不拖慢买家响应)
- 后台回显链接改按 **order.provider** 判断(抖音用 `claimLinkBase`)

**SQL 守卫**:允许「删除历史迁移」,但强制要求「同时新增兜底迁移」配对
(修改历史迁移仍禁止),并把改动来源换成 `git status --porcelain`(否则未跟踪的新迁移看不见)

`npm run check`:tests 671 / pass 668 / fail 0 / skipped 3(含新增的物品图回退用例)
This commit is contained in:
yml2213
2026-10-09 16:13:39 +08:00
parent 56ad06239b
commit 4fce07a621
37 changed files with 590 additions and 1300 deletions
@@ -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' })),
'',
)
})
@@ -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
@@ -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,
})
}
@@ -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,
@@ -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