From 412d219cca0e537e902a2abc349e50dc7e1be921 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 10 Oct 2026 18:05:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(claim):=20=E6=94=AF=E6=8C=81=E5=9B=BA?= =?UTF-8?q?=E5=AE=9AH5=E8=AE=A2=E5=8D=95=E5=8F=B7=E9=A2=86=E5=8F=96?= =?UTF-8?q?=E4=B8=8E=E8=AE=A2=E5=8D=95=E5=85=91=E6=8D=A2=E6=9C=89=E6=95=88?= =?UTF-8?q?=E6=80=A7=E5=90=8E=E5=8F=B0=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库新增 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)及全量构建编译均通过 --- .../db/migrations/099_order_claim_status.sql | 21 +++ apps/backend/src/repositories/order-repo.ts | 32 ++++ apps/backend/src/routes/admin/orders.ts | 29 ++++ apps/backend/src/routes/claims.ts | 48 ++++++ .../admin/admin-order-read-helpers.ts | 3 + .../src/services/admin/admin-read-service.ts | 3 + .../src/services/admin/write/order-actions.ts | 64 +++++++ .../src/services/claim/h5-claim-order.test.ts | 46 +++++ .../claim/kuaishou-cloud-claim-context.ts | 127 +++++++++++++- .../claim/kuaishou-cloud-claim-service.ts | 89 +++++++--- apps/backend/src/types/admin/read-models.ts | 3 + apps/backend/src/types/repository/rows.ts | 3 + apps/frontend/src/App.tsx | 20 ++- .../src/pages/admin/AdminOrderDetailPage.tsx | 160 +++++++++++++++++- .../src/pages/admin/AdminOrdersPage.tsx | 33 +++- apps/frontend/src/pages/claim/ClaimPage.tsx | 68 ++++++-- apps/frontend/src/services/admin/orders.ts | 16 +- apps/frontend/src/services/claim.ts | 19 +++ apps/frontend/src/types/admin/orders.ts | 6 + apps/frontend/src/utils/claim-path.ts | 24 +++ apps/frontend/tests/claim-path.test.ts | 24 ++- 21 files changed, 788 insertions(+), 50 deletions(-) create mode 100644 apps/backend/src/db/migrations/099_order_claim_status.sql create mode 100644 apps/backend/src/services/admin/write/order-actions.ts create mode 100644 apps/backend/src/services/claim/h5-claim-order.test.ts diff --git a/apps/backend/src/db/migrations/099_order_claim_status.sql b/apps/backend/src/db/migrations/099_order_claim_status.sql new file mode 100644 index 00000000..c05bd9aa --- /dev/null +++ b/apps/backend/src/db/migrations/099_order_claim_status.sql @@ -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 '订单兑换状态最后变更时间'; diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 3a8b5de9..a9d20bf3 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -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 = ( 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 { + const updatedAt = input.updatedAt || nowIso() + const result = await query( + ` + 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 +} + diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 8bdddb8b..2f70dd68 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -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) : {}, + }), + }, + ), +) + router.post( '/orders/:orderId/kuaishou-industry/resend-code', requireAdminRoles(['admin', 'operator', 'support']), @@ -58,3 +86,4 @@ router.post( ) export default router + diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index 0b96edfb..782a9f96 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -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, diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index 3a2c861d..d7582ec0 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -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, } } diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 3046732e..9f9fb237 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -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, diff --git a/apps/backend/src/services/admin/write/order-actions.ts b/apps/backend/src/services/admin/write/order-actions.ts new file mode 100644 index 00000000..ffd9ade3 --- /dev/null +++ b/apps/backend/src/services/admin/write/order-actions.ts @@ -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, + } +} diff --git a/apps/backend/src/services/claim/h5-claim-order.test.ts b/apps/backend/src/services/claim/h5-claim-order.test.ts new file mode 100644 index 00000000..85f31873 --- /dev/null +++ b/apps/backend/src/services/claim/h5-claim-order.test.ts @@ -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', + }, + ) +}) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index ac7f8f05..fd11f79b 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -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 = { 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 { + 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, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index a5eb75d4..9c4531be 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -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 @@ -53,8 +58,17 @@ function isRetiredExecutor(executorKey: unknown) { return RETIRED_EXECUTOR_KEYS.has(String(executorKey || '').trim()) } +async function refreshContextTask(context: ClaimContext): Promise { + const refreshed = await getTaskById(context.task.id) + return refreshed ? { ...context, task: refreshed } : context +} + export async function getKuaishouCloudClaimDetail(token: unknown): Promise { const context = await getClaimContext(token) + return getClaimDetailByContext(context) +} + +export async function getClaimDetailByContext(context: ClaimContext): Promise { let task = context.task const executorKey = String(task.executor_key || '').trim() @@ -99,15 +113,15 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise { + const context = await getOrderClaimContext(orderNo) + return getClaimDetailByContext(context) +} + +export async function submitClaimUidWithContext( + context: ClaimContext, payload: { uid?: unknown } = {}, ): Promise { - 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 { + const context = await getClaimContext(token) + return submitClaimUidWithContext(context, payload) +} + +export async function submitH5ClaimUidByOrderNo( + orderNo: unknown, + payload: { uid?: unknown } = {}, +): Promise { + const context = await getOrderClaimContext(orderNo) + return submitClaimUidWithContext(context, payload) } function buildClaimIdentityContext( @@ -191,12 +225,11 @@ async function createClaimUidSubmittedEventIfChanged( } async function submitAffiliateDashClaimUid( - context: Awaited>, + context: ClaimContext, expectedUid: string, previous: ReturnType, taskContext: JsonObject, now: string, - token: unknown, ): Promise { 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 { - 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 { + const context = await getClaimContext(token) + return submitAffiliateDashClaimWithContext(context, payload) +} + +export async function submitH5AffiliateDashClaimByOrderNo( + orderNo: unknown, + payload: { gameAccount?: unknown; bindUuid?: unknown } = {}, +): Promise { + const context = await getOrderClaimContext(orderNo) + return submitAffiliateDashClaimWithContext(context, payload) } /** diff --git a/apps/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts index 772fbc20..cfa41dcc 100644 --- a/apps/backend/src/types/admin/read-models.ts +++ b/apps/backend/src/types/admin/read-models.ts @@ -41,6 +41,9 @@ export type AdminOrderListItem = { customerStatus: string preparedTaskCount: number completedTaskCount: number + claimStatus?: string + claimStatusReason?: string + claimStatusUpdatedAt?: string | null } export type AdminTaskListItem = { diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 592df153..3374a05d 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -14,6 +14,9 @@ export type OrderRow = { currency: string raw_payload_json: string | Record 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 } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1e99b661..459f4006 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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 } + // 路径式 H5 订单号领取链接(`https://<域名>/h5/<订单号>`,不带 `#`) + if (isH5ClaimPath(window.location.pathname)) { + return + } + // 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断, // 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。 if (isRedeemPath(window.location.pathname)) { @@ -190,6 +195,19 @@ function ClaimApplication() { ) } +function H5ClaimApplication() { + return ( + + }> + + } /> + } /> + + + + ) +} + function WorkerApplication() { return ( diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index cbba3795..61df3526 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -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 (
@@ -93,7 +155,45 @@ export default function AdminOrderDetailPage() { extra={} /> - + + + {detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? ( + + ) : ( + + )} + {detail.order.claimStatus !== 'closed' ? ( + + ) : null} + + } + > {detail.order.orderId} @@ -106,6 +206,28 @@ export default function AdminOrderDetailPage() { + + + {detail.order.claimStatus === 'paused' ? ( + }> + 已暂停兑换 + + ) : detail.order.claimStatus === 'closed' ? ( + }> + 已关闭兑换 + + ) : ( + }> + 正常有效 + + )} + {detail.order.claimStatusReason ? ( + + ({detail.order.claimStatusReason}) + + ) : null} + + {detail.order.totalAmount} {detail.order.currency} @@ -177,6 +299,34 @@ export default function AdminOrderDetailPage() { + + setStatusModalVisible(false)} + confirmLoading={statusMutation.isPending} + onOk={() => { + if (!statusReason.trim()) { + message.warning('请填写原因或备注(如:买家申请退款)') + return + } + statusMutation.mutate({ status: targetStatus, reason: statusReason.trim() }) + }} + > +
+

+ {targetStatus === 'paused' + ? '暂停后,买家访问 H5 链接将被拦截并提示“兑换已暂停,请联系客服”,后续可随时恢复启用。' + : '关闭后,买家将无法继续进入 H5 页面兑换,适用于已退款或订单作废。'} +

+ setStatusReason(e.target.value)} + /> +
+
) } diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index f7fadfc1..6122c3e4 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -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) => (
- navigate(`/admin/orders/${row.orderId}`)}> - {row.platformOrderId} - + + navigate(`/admin/orders/${row.orderId}`)}> + {row.platformOrderId} + +
), @@ -214,12 +230,17 @@ export default function AdminOrdersPage() { }, { title: '状态', - minWidth: 220, + minWidth: 240, render: (_, row) => ( + {row.claimStatus === 'paused' ? ( + H5已暂停 + ) : row.claimStatus === 'closed' ? ( + H5已关闭 + ) : null} ), }, diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 819c924e..f052de01 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -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() { ) : errorMessage ? ( - -

{errorMessage}

+ {errorMessage.includes('暂停') ? ( + <> + +

订单兑换已暂停

+

{errorMessage}

+ + ) : errorMessage.includes('关闭') || errorMessage.includes('退款') ? ( + <> + +

订单不可兑换

+

{errorMessage}

+ + ) : ( + <> + +

{errorMessage}

+ + )}
) : !detail ? ( diff --git a/apps/frontend/src/services/admin/orders.ts b/apps/frontend/src/services/admin/orders.ts index 02de9ed5..23e20b5b 100644 --- a/apps/frontend/src/services/admin/orders.ts +++ b/apps/frontend/src/services/admin/orders.ts @@ -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) { @@ -11,3 +11,17 @@ export function fetchAdminOrders(params?: Record) { export function fetchAdminOrderDetail(orderId: number | string) { return apiGet(`/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) +} + diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index df2b11e5..ed983d86 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -15,3 +15,22 @@ export function submitAffiliateDashClaim( ) { return apiPost(`/api/v1/claim/${token}/affiliate-dash/submit`, payload) } + +export function fetchH5ClaimDetail(orderNo: string) { + return apiGet(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}`) +} + +export function submitH5ClaimUid(orderNo: string, uid: string) { + return apiPost(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}/uid`, { uid }) +} + +export function submitH5AffiliateDashClaim( + orderNo: string, + payload: { gameAccount: string; bindUuid: string }, +) { + return apiPost( + `/api/v1/claim/h5/${encodeURIComponent(orderNo)}/affiliate-dash/submit`, + payload, + ) +} + diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 681ad549..cd432398 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -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 diff --git a/apps/frontend/src/utils/claim-path.ts b/apps/frontend/src/utils/claim-path.ts index abb4eae5..10d8f290 100644 --- a/apps/frontend/src/utils/claim-path.ts +++ b/apps/frontend/src/utils/claim-path.ts @@ -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/`) */ +export function isH5ClaimPath(pathname: unknown): boolean { + return resolveH5ClaimOrderNo(pathname) !== '' +} + function decodeTokenSegment(segment: string): string { try { return decodeURIComponent(segment) diff --git a/apps/frontend/tests/claim-path.test.ts b/apps/frontend/tests/claim-path.test.ts index 5ec2bded..cbac1e68 100644 --- a/apps/frontend/tests/claim-path.test.ts +++ b/apps/frontend/tests/claim-path.test.ts @@ -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/,容忍结尾斜杠', () => { 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/,容忍结尾斜杠', () => { + 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'), '') +}) +