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:
@@ -0,0 +1,21 @@
|
||||
-- 099_order_claim_status.sql —— 订单兑换有效性控制(支持默认有效、暂停、关闭与重新启用)。
|
||||
|
||||
ALTER TABLE orders
|
||||
ADD COLUMN IF NOT EXISTS claim_status TEXT NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN IF NOT EXISTS claim_status_reason TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS claim_status_updated_at TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_orders_claim_status'
|
||||
) THEN
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT chk_orders_claim_status
|
||||
CHECK (claim_status IN ('active', 'paused', 'closed'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN orders.claim_status IS '订单兑换有效性状态: active(正常有效), paused(暂停兑换), closed(关闭兑换)';
|
||||
COMMENT ON COLUMN orders.claim_status_reason IS '订单兑换状态变更原因或备注说明';
|
||||
COMMENT ON COLUMN orders.claim_status_updated_at IS '订单兑换状态最后变更时间';
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { OrderListQueryResult, OrderListRow, OrderRow } from '../types/repository/rows.js'
|
||||
import { isPlatformOrderIdLookupKeyword } from '../utils/platform-order-id.js'
|
||||
import { safeJsonStringify, sanitizeWellFormedUnicode } from '../utils/json.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export type OrderQueryExecutor = <T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
@@ -409,3 +410,34 @@ function appendProviderFilter(filters: string[], params: unknown[], provider: st
|
||||
params.push(provider)
|
||||
filters.push(`(o.provider = $${params.length} OR o.platform = $${params.length})`)
|
||||
}
|
||||
|
||||
export async function updateOrderClaimStatus(
|
||||
orderId: number | string,
|
||||
input: {
|
||||
claimStatus: 'active' | 'paused' | 'closed'
|
||||
reason?: string
|
||||
updatedAt?: string
|
||||
},
|
||||
): Promise<OrderRow | null> {
|
||||
const updatedAt = input.updatedAt || nowIso()
|
||||
const result = await query<OrderRow>(
|
||||
`
|
||||
UPDATE orders
|
||||
SET
|
||||
claim_status = $1,
|
||||
claim_status_reason = $2,
|
||||
claim_status_updated_at = $3,
|
||||
updated_at = $3
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.claimStatus,
|
||||
String(input.reason || '').trim(),
|
||||
updatedAt,
|
||||
Number(orderId),
|
||||
],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Router } from 'express'
|
||||
|
||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
||||
import { resendAdminOrderKuaishouIndustryVoucherCodes } from '../../services/admin/write/kuaishou-industry-actions.js'
|
||||
import {
|
||||
updateAdminOrderClaimStatus,
|
||||
type UpdateOrderClaimStatusPayload,
|
||||
} from '../../services/admin/write/order-actions.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/admin/route-inputs.js'
|
||||
|
||||
@@ -34,6 +38,30 @@ router.get(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/claim-status',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminOrderClaimStatus(
|
||||
String((req.params as AdminOrderRouteParams).orderId || ''),
|
||||
req.body as UpdateOrderClaimStatusPayload,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '订单兑换状态已更新',
|
||||
errorMessage: '更新订单兑换状态失败',
|
||||
scope: '[admin/orders/:orderId/claim-status]',
|
||||
audit: (req, data) => ({
|
||||
action: 'order_claim_status_update',
|
||||
targetType: 'order',
|
||||
targetId: String((req.params as AdminOrderRouteParams).orderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/kuaishou-industry/resend-code',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
@@ -58,3 +86,4 @@ router.post(
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import {
|
||||
getH5ClaimDetailByOrderNo,
|
||||
getKuaishouCloudClaimDetail,
|
||||
submitAffiliateDashClaim,
|
||||
submitClaimUid,
|
||||
submitH5AffiliateDashClaimByOrderNo,
|
||||
submitH5ClaimUidByOrderNo,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
|
||||
@@ -22,6 +25,51 @@ const claimWriteRateLimit = createRateLimitMiddleware({
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
const h5ReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:h5:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
const h5WriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:h5:write',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('orderNo'),
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/h5/:orderNo',
|
||||
h5ReadRateLimit,
|
||||
createRouteHandler((req) => getH5ClaimDetailByOrderNo(req.params.orderNo), {
|
||||
errorMessage: '查询订单领取详情失败',
|
||||
scope: '[claims/h5/:orderNo]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/h5/:orderNo/uid',
|
||||
h5WriteRateLimit,
|
||||
createRouteHandler((req) => submitH5ClaimUidByOrderNo(req.params.orderNo, req.body || {}), {
|
||||
successMessage: 'UID 已提交',
|
||||
errorMessage: '提交 UID 失败',
|
||||
scope: '[claims/h5/:orderNo/uid]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/h5/:orderNo/affiliate-dash/submit',
|
||||
h5WriteRateLimit,
|
||||
createRouteHandler(
|
||||
(req) => submitH5AffiliateDashClaimByOrderNo(req.params.orderNo, req.body || {}),
|
||||
{
|
||||
successMessage: '发货已提交',
|
||||
errorMessage: '提交发货失败',
|
||||
scope: '[claims/h5/:orderNo/affiliate-dash/submit]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/:token',
|
||||
claimReadRateLimit,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,9 @@ export type AdminOrderListItem = {
|
||||
customerStatus: string
|
||||
preparedTaskCount: number
|
||||
completedTaskCount: number
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
}
|
||||
|
||||
export type AdminTaskListItem = {
|
||||
|
||||
@@ -14,6 +14,9 @@ export type OrderRow = {
|
||||
currency: string
|
||||
raw_payload_json: string | Record<string, unknown>
|
||||
paid_at: string | null
|
||||
claim_status?: string | null
|
||||
claim_status_reason?: string | null
|
||||
claim_status_updated_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'react-router'
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import { isClaimPath } from '@/utils/claim-path'
|
||||
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
|
||||
import { isRedeemPath } from '@/utils/redeem'
|
||||
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
|
||||
import { hasWorkerSession } from '@/utils/worker-auth'
|
||||
@@ -100,6 +100,11 @@ export default function App() {
|
||||
return <ClaimApplication />
|
||||
}
|
||||
|
||||
// 路径式 H5 订单号领取链接(`https://<域名>/h5/<订单号>`,不带 `#`)
|
||||
if (isH5ClaimPath(window.location.pathname)) {
|
||||
return <H5ClaimApplication />
|
||||
}
|
||||
|
||||
// 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断,
|
||||
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
|
||||
if (isRedeemPath(window.location.pathname)) {
|
||||
@@ -190,6 +195,19 @@ function ClaimApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
function H5ClaimApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/h5/:orderNo" element={<ClaimPage />} />
|
||||
<Route path="*" element={<ClaimPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkerApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Tag, Typography } from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CopyOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Input,
|
||||
Modal,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
|
||||
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
export default function AdminOrderDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { orderId = '' } = useParams()
|
||||
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
||||
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
|
||||
const [statusReason, setStatusReason] = useState('')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order-detail', orderId],
|
||||
queryFn: () => fetchAdminOrderDetail(orderId),
|
||||
@@ -21,6 +46,43 @@ export default function AdminOrderDetailPage() {
|
||||
})
|
||||
const detail = query.data?.data
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) =>
|
||||
updateAdminOrderClaimStatus(orderId, data),
|
||||
onSuccess: () => {
|
||||
message.success('订单兑换状态已更新')
|
||||
setStatusModalVisible(false)
|
||||
setStatusReason('')
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
message.error(err instanceof Error ? err.message : '更新状态失败')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCopyH5Link = () => {
|
||||
if (!detail?.order.platformOrderId) return
|
||||
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(detail.order.platformOrderId)}`
|
||||
navigator.clipboard
|
||||
.writeText(h5Url)
|
||||
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
|
||||
.catch(() => message.error('复制失败,请手动复制'))
|
||||
}
|
||||
|
||||
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
|
||||
if (status === 'active') {
|
||||
Modal.confirm({
|
||||
title: '恢复启用订单兑换',
|
||||
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
|
||||
onOk: () => statusMutation.mutateAsync({ status: 'active', reason: '管理员恢复启用' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
setTargetStatus(status)
|
||||
setStatusReason('')
|
||||
setStatusModalVisible(true)
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -93,7 +155,45 @@ export default function AdminOrderDetailPage() {
|
||||
extra={<BackButton />}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Card
|
||||
title="基础信息"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
复制 H5 领取链接
|
||||
</Button>
|
||||
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openStatusModal('active')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
恢复启用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => openStatusModal('paused')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
暂停兑换
|
||||
</Button>
|
||||
)}
|
||||
{detail.order.claimStatus !== 'closed' ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => openStatusModal('closed')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
关闭兑换
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺">
|
||||
@@ -106,6 +206,28 @@ export default function AdminOrderDetailPage() {
|
||||
<Descriptions.Item label="支付状态">
|
||||
<StatusTag value={detail.order.payStatus} kind="pay" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="H5 兑换状态">
|
||||
<Space wrap>
|
||||
{detail.order.claimStatus === 'paused' ? (
|
||||
<Tag color="warning" icon={<PauseCircleOutlined />}>
|
||||
已暂停兑换
|
||||
</Tag>
|
||||
) : detail.order.claimStatus === 'closed' ? (
|
||||
<Tag color="default" icon={<StopOutlined />}>
|
||||
已关闭兑换
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="success" icon={<PlayCircleOutlined />}>
|
||||
正常有效
|
||||
</Tag>
|
||||
)}
|
||||
{detail.order.claimStatusReason ? (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
({detail.order.claimStatusReason})
|
||||
</span>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{detail.order.totalAmount} {detail.order.currency}
|
||||
</Descriptions.Item>
|
||||
@@ -177,6 +299,34 @@ export default function AdminOrderDetailPage() {
|
||||
<Card title="原始载荷">
|
||||
<JsonPreview value={detail.order.rawPayload} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
|
||||
open={statusModalVisible}
|
||||
onCancel={() => setStatusModalVisible(false)}
|
||||
confirmLoading={statusMutation.isPending}
|
||||
onOk={() => {
|
||||
if (!statusReason.trim()) {
|
||||
message.warning('请填写原因或备注(如:买家申请退款)')
|
||||
return
|
||||
}
|
||||
statusMutation.mutate({ status: targetStatus, reason: statusReason.trim() })
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<p style={{ color: '#64748b', marginBottom: 8 }}>
|
||||
{targetStatus === 'paused'
|
||||
? '暂停后,买家访问 H5 链接将被拦截并提示“兑换已暂停,请联系客服”,后续可随时恢复启用。'
|
||||
: '关闭后,买家将无法继续进入 H5 页面兑换,适用于已退款或订单作废。'}
|
||||
</p>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请输入暂停/关闭原因(必填,如:买家退款、售后争议排查)"
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { SearchOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
@@ -132,9 +132,25 @@ export default function AdminOrdersPage() {
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
|
||||
{row.platformOrderId}
|
||||
</Typography.Link>
|
||||
<Space size={4}>
|
||||
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
|
||||
{row.platformOrderId}
|
||||
</Typography.Link>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
title="复制 H5 领取链接"
|
||||
style={{ padding: 0, height: 'auto', color: '#64748b' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const url = `${window.location.origin}/h5/${encodeURIComponent(row.platformOrderId)}`
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
message.success('已复制 H5 链接')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<span className="muted">{row.shopName || row.shopId || row.provider}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -214,12 +230,17 @@ export default function AdminOrdersPage() {
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 220,
|
||||
minWidth: 240,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.payStatus} kind="pay" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
{row.claimStatus === 'paused' ? (
|
||||
<Tag color="warning">H5已暂停</Tag>
|
||||
) : row.claimStatus === 'closed' ? (
|
||||
<Tag color="default">H5已关闭</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
ExclamationCircleOutlined,
|
||||
LoadingOutlined,
|
||||
StopOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Card, Empty, Spin } from 'antd'
|
||||
import QRCode from 'qrcode'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { fetchClaimDetail, submitAffiliateDashClaim, submitClaimUid } from '@/services/claim'
|
||||
import {
|
||||
fetchClaimDetail,
|
||||
fetchH5ClaimDetail,
|
||||
submitAffiliateDashClaim,
|
||||
submitClaimUid,
|
||||
submitH5AffiliateDashClaim,
|
||||
submitH5ClaimUid,
|
||||
} from '@/services/claim'
|
||||
import type { ClaimDetailData } from '@/types/claim'
|
||||
|
||||
import { AffiliateDashClaimPanel, AffiliateDashResultStep } from './ClaimAffiliateDashSteps'
|
||||
@@ -15,8 +27,13 @@ import { resolveNextPollDelayMs, resolveRolePollDelayMs } from './claim-poll'
|
||||
import { createClaimSnapshot, type ClaimSnapshot } from './claim-snapshot'
|
||||
|
||||
export default function ClaimPage() {
|
||||
const { token: routeToken } = useParams<{ token: string }>()
|
||||
const { token: routeToken, orderNo: routeOrderNo } = useParams<{
|
||||
token?: string
|
||||
orderNo?: string
|
||||
}>()
|
||||
const token = String(routeToken || '').trim()
|
||||
const orderNo = String(routeOrderNo || '').trim()
|
||||
const isH5Mode = Boolean(orderNo)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submittingUid, setSubmittingUid] = useState(false)
|
||||
@@ -82,7 +99,7 @@ export default function ClaimPage() {
|
||||
|
||||
const loadDetail = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!token) {
|
||||
if (!token && !orderNo) {
|
||||
setLoading(false)
|
||||
setErrorMessage('领取链接无效,请检查链接是否完整')
|
||||
stopPolling()
|
||||
@@ -95,7 +112,9 @@ export default function ClaimPage() {
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchClaimDetail(token)
|
||||
const response = isH5Mode
|
||||
? await fetchH5ClaimDetail(orderNo)
|
||||
: await fetchClaimDetail(token)
|
||||
await applyDetail(response.data)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
@@ -108,7 +127,7 @@ export default function ClaimPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyDetail, stopPolling, token],
|
||||
[applyDetail, isH5Mode, orderNo, stopPolling, token],
|
||||
)
|
||||
|
||||
const resolveNextPollDelay = useCallback((nextSnapshot: ClaimSnapshot) => {
|
||||
@@ -162,7 +181,9 @@ export default function ClaimPage() {
|
||||
|
||||
setSubmittingUid(true)
|
||||
try {
|
||||
const response = await submitClaimUid(token, uid)
|
||||
const response = isH5Mode
|
||||
? await submitH5ClaimUid(orderNo, uid)
|
||||
: await submitClaimUid(token, uid)
|
||||
await applyDetail(response.data)
|
||||
showSuccess('UID 已提交')
|
||||
} catch (error) {
|
||||
@@ -194,10 +215,15 @@ export default function ClaimPage() {
|
||||
|
||||
setSubmittingSubmit(true)
|
||||
try {
|
||||
const response = await submitAffiliateDashClaim(token, {
|
||||
gameAccount: uid,
|
||||
bindUuid,
|
||||
})
|
||||
const response = isH5Mode
|
||||
? await submitH5AffiliateDashClaim(orderNo, {
|
||||
gameAccount: uid,
|
||||
bindUuid,
|
||||
})
|
||||
: await submitAffiliateDashClaim(token, {
|
||||
gameAccount: uid,
|
||||
bindUuid,
|
||||
})
|
||||
await applyDetail(response.data)
|
||||
showSuccess('发货已提交')
|
||||
} catch (error) {
|
||||
@@ -224,8 +250,24 @@ export default function ClaimPage() {
|
||||
</Card>
|
||||
) : errorMessage ? (
|
||||
<Card className="claim-content-card claim-state-card">
|
||||
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
|
||||
<p>{errorMessage}</p>
|
||||
{errorMessage.includes('暂停') ? (
|
||||
<>
|
||||
<WarningOutlined style={{ fontSize: 48, color: '#faad14' }} />
|
||||
<h3 style={{ marginTop: 16 }}>订单兑换已暂停</h3>
|
||||
<p style={{ color: '#64748b' }}>{errorMessage}</p>
|
||||
</>
|
||||
) : errorMessage.includes('关闭') || errorMessage.includes('退款') ? (
|
||||
<>
|
||||
<StopOutlined style={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<h3 style={{ marginTop: 16 }}>订单不可兑换</h3>
|
||||
<p style={{ color: '#64748b' }}>{errorMessage}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
|
||||
<p>{errorMessage}</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
) : !detail ? (
|
||||
<Card className="claim-content-card claim-state-card">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet } from '@/lib/http'
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
|
||||
|
||||
export function fetchAdminOrders(params?: Record<string, unknown>) {
|
||||
@@ -11,3 +11,17 @@ export function fetchAdminOrders(params?: Record<string, unknown>) {
|
||||
export function fetchAdminOrderDetail(orderId: number | string) {
|
||||
return apiGet<AdminOrderDetail>(`/api/v1/admin/orders/${orderId}`)
|
||||
}
|
||||
|
||||
export function updateAdminOrderClaimStatus(
|
||||
orderId: number | string,
|
||||
payload: { status: 'active' | 'paused' | 'closed'; reason?: string },
|
||||
) {
|
||||
return apiPost<{
|
||||
orderId: number
|
||||
platformOrderId: string
|
||||
claimStatus: string
|
||||
claimStatusReason: string
|
||||
claimStatusUpdatedAt: string
|
||||
}>(`/api/v1/admin/orders/${orderId}/claim-status`, payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,22 @@ export function submitAffiliateDashClaim(
|
||||
) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/affiliate-dash/submit`, payload)
|
||||
}
|
||||
|
||||
export function fetchH5ClaimDetail(orderNo: string) {
|
||||
return apiGet<ClaimDetailData>(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
export function submitH5ClaimUid(orderNo: string, uid: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}/uid`, { uid })
|
||||
}
|
||||
|
||||
export function submitH5AffiliateDashClaim(
|
||||
orderNo: string,
|
||||
payload: { gameAccount: string; bindUuid: string },
|
||||
) {
|
||||
return apiPost<ClaimDetailData>(
|
||||
`/api/v1/claim/h5/${encodeURIComponent(orderNo)}/affiliate-dash/submit`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ export interface AdminOrderListItem {
|
||||
customerStatus: string
|
||||
preparedTaskCount: number
|
||||
completedTaskCount: number
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
@@ -55,6 +58,9 @@ export interface AdminOrderDetail {
|
||||
platformOrderId: string
|
||||
orderStatus: string
|
||||
payStatus: string
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
|
||||
@@ -39,6 +39,30 @@ export function isClaimPath(pathname: unknown): boolean {
|
||||
return resolveClaimToken(pathname) !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 H5 订单号领取链接(`/h5/<订单号>`)里的订单号。
|
||||
*
|
||||
* 只认 `/h5/<一段 orderNo>`:
|
||||
* - `/h5/2623001922675146` 命中,结尾多余斜杠容忍(`/h5/2623001922675146/`);
|
||||
* - `/h5`、`/h5/`、`/h5/a/b` 都不命中;
|
||||
* - 查询串 / hash 片段(如 `/h5/123?x=1`)会被丢弃后取第一段。
|
||||
*/
|
||||
export function resolveH5ClaimOrderNo(pathname: unknown): string {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return ''
|
||||
|
||||
const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
|
||||
if (segments.length !== 2 || segments[0] !== 'h5') return ''
|
||||
|
||||
return decodeTokenSegment(segments[1])
|
||||
}
|
||||
|
||||
/** 是否为 H5 订单号领取链接(`/h5/<orderNo>`) */
|
||||
export function isH5ClaimPath(pathname: unknown): boolean {
|
||||
return resolveH5ClaimOrderNo(pathname) !== ''
|
||||
}
|
||||
|
||||
function decodeTokenSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isClaimPath, resolveClaimToken } from '../src/utils/claim-path.ts'
|
||||
import {
|
||||
isClaimPath,
|
||||
isH5ClaimPath,
|
||||
resolveClaimToken,
|
||||
resolveH5ClaimOrderNo,
|
||||
} from '../src/utils/claim-path.ts'
|
||||
|
||||
test('isClaimPath:只认路径式 /claim/<token>,容忍结尾斜杠', () => {
|
||||
assert.equal(isClaimPath('/claim/abc123'), true)
|
||||
@@ -38,3 +43,20 @@ test('resolveClaimToken:取出并解码 token 片段', () => {
|
||||
assert.equal(resolveClaimToken('/claim/100%'), '100%')
|
||||
assert.equal(resolveClaimToken('/redeem'), '')
|
||||
})
|
||||
|
||||
test('isH5ClaimPath:只认路径式 /h5/<orderNo>,容忍结尾斜杠', () => {
|
||||
assert.equal(isH5ClaimPath('/h5/2623001922675146'), true)
|
||||
assert.equal(isH5ClaimPath('/h5/2623001922675146/'), true)
|
||||
assert.equal(isH5ClaimPath('/h5'), false)
|
||||
assert.equal(isH5ClaimPath('/h5/'), false)
|
||||
assert.equal(isH5ClaimPath('/h5/a/b'), false)
|
||||
assert.equal(isH5ClaimPath('/claim/123'), false)
|
||||
})
|
||||
|
||||
test('resolveH5ClaimOrderNo:正确解析并解码订单号', () => {
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146'), '2623001922675146')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146?from=sms'), '2623001922675146')
|
||||
assert.equal(resolveH5ClaimOrderNo('/h5/ORDER-001/'), 'ORDER-001')
|
||||
assert.equal(resolveH5ClaimOrderNo('/claim/123'), '')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user