上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」, 跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 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(含新增的物品图回退用例)
188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
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,
|
||
}
|
||
}
|