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)及全量构建编译均通过
This commit is contained in:
@@ -96,6 +96,9 @@ export function mapAdminOrderListItem(
|
||||
customerStatus: fulfillmentProgress.customerStatus,
|
||||
preparedTaskCount: fulfillmentProgress.preparedTaskCount,
|
||||
completedTaskCount: fulfillmentProgress.completedTaskCount,
|
||||
claimStatus: item.claim_status || 'active',
|
||||
claimStatusReason: item.claim_status_reason || '',
|
||||
claimStatusUpdatedAt: item.claim_status_updated_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ export async function getAdminOrderDetail(
|
||||
? order.raw_payload_json
|
||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||
fulfillmentProgress: buildOrderFulfillmentProgress(tasks),
|
||||
claimStatus: order.claim_status || 'active',
|
||||
claimStatusReason: order.claim_status_reason || '',
|
||||
claimStatusUpdatedAt: order.claim_status_updated_at || null,
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getOrderById, updateOrderClaimStatus } from '../../../repositories/order-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import type { AdminViewerSessionInput } from '../../../types/admin/read-inputs.js'
|
||||
|
||||
export type UpdateOrderClaimStatusPayload = {
|
||||
status: 'active' | 'paused' | 'closed'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function updateAdminOrderClaimStatus(
|
||||
orderId: number | string,
|
||||
payload: UpdateOrderClaimStatusPayload,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
) {
|
||||
const normalizedOrderId = Number(orderId)
|
||||
if (!Number.isFinite(normalizedOrderId) || normalizedOrderId <= 0) {
|
||||
throw createHttpError('无效的订单 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
const validStatuses = new Set(['active', 'paused', 'closed'])
|
||||
if (!payload || !validStatuses.has(payload.status)) {
|
||||
throw createHttpError('无效的兑换有效性状态,仅支持 active, paused, closed', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_claim_status',
|
||||
})
|
||||
}
|
||||
|
||||
const order = await getOrderById(normalizedOrderId)
|
||||
if (!order) {
|
||||
throw createHttpError('订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const reason = String(payload.reason || '').trim()
|
||||
const now = nowIso()
|
||||
|
||||
const updated = await updateOrderClaimStatus(order.id, {
|
||||
claimStatus: payload.status,
|
||||
reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createHttpError('更新订单兑换状态失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'update_claim_status_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: updated.id,
|
||||
platformOrderId: updated.platform_order_id,
|
||||
claimStatus: updated.claim_status || payload.status,
|
||||
claimStatusReason: updated.claim_status_reason || reason,
|
||||
claimStatusUpdatedAt: updated.claim_status_updated_at || now,
|
||||
operator: session ? { userId: session.userId, username: session.username } : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
getOrderClaimContext,
|
||||
} from './kuaishou-cloud-claim-context.js'
|
||||
import {
|
||||
updateAdminOrderClaimStatus,
|
||||
} from '../admin/write/order-actions.js'
|
||||
|
||||
test('getOrderClaimContext: 缺少订单号时抛出 400 错误', async () => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await getOrderClaimContext('')
|
||||
},
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_order_no',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('updateAdminOrderClaimStatus: 非法状态入参时抛出 400 错误', async () => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
// @ts-expect-error invalid status test
|
||||
await updateAdminOrderClaimStatus(1, { status: 'invalid_status' })
|
||||
},
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_claim_status',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('updateAdminOrderClaimStatus: 非法 orderId 抛出 400 错误', async () => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await updateAdminOrderClaimStatus(0, { status: 'paused' })
|
||||
},
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_order_id',
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-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'
|
||||
@@ -21,13 +21,14 @@ const RETIRED_CHANNEL_MESSAGE: Record<string, string> = {
|
||||
kuaishou_ct_assisted: '乐玩履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
|
||||
}
|
||||
|
||||
type ClaimContext = {
|
||||
export type ClaimContext = {
|
||||
claimToken: ClaimTokenRow
|
||||
task: TaskRow
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
}
|
||||
|
||||
|
||||
export async function getClaimContext(token: unknown) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
@@ -125,6 +126,122 @@ export async function getClaimContext(token: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -357,6 +474,8 @@ function buildClaimOrderPayload(order: OrderRow) {
|
||||
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,
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
canUpdateClaimUid,
|
||||
getClaimIdentityFromTask,
|
||||
} from './claim-identity.js'
|
||||
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||
import {
|
||||
buildClaimDetailPayload,
|
||||
getClaimContext,
|
||||
getOrderClaimContext,
|
||||
type ClaimContext,
|
||||
} from './kuaishou-cloud-claim-context.js'
|
||||
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
|
||||
@@ -53,8 +58,17 @@ function isRetiredExecutor(executorKey: unknown) {
|
||||
return RETIRED_EXECUTOR_KEYS.has(String(executorKey || '').trim())
|
||||
}
|
||||
|
||||
async function refreshContextTask(context: ClaimContext): Promise<ClaimContext> {
|
||||
const refreshed = await getTaskById(context.task.id)
|
||||
return refreshed ? { ...context, task: refreshed } : context
|
||||
}
|
||||
|
||||
export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
return getClaimDetailByContext(context)
|
||||
}
|
||||
|
||||
export async function getClaimDetailByContext(context: ClaimContext): Promise<ClaimDetailPayload> {
|
||||
let task = context.task
|
||||
const executorKey = String(task.executor_key || '').trim()
|
||||
|
||||
@@ -99,15 +113,15 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一领取 Step1:提交游戏 UID。
|
||||
* 已下线渠道仅允许查看,禁止再改 UID。
|
||||
*/
|
||||
export async function submitClaimUid(
|
||||
token: unknown,
|
||||
export async function getH5ClaimDetailByOrderNo(orderNo: unknown): Promise<ClaimDetailPayload> {
|
||||
const context = await getOrderClaimContext(orderNo)
|
||||
return getClaimDetailByContext(context)
|
||||
}
|
||||
|
||||
export async function submitClaimUidWithContext(
|
||||
context: ClaimContext,
|
||||
payload: { uid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
const executorKey = String(context.task.executor_key || '').trim()
|
||||
|
||||
if (isRetiredExecutor(executorKey)) {
|
||||
@@ -130,7 +144,7 @@ export async function submitClaimUid(
|
||||
const taskContext = parseTaskContext(context.task)
|
||||
const previous = getClaimIdentityFromTask(context.task)
|
||||
if (executorKey === 'affiliate_dash') {
|
||||
return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now, token)
|
||||
return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now)
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
@@ -152,7 +166,27 @@ export async function submitClaimUid(
|
||||
now,
|
||||
)
|
||||
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
return getClaimDetailByContext(await refreshContextTask(context))
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一领取 Step1:提交游戏 UID。
|
||||
* 已下线渠道仅允许查看,禁止再改 UID。
|
||||
*/
|
||||
export async function submitClaimUid(
|
||||
token: unknown,
|
||||
payload: { uid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
return submitClaimUidWithContext(context, payload)
|
||||
}
|
||||
|
||||
export async function submitH5ClaimUidByOrderNo(
|
||||
orderNo: unknown,
|
||||
payload: { uid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getOrderClaimContext(orderNo)
|
||||
return submitClaimUidWithContext(context, payload)
|
||||
}
|
||||
|
||||
function buildClaimIdentityContext(
|
||||
@@ -191,12 +225,11 @@ async function createClaimUidSubmittedEventIfChanged(
|
||||
}
|
||||
|
||||
async function submitAffiliateDashClaimUid(
|
||||
context: Awaited<ReturnType<typeof getClaimContext>>,
|
||||
context: ClaimContext,
|
||||
expectedUid: string,
|
||||
previous: ReturnType<typeof getClaimIdentityFromTask>,
|
||||
taskContext: JsonObject,
|
||||
now: string,
|
||||
token: unknown,
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
|
||||
const claimIdentity = buildClaimIdentityContext(previous, expectedUid, now)
|
||||
@@ -228,7 +261,7 @@ async function submitAffiliateDashClaimUid(
|
||||
expectedUid,
|
||||
now,
|
||||
)
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
return getClaimDetailByContext(await refreshContextTask(context))
|
||||
}
|
||||
|
||||
if (canReuseAffiliateDashBindSnapshot(flow, expectedUid)) {
|
||||
@@ -263,7 +296,7 @@ async function submitAffiliateDashClaimUid(
|
||||
},
|
||||
now,
|
||||
)
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
return getClaimDetailByContext(await refreshContextTask(context))
|
||||
}
|
||||
|
||||
const bindResult = await bindAffiliateDashDelivery({
|
||||
@@ -312,17 +345,16 @@ async function submitAffiliateDashClaimUid(
|
||||
now,
|
||||
)
|
||||
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
return getClaimDetailByContext(await refreshContextTask(context))
|
||||
}
|
||||
|
||||
/**
|
||||
* affiliate_dash 领取 Step2:提交发货(绑定完成后)。
|
||||
*/
|
||||
export async function submitAffiliateDashClaim(
|
||||
token: unknown,
|
||||
export async function submitAffiliateDashClaimWithContext(
|
||||
context: ClaimContext,
|
||||
payload: { gameAccount?: unknown; bindUuid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
const task = context.task
|
||||
const executorKey = String(task.executor_key || '').trim()
|
||||
if (executorKey !== 'affiliate_dash') {
|
||||
@@ -467,7 +499,26 @@ export async function submitAffiliateDashClaim(
|
||||
verifyDouyinCertificateAfterClaim(context)
|
||||
}
|
||||
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
return getClaimDetailByContext(await refreshContextTask(context))
|
||||
}
|
||||
|
||||
/**
|
||||
* affiliate_dash 领取 Step2:提交发货(绑定完成后)。
|
||||
*/
|
||||
export async function submitAffiliateDashClaim(
|
||||
token: unknown,
|
||||
payload: { gameAccount?: unknown; bindUuid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
return submitAffiliateDashClaimWithContext(context, payload)
|
||||
}
|
||||
|
||||
export async function submitH5AffiliateDashClaimByOrderNo(
|
||||
orderNo: unknown,
|
||||
payload: { gameAccount?: unknown; bindUuid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const context = await getOrderClaimContext(orderNo)
|
||||
return submitAffiliateDashClaimWithContext(context, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user