- 数据库新增 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)及全量构建编译均通过
526 lines
16 KiB
TypeScript
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,
|
|
}
|
|
}
|