Files
order_site/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts
T
yml2213 412d219cca feat(claim): 支持固定H5订单号领取与订单兑换有效性后台控制
- 数据库新增 099_order_claim_status.sql 迁移,扩展 claim_status、claim_status_reason 等字段
- 后端新增 /claims/h5/:orderNo、UID提交、分销提交接口,保留原有 token 接口双轨兼容
- 增加兑换有效性强拦截:暂停/关闭/退款/取消订单精准阻断并返回友好提示
- 后台新增 POST /admin/orders/:orderId/claim-status 接口与审计日志记录
- 前端管理后台(订单列表、详情页)支持 H5 兑换状态展示、一键复制链接及暂停/关闭/启用弹窗
- 前端领取页面适配 /h5/:orderNo 路由及暂停/关闭等状态的异常展示
- 验证方式:运行前后端类型检查、全量单元测试(后端 674、前端 132)及全量构建编译均通过
2026-10-10 18:05:46 +08:00

526 lines
16 KiB
TypeScript

import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { getOrderItemById, listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
import { findTaskByClaimTokenId, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
import { buildClaimIdentityPayload } from './claim-identity.js'
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import type { JsonObject } from '../../types/json.js'
export const CLAIM_TERMINAL_STATUSES = new Set<string>([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED])
const RETIRED_CHANNEL_MESSAGE: Record<string, string> = {
kuaishou_feifei: '飞飞履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
kuaishou_ct_assisted: '乐玩履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
}
export type ClaimContext = {
claimToken: ClaimTokenRow
task: TaskRow
order: OrderRow
orderItem: OrderItemRow
}
export async function getClaimContext(token: unknown) {
const normalized = String(token || '').trim()
if (!normalized) {
throw createHttpError('缺少领取 token', {
statusCode: 400,
errorCode: 'missing_claim_token',
})
}
const claimToken = await findClaimTokenByToken(normalized)
if (!claimToken) {
throw createHttpError('领取链接无效或不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const task = await findTaskByClaimTokenId(claimToken.id)
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
if (CLAIM_TERMINAL_STATUSES.has(task.task_status)) {
if (claimToken.status === 'active') {
await updateClaimToken(claimToken.id, {
status: task.task_status === TASK_STATUS.EXPIRED ? 'expired' : 'closed',
expired_at: nowIso(),
updated_at: nowIso(),
}).catch(() => null)
}
const isExpired = task.task_status === TASK_STATUS.EXPIRED
throw createHttpError(isExpired ? '领取链接已过期' : '任务已关闭,领取链接已失效', {
statusCode: 410,
errorCode: isExpired ? 'claim_token_expired' : 'claim_task_closed',
context: {
claimToken: {
...claimToken,
status: isExpired ? 'expired' : 'closed',
},
task,
},
})
}
if (claimToken.status !== 'active') {
const isClosed = claimToken.status === 'closed'
const isExpired = claimToken.status === 'expired'
throw createHttpError(
isClosed ? '任务已关闭,领取链接已失效' : isExpired ? '领取链接已过期' : '领取链接当前不可用',
{
statusCode: 410,
errorCode: isClosed
? 'claim_task_closed'
: isExpired
? 'claim_token_expired'
: 'claim_token_inactive',
},
)
}
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
const expiredContext = await expireClaimContext(claimToken, task)
throw createHttpError('领取链接已过期', {
statusCode: 410,
errorCode: 'claim_token_expired',
context: expiredContext,
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
export async function getOrderClaimContext(orderNo: unknown): Promise<ClaimContext> {
const normalized = String(orderNo || '').trim()
if (!normalized) {
throw createHttpError('缺少订单号', {
statusCode: 400,
errorCode: 'missing_order_no',
})
}
let order = await findLatestOrderByAnyPlatformOrderId(normalized)
if (!order && /^\d+$/.test(normalized)) {
order = await getOrderById(Number(normalized))
}
if (!order) {
throw createHttpError('订单不存在或尚未同步,请核对订单号', {
statusCode: 404,
errorCode: 'claim_order_not_found',
})
}
const claimStatus = String(order.claim_status || 'active').trim()
if (claimStatus === 'paused') {
throw createHttpError('该订单兑换已暂停,如有疑问请联系客服', {
statusCode: 403,
errorCode: 'order_claim_paused',
context: {
claimStatus: 'paused',
reason: order.claim_status_reason || '',
},
})
}
if (claimStatus === 'closed') {
throw createHttpError('该订单兑换已关闭,无法继续领取', {
statusCode: 410,
errorCode: 'order_claim_closed',
context: {
claimStatus: 'closed',
reason: order.claim_status_reason || '',
},
})
}
if (order.pay_status === 'refunded' || order.order_status === 'cancelled') {
throw createHttpError('该订单已退款或已取消,无法兑换', {
statusCode: 410,
errorCode: 'order_refunded_or_cancelled',
context: {
orderStatus: order.order_status,
payStatus: order.pay_status,
},
})
}
const [tasks, items] = await Promise.all([
listTasksByOrderId(order.id),
listOrderItemsByOrderId(order.id),
])
if (tasks.length === 0) {
throw createHttpError('该订单履约任务正在准备中,请稍后刷新重试', {
statusCode: 404,
errorCode: 'claim_tasks_not_ready',
})
}
const activeTask =
tasks.find((t) => !isTaskFinalStatus(t.task_status)) ||
tasks[tasks.length - 1]
if (!activeTask) {
throw createHttpError('该订单暂无可用履约任务', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
const orderItem =
items.find((it) => it.id === activeTask.order_item_id) ||
items[0]
if (!orderItem) {
throw createHttpError('订单商品明细缺失', {
statusCode: 500,
errorCode: 'claim_order_item_missing',
})
}
if (CLAIM_TERMINAL_STATUSES.has(activeTask.task_status)) {
const isExpired = activeTask.task_status === TASK_STATUS.EXPIRED
throw createHttpError(isExpired ? '领取任务已过期' : '任务已关闭,无法继续兑换', {
statusCode: 410,
errorCode: isExpired ? 'claim_task_expired' : 'claim_task_closed',
context: { task: activeTask },
})
}
const claimToken: ClaimTokenRow = {
id: Number(activeTask.primary_claim_token_id || 0),
token: String(activeTask.primary_claim_token || activeTask.claim_token || `h5_${order.platform_order_id}`).trim(),
status: 'active',
expired_at: activeTask.claim_expires_at || null,
created_at: activeTask.created_at,
updated_at: activeTask.updated_at,
}
return {
claimToken,
task: activeTask,
order,
orderItem,
}
}
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const executorKey = String(task.executor_key || '').trim()
if (executorKey === 'affiliate_dash') {
return buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem })
}
if (executorKey === 'kuaishou_feifei' || executorKey === 'kuaishou_ct_assisted') {
return buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem })
}
return buildGenericClaimDetailPayload({ claimToken, task, order, orderItem })
}
function buildAffiliateDashClaimDetailPayload({
claimToken,
task,
order,
orderItem,
}: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const flow = normalizeAffiliateDashFlow(context.affiliateDash)
const productTitle = String(
flow.productName || orderItem.sku_name || orderItem.sku_code || '',
).trim()
const product = {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
}
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'affiliate_dash' as const,
channelOffline: false,
channelOfflineMessage: '',
claimIdentity,
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: productTitle,
quantity: orderItem.quantity,
},
product,
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: null as null,
affiliateDash: flow,
result: buildClaimResultPayload(task),
}
}
/**
* 领取页物品图片(内部 SKU → 图片 URL)。
*
* 内部 SKU(中文,如「幸运币2个」)在不同写入口径下可能落在不同字段:平台 `code` 先写进
* `sku_code`,履约解析后 `sku_code` 又可能被 affiliate-dash 的英文 sku 覆盖。
* 这里以「能映射出图片」为准则逐个回退,避免换口径就丢图;都映射不到返回空串(前端占位)。
*/
export function resolveClaimProductImageUrl(orderItem: Partial<OrderItemRow>): string {
const spec = parseJsonRecord(orderItem.spec_json)
const snapshot = parseJsonRecord(orderItem.item_snapshot_json)
return pickFirstNonEmptyText([
resolveProductImageUrl(spec.code),
resolveProductImageUrl(snapshot.code),
resolveProductImageUrl(orderItem.sku_name),
resolveProductImageUrl(orderItem.sku_code),
])
}
function parseJsonRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
const text = String(value ?? '').trim()
if (!text) {
return {}
}
try {
const parsed: unknown = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
function pickFirstNonEmptyText(values: unknown[]): string {
for (const value of values) {
const text = String(value ?? '').trim()
if (text) {
return text
}
}
return ''
}
function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const executorKey = String(task.executor_key || '').trim()
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim()
const offlineMessage =
RETIRED_CHANNEL_MESSAGE[executorKey] || '该履约渠道已下线,本链接仅可查看历史信息'
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType:
executorKey === 'kuaishou_feifei'
? ('kuaishou_feifei' as const)
: ('kuaishou_cloud' as const),
channelOffline: true,
channelOfflineMessage: offlineMessage,
claimIdentity,
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: productTitle,
quantity: orderItem.quantity,
},
product: {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
// 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp
imageUrl: resolveClaimProductImageUrl(orderItem),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
},
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: null as null,
affiliateDash: null as null,
result: buildClaimResultPayload(task) || {
resultCode: 'channel_offline',
resultMessage: offlineMessage,
screenshotReady: false,
screenshotUrl: '',
},
}
}
function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim()
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
channelOffline: false,
channelOfflineMessage: '',
claimIdentity,
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: productTitle,
quantity: orderItem.quantity,
},
product: {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
// 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp
imageUrl: resolveClaimProductImageUrl(orderItem),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
},
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: null as null,
affiliateDash: null as null,
result: buildClaimResultPayload(task),
}
}
function buildClaimTaskPayload(task: TaskRow, claimToken: ClaimTokenRow) {
return {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
executorKey: task.executor_key || '',
requiresSupportReview: false,
expiresAt: claimToken.expired_at,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
redeemedAt: task.redeemed_at,
loginType: task.login_type,
lastError: task.last_error,
runtimeSessionId: task.runtime_session_id,
}
}
function buildClaimOrderPayload(order: OrderRow) {
return {
orderId: order.id,
platform: order.platform,
platformOrderId: order.platform_order_id,
payStatus: order.pay_status,
orderStatus: order.order_status,
claimStatus: order.claim_status || 'active',
claimStatusReason: order.claim_status_reason || '',
totalAmount: formatFenToAmount(order.total_amount),
totalAmountFen: normalizeFen(order.total_amount),
currency: order.currency,
}
}
function buildClaimResultPayload(task: TaskRow) {
if (!task.redeemed_at) {
return null
}
return {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
}
function parseTaskContext(task: TaskRow): JsonObject {
return parseTaskContextValue(task)
}
async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) {
const now = nowIso()
const nextClaimToken = await updateClaimToken(claimToken.id, {
status: TASK_STATUS.EXPIRED,
updated_at: now,
})
let nextTask = task
if (!isTaskFinalStatus(task.task_status)) {
nextTask =
(await updateTask(task.id, {
task_status: TASK_STATUS.EXPIRED,
user_action_status: TASK_STATUS.EXPIRED,
last_error: '领取链接已过期',
updated_at: now,
})) || task
}
return {
claimToken: nextClaimToken,
task: nextTask,
}
}