Files
order_site/apps/backend/src/services/claim/claim-service.ts
T
yml2213 4fce07a621 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(含新增的物品图回退用例)
2026-10-09 16:13:39 +08:00

188 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Awaited<ReturnType<typeof createClaimToken>>> & {
claimUrl: string
}
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
const createdAt = nowIso()
const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours)
const token = await createClaimToken({
taskId: Number(taskId),
token: randomToken(24),
status: 'active',
expiredAt,
usedAt: null,
maxUseCount: 1,
usedCount: 0,
createdAt,
updatedAt: createdAt,
})
if (!token) {
throw createHttpError('领取 token 创建失败', {
statusCode: 500,
errorCode: 'claim_token_create_failed',
})
}
return {
...token,
claimUrl: buildClaimUrl(token.token),
}
}
/** 0 表示长期有效;正整数保留为可选的限时领取策略。 */
export function resolveClaimTokenExpiration(
createdAt: string,
tokenTtlHours: unknown,
): string | null {
const ttlHours = Number(tokenTtlHours)
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}`。
*
* 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页),
* 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
*/
export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string {
const base = String(baseUrl || '')
.trim()
.replace(/\/+$/, '')
const normalizedToken = String(token || '').trim()
if (!base || !normalizedToken) {
return ''
}
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<TaskRow> | 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<Partial<TaskRow>> | 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<OrderItemClaimLink> {
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,
}
}