From bb060e81a47809a8a7765c68c0a48feab22a1007 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 10 Jul 2026 13:30:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A2=86=E5=8F=96=E6=B5=81?= =?UTF-8?q?=EF=BC=9AStep1=20=E5=A1=AB=20UID=EF=BC=8C=E6=8C=89=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=88=86=E5=8F=89=E5=B1=A5=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 领取页改为统一 UID 入口;lewan 强制角色 ID 匹配后才发货核销,feifei 将 uid 拼入 H5 并去掉 claim 短链,对外仍返回本站 claimUrl。 --- apps/backend/src/db/migrate.ts | 15 +- apps/backend/src/routes/claims.ts | 11 + .../src/services/admin/admin-read-service.ts | 18 +- .../src/services/claim/claim-identity.test.ts | 40 ++ .../src/services/claim/claim-identity.ts | 199 ++++++++ .../claim/kuaishou-cloud-claim-context.ts | 20 +- .../claim/kuaishou-cloud-claim-service.ts | 135 +++-- .../fulfillment/delivery-link-service.test.ts | 13 +- .../executors/kuaishou-feifei-executor.ts | 38 +- .../kuaishou-cloud/dispatch-role-sync.ts | 92 +++- .../fulfillment/kuaishou-feifei/index.test.ts | 19 +- .../fulfillment/kuaishou-feifei/index.ts | 93 ++-- apps/frontend/src/pages/claim/ClaimPage.tsx | 477 +++++++++++++----- apps/frontend/src/services/claim.ts | 4 + apps/frontend/src/types/claim.ts | 9 + 15 files changed, 898 insertions(+), 285 deletions(-) create mode 100644 apps/backend/src/services/claim/claim-identity.test.ts create mode 100644 apps/backend/src/services/claim/claim-identity.ts diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 58dbb658..1b0306ca 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -83,11 +83,18 @@ function assertMigrationFilesOrdered() { const versions = files.map((name) => Number(name.slice(0, 3))) for (let index = 1; index < versions.length; index += 1) { - if (versions[index] < versions[index - 1]) { - throw new Error(`迁移序号乱序: ${files[index - 1]} 之后出现 ${files[index]}`) + const current = versions[index] + const previous = versions[index - 1] + const currentFile = files[index] + const previousFile = files[index - 1] + if (current === undefined || previous === undefined || !currentFile || !previousFile) { + continue } - if (versions[index] === versions[index - 1]) { - throw new Error(`迁移序号重复: ${files[index - 1]} 与 ${files[index]}`) + if (current < previous) { + throw new Error(`迁移序号乱序: ${previousFile} 之后出现 ${currentFile}`) + } + if (current === previous) { + throw new Error(`迁移序号重复: ${previousFile} 与 ${currentFile}`) } } diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index ec18afde..58827034 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -6,6 +6,7 @@ import { getKuaishouCloudClaimDetail, rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, + submitClaimUid, } from '../services/claim/kuaishou-cloud-claim-service.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' @@ -32,6 +33,16 @@ router.get( }), ) +router.post( + '/:token/uid', + claimWriteRateLimit, + createRouteHandler((req) => submitClaimUid(req.params.token, req.body || {}), { + successMessage: 'UID 已提交', + errorMessage: '提交 UID 失败', + scope: '[claims/:token/uid]', + }), +) + router.post( '/:token/kuaishou-cloud/confirm-role', claimWriteRateLimit, diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index bf9832a8..d69b4a81 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -9,7 +9,7 @@ import { listKuaishouIndustryVouchersByTaskId, } from '../../repositories/kuaishou-industry-voucher-repo.js' import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' -import { resolveKuaishouFeifeiClaimUrl } from '../fulfillment/kuaishou-feifei/index.js' + import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { @@ -242,10 +242,8 @@ export async function getAdminTaskDetail( const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) const viewerContext = createAdminViewerContext(session) - const directClaimUrl = String(task.executor_key || '').trim() === 'kuaishou_feifei' - ? resolveKuaishouFeifeiClaimUrl(taskContext.kuaishouFeifei) - : '' - const claimUrl = directClaimUrl || (claimToken ? buildClaimUrl(claimToken.token) : '') + // 统一对外领取入口为本站 claim 链接;平台 H5 仅作内部上下文 + const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() const kuaishouCloudFulfillment = @@ -300,12 +298,12 @@ export async function getAdminTaskDetail( quantity: orderItem.quantity, } : null, - claimToken: claimToken || directClaimUrl + claimToken: claimToken ? { - primaryClaimTokenId: claimToken?.id || 0, - token: viewerContext.canViewSensitiveTaskData ? (claimToken?.token || '') : '', - status: claimToken?.status || 'external', - expiredAt: claimToken?.expired_at || '', + primaryClaimTokenId: claimToken.id, + token: viewerContext.canViewSensitiveTaskData ? claimToken.token || '' : '', + status: claimToken.status || '', + expiredAt: claimToken.expired_at || '', claimUrl, } : null, diff --git a/apps/backend/src/services/claim/claim-identity.test.ts b/apps/backend/src/services/claim/claim-identity.test.ts new file mode 100644 index 00000000..c2e5fb82 --- /dev/null +++ b/apps/backend/src/services/claim/claim-identity.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + appendUidToUrl, + assertValidClaimUid, + isClaimUidMatched, + normalizeClaimUid, +} from './claim-identity.js' + +test('normalizeClaimUid trims and removes spaces', () => { + assert.equal(normalizeClaimUid(' 166 909 256 '), '166909256') +}) + +test('assertValidClaimUid accepts common game uid', () => { + assert.equal(assertValidClaimUid('166909256'), '166909256') +}) + +test('assertValidClaimUid rejects empty value', () => { + assert.throws(() => assertValidClaimUid(''), /请输入游戏 UID/) +}) + +test('isClaimUidMatched compares normalized values', () => { + assert.equal(isClaimUidMatched('166909256', '166909256'), true) + assert.equal(isClaimUidMatched('166909256', '999'), false) +}) + +test('appendUidToUrl sets uid query param', () => { + const source = + 'http://skin-exchange.yiquyou.icu/h5/bind?code=eyJpdi&product_name=%E5%A5%97%E8%A3%85' + const next = appendUidToUrl(source, '166909256') + const url = new URL(next) + assert.equal(url.searchParams.get('uid'), '166909256') + assert.equal(url.searchParams.get('code'), 'eyJpdi') +}) + +test('appendUidToUrl overwrites existing uid', () => { + const next = appendUidToUrl('https://example.com/h5/bind?code=abc&uid=old', 'new-uid') + assert.equal(new URL(next).searchParams.get('uid'), 'new-uid') +}) diff --git a/apps/backend/src/services/claim/claim-identity.ts b/apps/backend/src/services/claim/claim-identity.ts new file mode 100644 index 00000000..687a3f64 --- /dev/null +++ b/apps/backend/src/services/claim/claim-identity.ts @@ -0,0 +1,199 @@ +import { createHttpError } from '../../utils/http.js' +import { parseTaskContext } from '../../utils/task-json.js' +import { isTaskFinalStatus, normalizeTaskStatus, TASK_STATUS } from '../../domain/task-status.js' +import type { TaskRow } from '../../types/repository/rows.js' + +export type ClaimIdentity = { + expectedUid: string + submittedAt: string | null + source: string +} + +type JsonObject = Record + +const CLAIM_UID_MAX_LENGTH = 64 + +export function normalizeClaimUid(value: unknown): string { + return String(value || '') + .trim() + .replace(/\s+/g, '') +} + +export function assertValidClaimUid(value: unknown): string { + const uid = normalizeClaimUid(value) + if (!uid) { + throw createHttpError('请输入游戏 UID', { + statusCode: 400, + errorCode: 'claim_uid_required', + }) + } + + if (uid.length > CLAIM_UID_MAX_LENGTH) { + throw createHttpError(`UID 长度不能超过 ${CLAIM_UID_MAX_LENGTH} 个字符`, { + statusCode: 400, + errorCode: 'claim_uid_too_long', + }) + } + + if (!/^[A-Za-z0-9_\-]+$/.test(uid)) { + throw createHttpError('UID 仅支持字母、数字、下划线和中划线', { + statusCode: 400, + errorCode: 'claim_uid_invalid', + }) + } + + return uid +} + +export function normalizeClaimIdentity(value: unknown): ClaimIdentity { + const source = isPlainObject(value) ? value : {} + return { + expectedUid: normalizeClaimUid(source.expectedUid || source.uid), + submittedAt: source.submittedAt ? String(source.submittedAt) : null, + source: String(source.source || '').trim(), + } +} + +export function getClaimIdentityFromContext(context: unknown): ClaimIdentity { + const source = isPlainObject(context) ? context : {} + return normalizeClaimIdentity(source.claimIdentity) +} + +export function getClaimIdentityFromTask(task: Partial | null | undefined): ClaimIdentity { + return getClaimIdentityFromContext(parseTaskContext(task)) +} + +export function hasClaimExpectedUid(taskOrContext: unknown): boolean { + if (taskOrContext && typeof taskOrContext === 'object' && 'context_json' in (taskOrContext as object)) { + return Boolean(getClaimIdentityFromTask(taskOrContext as TaskRow).expectedUid) + } + return Boolean(getClaimIdentityFromContext(taskOrContext).expectedUid) +} + +export function isClaimUidMatched(expectedUid: unknown, boundUid: unknown): boolean { + const expected = normalizeClaimUid(expectedUid) + const bound = normalizeClaimUid(boundUid) + if (!expected || !bound) { + return false + } + return expected === bound +} + +export function resolveBoundRoleUid(flowLike: unknown): string { + const flow = isPlainObject(flowLike) ? flowLike : {} + const binding = isPlainObject(flow.binding) ? flow.binding : {} + const role = isPlainObject(flow.role) ? flow.role : {} + return normalizeClaimUid( + binding.roleId || role.rid || role.roleId || binding.uid || role.uid || '', + ) +} + +export function assertClaimExpectedUidReady(task: Partial | null | undefined): string { + const expectedUid = getClaimIdentityFromTask(task).expectedUid + if (!expectedUid) { + throw createHttpError('请先填写游戏 UID', { + statusCode: 409, + errorCode: 'claim_uid_not_submitted', + }) + } + return expectedUid +} + +export function assertBoundUidMatchesExpected( + task: Partial | null | undefined, + flowLike: unknown, + options: { errorCodePrefix?: string } = {}, +): string { + const expectedUid = assertClaimExpectedUidReady(task) + const boundUid = resolveBoundRoleUid(flowLike) + const prefix = String(options.errorCodePrefix || 'claim').trim() || 'claim' + + if (!boundUid) { + throw createHttpError('尚未识别到绑定角色 ID,请完成绑定后刷新再试', { + statusCode: 409, + errorCode: `${prefix}_bound_uid_missing`, + }) + } + + if (!isClaimUidMatched(expectedUid, boundUid)) { + throw createHttpError( + `绑定角色 ID(${boundUid})与填写的 UID(${expectedUid})不一致,请重新绑定正确角色`, + { + statusCode: 409, + errorCode: `${prefix}_uid_mismatch`, + }, + ) + } + + return expectedUid +} + +export function canUpdateClaimUid(task: Partial | null | undefined): boolean { + const status = normalizeTaskStatus(task?.task_status) + if (isTaskFinalStatus(status)) { + return false + } + + if ( + status === TASK_STATUS.REDEEMING || + status === TASK_STATUS.DISPATCHED_PENDING_RETURN || + status === TASK_STATUS.COMPLETED || + status === TASK_STATUS.REDEEMED + ) { + return false + } + + const context = parseTaskContext(task) + const flow = isPlainObject(context.kuaishouCloudFulfillment) + ? context.kuaishouCloudFulfillment + : {} + const dispatch = isPlainObject(flow.dispatch) ? flow.dispatch : {} + if (String(dispatch.status || '').trim() === 'success') { + return false + } + + const feifei = isPlainObject(context.kuaishouFeifei) ? context.kuaishouFeifei : {} + const rechargeStatus = Number(feifei.rechargeStatus || 0) || 0 + if (rechargeStatus === 30) { + return false + } + + return true +} + +/** + * 将 uid 拼到目标 H5 链接 query 中(覆盖已有 uid 参数)。 + */ +export function appendUidToUrl(targetUrl: unknown, uid: unknown): string { + const rawUrl = String(targetUrl || '').trim() + const normalizedUid = normalizeClaimUid(uid) + if (!rawUrl || !normalizedUid) { + return rawUrl + } + + try { + const url = new URL(rawUrl) + url.searchParams.set('uid', normalizedUid) + return url.toString() + } catch { + const separator = rawUrl.includes('?') ? '&' : '?' + if (/([?&])uid=/i.test(rawUrl)) { + return rawUrl.replace(/([?&])uid=[^&]*/i, `$1uid=${encodeURIComponent(normalizedUid)}`) + } + return `${rawUrl}${separator}uid=${encodeURIComponent(normalizedUid)}` + } +} + +export function buildClaimIdentityPayload(value: unknown) { + const identity = normalizeClaimIdentity(value) + return { + expectedUid: identity.expectedUid, + submittedAt: identity.submittedAt, + source: identity.source, + ready: Boolean(identity.expectedUid), + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} 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 9b381f8a..0df49ca1 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -8,6 +8,8 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json import { nowIso } from '../../utils/time.js' import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' import { buildClaimUrl } from './claim-service.js' +import { buildClaimIdentityPayload, getClaimIdentityFromContext } from './claim-identity.js' +import { resolveKuaishouFeifeiH5UrlWithUid } from '../fulfillment/kuaishou-feifei/index.js' import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED]) @@ -89,6 +91,8 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }) } + const taskContext = parseTaskContext(task) + const claimIdentity = buildClaimIdentityPayload(taskContext.claimIdentity) const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task) const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) const displaySkuName = resolveClaimOrderItemDisplaySkuName( @@ -102,6 +106,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), flowType: 'kuaishou_cloud', + claimIdentity, task: { taskId: task.id, taskNo: task.task_no, @@ -149,7 +154,9 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { const context = parseTaskContext(task) - const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei) + const claimIdentity = buildClaimIdentityPayload(context.claimIdentity) + const expectedUid = getClaimIdentityFromContext(context).expectedUid + const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei, expectedUid) const product = { title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), skuCode: String(orderItem.sku_code || '').trim(), @@ -166,6 +173,7 @@ function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderI tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), flowType: 'kuaishou_feifei', + claimIdentity, task: { taskId: task.id, taskNo: task.task_no, @@ -211,9 +219,12 @@ function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderI } } -function mapClaimKuaishouFeifeiFulfillment(value: unknown) { +function mapClaimKuaishouFeifeiFulfillment(value: unknown, expectedUid = '') { const source = isPlainObject(value) ? value : {} const h5 = isPlainObject(source.h5) ? source.h5 : {} + const entryUrl = String(h5.entryUrl || '').trim() + const rechargeUrl = String(h5.rechargeUrl || '').trim() + const h5UrlWithUid = resolveKuaishouFeifeiH5UrlWithUid(source, expectedUid) return { flowType: 'kuaishou_feifei', @@ -227,9 +238,10 @@ function mapClaimKuaishouFeifeiFulfillment(value: unknown) { claimUrl: String(source.claimUrl || '').trim(), consumeStatus: String(source.consumeStatus || 'pending').trim(), h5: { - entryUrl: String(h5.entryUrl || '').trim(), - rechargeUrl: String(h5.rechargeUrl || '').trim(), + entryUrl, + rechargeUrl, }, + h5UrlWithUid: expectedUid ? h5UrlWithUid : '', lastSyncedAt: source.lastSyncedAt || null, } } 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 74f96edb..1aa5b798 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -1,6 +1,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js' import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' +import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { addHours, nowIso } from '../../utils/time.js' import { TASK_STATUS, @@ -11,14 +12,19 @@ import { } from '../../domain/task-status.js' import { dispatchKuaishouCloudFulfillmentTask, - hasKuaishouCloudCustomerRole, - hasKuaishouCloudDefaultRoleSnapshot, normalizeKuaishouCloudFlow, prepareKuaishouCloudFulfillmentTask, rebindKuaishouCloudTaskRole, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud/index.js' import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js' +import { + assertBoundUidMatchesExpected, + assertClaimExpectedUidReady, + assertValidClaimUid, + canUpdateClaimUid, + getClaimIdentityFromTask, +} from './claim-identity.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import type { TaskRow } from '../../types/repository/rows.js' @@ -179,9 +185,11 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise { + const context = await getClaimContext(token) + const expectedUid = assertValidClaimUid(payload.uid) + const now = nowIso() + + if (!canUpdateClaimUid(context.task)) { + throw createHttpError('当前任务状态不可修改 UID', { + statusCode: 409, + errorCode: 'claim_uid_locked', + }) + } + + const taskContext = parseTaskContext(context.task) + const previous = getClaimIdentityFromTask(context.task) + const nextContext = { + ...taskContext, + claimIdentity: { + expectedUid, + submittedAt: previous.expectedUid === expectedUid && previous.submittedAt + ? previous.submittedAt + : now, + source: 'claim_page', + }, + } + + let updatedTask = + (await updateTask(context.task.id, { + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + last_error: '', + updated_at: now, + })) || { + ...context.task, + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + updated_at: now, + } + + if (previous.expectedUid !== expectedUid) { + await createTaskEvent( + context.task.id, + 'claim_uid_submitted', + { + expectedUid, + previousUid: previous.expectedUid || '', + source: 'claim_page', + }, + now, + ) + } + + const executorKey = String(updatedTask.executor_key || '').trim() + if (executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(updatedTask)) { + try { + const prepared = await prepareKuaishouCloudFulfillmentTask(updatedTask, { + source: 'claim_page_submit_uid', + actor: { source: 'claim_page' }, + }) + if (prepared?.task) { + updatedTask = prepared.task + } + } catch { + // 绑定资源准备失败时仍保留 UID,详情页可继续重试/轮询 + } + } + + return getKuaishouCloudClaimDetail(token) +} + export async function rebindKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) @@ -213,20 +296,7 @@ export async function rebindKuaishouCloudClaimRole(token: unknown) { } function parseTaskContext(task: Partial | null | undefined): JsonObject { - const rawValue = task?.context_json - if (!rawValue) { - return {} - } - - if (typeof rawValue === 'object') { - return rawValue - } - - try { - return JSON.parse(String(rawValue || '{}')) - } catch { - return {} - } + return parseTaskContextValue(task) } function hasUsableIndustryVoucher(context: JsonObject = {}) { @@ -273,6 +343,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { return getKuaishouCloudClaimDetail(token) } + const expectedUid = assertClaimExpectedUidReady(context.task) const contextSource = parseTaskContext(context.task) const mockMode = isKuaishouCloudMockContext(contextSource) const refreshed = mockMode @@ -285,13 +356,6 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { }) const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment) - if (flow.ticket.status !== 'verified') { - throw createHttpError('请先完成电子凭证确认', { - statusCode: 409, - errorCode: 'claim_kuaishou_cloud_ticket_not_verified', - }) - } - if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) { throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', { statusCode: 409, @@ -299,16 +363,9 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { }) } - const hasDefaultRoleSnapshot = hasKuaishouCloudDefaultRoleSnapshot(flow) - if (!mockMode && !hasKuaishouCloudCustomerRole(flow)) { - const message = hasDefaultRoleSnapshot - ? '当前仍是虚拟机默认角色,请先重新绑定自己的角色信息' - : '系统还未获取到虚拟机默认角色信息,请稍后刷新角色信息后再试' - throw createHttpError(message, { - statusCode: 409, - errorCode: hasDefaultRoleSnapshot - ? 'claim_kuaishou_cloud_default_role_not_rebound' - : 'claim_kuaishou_cloud_default_role_not_ready', + if (!mockMode) { + assertBoundUidMatchesExpected(refreshed.task, flow, { + errorCodePrefix: 'claim_kuaishou_cloud', }) } @@ -326,6 +383,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) { context.task.id, 'kuaishou_cloud_role_confirmed', { + expectedUid, vnPhone: flow.binding.vnPhone, roleName: flow.binding.roleName, roleId: flow.binding.roleId, @@ -359,6 +417,15 @@ export async function redeemKuaishouCloudClaim(token: unknown) { }) } + const flowBeforeRedeem = normalizeKuaishouCloudFlow( + parseTaskContext(context.task).kuaishouCloudFulfillment, + ) + if (!isKuaishouCloudMockTask(context.task)) { + assertBoundUidMatchesExpected(context.task, flowBeforeRedeem, { + errorCodePrefix: 'claim_kuaishou_cloud_redeem', + }) + } + const lockedTask = await updateTaskStatusIfCurrent(context.task.id, TASK_STATUS.ROLE_CONFIRMED, { task_status: TASK_STATUS.REDEEMING, user_action_status: 'not_required', diff --git a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts index c36dd177..fe559f23 100644 --- a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts +++ b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts @@ -31,16 +31,13 @@ test('resolveTaskDeliveryLink 兼容历史 kuaishou-industry cloud 任务', asyn }) }) -test('resolveTaskDeliveryLink 为 feifei 任务返回已保存短链', async () => { +test('resolveTaskDeliveryLink 为 feifei 任务返回本站领取链接', async () => { const result = await resolveTaskDeliveryLink(createTask({ executor_key: 'kuaishou_feifei', + primary_claim_token: 'feifei-token', + primary_claim_expires_at: '2026-07-09T12:00:00.000Z', context_json: { kuaishouFeifei: { - shortLink: { - code: 'AbCd1234', - url: 'https://ks.khhao.com/s/AbCd1234', - targetUrl: 'https://feifei.example.com/h5/bind?code=very-long', - }, h5: { rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', }, @@ -49,8 +46,8 @@ test('resolveTaskDeliveryLink 为 feifei 任务返回已保存短链', async () })) assert.deepEqual(result, { - claimUrl: 'https://ks.khhao.com/s/AbCd1234', - expireTime: '', + claimUrl: buildClaimUrl('feifei-token'), + expireTime: '2026-07-09T12:00:00.000Z', }) }) diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts index 5e1b450a..e7145258 100644 --- a/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts @@ -1,10 +1,6 @@ +import { buildClaimUrl } from '../../claim/claim-service.js' import { TASK_STATUS } from '../../../domain/task-status.js' -import { - ensureKuaishouFeifeiClaimShortLink, - prepareKuaishouFeifeiTask, - resolveKuaishouFeifeiClaimUrl, -} from '../kuaishou-feifei/index.js' -import { parseTaskContext } from '../../../utils/task-json.js' +import { prepareKuaishouFeifeiTask } from '../kuaishou-feifei/index.js' import { FULFILLMENT_EXECUTOR_KEYS, type FulfillmentDeliveryLink, @@ -24,7 +20,19 @@ async function preparePaidTask( deps: FulfillmentPrepareDeps, ): Promise { try { - return await prepareKuaishouFeifeiTask(task) + let workingTask = task + if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { + const claimToken = await deps.createTaskClaimToken(task.id) + workingTask = + (await deps.updateTask(task.id, { + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + user_action_status: 'pending_claim', + updated_at: deps.nowIso(), + })) || task + } + + return await prepareKuaishouFeifeiTask(workingTask) } catch (error) { const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' const updatedTask = await deps.updateTask(task.id, { @@ -44,19 +52,19 @@ async function preparePaidTask( } } +/** + * 对外(91 等)统一返回本站领取链接,不再返回 feifei H5 或短链。 + */ async function resolveDeliveryLink(task: TaskRow): Promise { - let claimUrl = await ensureKuaishouFeifeiClaimShortLink(task) - if (!claimUrl) { - const context = parseTaskContext(task) - claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei) - } + const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() + const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim() - if (!claimUrl) { + if (!primaryToken) { return null } return { - claimUrl, - expireTime: '', + claimUrl: buildClaimUrl(primaryToken), + expireTime, } } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts index f7e9f461..2b7b4772 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts @@ -3,6 +3,11 @@ import { updateTask } from "../../../repositories/task-repo.js"; import type { TaskRow } from "../../../types/repository/rows.js"; import { createHttpError } from "../../../utils/http.js"; import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js"; +import { + getClaimIdentityFromContext, + isClaimUidMatched, + normalizeClaimUid, +} from "../../claim/claim-identity.js"; import { normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, @@ -31,6 +36,7 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch( const flow = normalizeKuaishouCloudFlow( input.flow || taskContext.kuaishouCloudFulfillment ); + const expectedUid = getClaimIdentityFromContext(taskContext).expectedUid; if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", { @@ -62,40 +68,70 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch( const confirmedRoleId = String( flow.binding.roleId || flow.role.rid || task.role_id || "" ).trim(); - const roleMatches = isSameKuaishouCloudRole( - { - name: confirmedRoleName, - rid: confirmedRoleId, - }, - { - name: roleInfo.name, - rid: roleInfo.rid, + const liveBoundUid = normalizeClaimUid(roleInfo.rid); + + // 优先:声明 UID 与云端当前绑定角色一致 + if (expectedUid) { + if (!isClaimUidMatched(expectedUid, liveBoundUid)) { + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_uid_mismatch", + { + source, + vnId: flow.binding.vnId, + expectedUid, + cloudtentaclesRoleName: roleInfo.name, + cloudtentaclesRoleId: roleInfo.rid, + actor: input.actor || null, + }, + now + ); + + throw createHttpError( + `cloudtentacles 当前绑定角色 ID(${roleInfo.rid})与用户填写 UID(${expectedUid})不一致,不能发货`, + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_uid_mismatch`, + } + ); } - ); - - if (!roleMatches) { - await createTaskEvent( - task.id, - "kuaishou_cloud_dispatch_role_mismatch", + } else { + // 兼容旧任务:无 expectedUid 时仍比对「确认角色」 + const roleMatches = isSameKuaishouCloudRole( { - source, - vnId: flow.binding.vnId, - confirmedRoleName, - confirmedRoleId, - cloudtentaclesRoleName: roleInfo.name, - cloudtentaclesRoleId: roleInfo.rid, - actor: input.actor || null, + name: confirmedRoleName, + rid: confirmedRoleId, }, - now - ); - - throw createHttpError( - `cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`, { - statusCode: 409, - errorCode: `${errorCodePrefix}_dispatch_role_mismatch`, + name: roleInfo.name, + rid: roleInfo.rid, } ); + + if (!roleMatches) { + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_role_mismatch", + { + source, + vnId: flow.binding.vnId, + confirmedRoleName, + confirmedRoleId, + cloudtentaclesRoleName: roleInfo.name, + cloudtentaclesRoleId: roleInfo.rid, + actor: input.actor || null, + }, + now + ); + + throw createHttpError( + `cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`, + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_role_mismatch`, + } + ); + } } const nextFlow = normalizeKuaishouCloudFlow({ diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts index 5d7776bd..81d7d7eb 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict' import { buildFeifeiPlatformOrderNo, resolveKuaishouFeifeiClaimUrl, + resolveKuaishouFeifeiH5UrlWithUid, resolveKuaishouFeifeiPlatformAmount, } from './index.js' import type { TaskRow } from '../../../types/repository/rows.js' @@ -31,7 +32,7 @@ test('resolveKuaishouFeifeiClaimUrl does not expose local claim token fallback', ) }) -test('resolveKuaishouFeifeiClaimUrl prefers internal short link', () => { +test('resolveKuaishouFeifeiClaimUrl prefers direct h5 over historical short link url', () => { assert.equal( resolveKuaishouFeifeiClaimUrl({ shortLink: { @@ -43,10 +44,24 @@ test('resolveKuaishouFeifeiClaimUrl prefers internal short link', () => { rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', }, }), - 'https://ks.khhao.com/s/AbCd1234', + 'https://feifei.example.com/h5/bind?code=very-long', ) }) +test('resolveKuaishouFeifeiH5UrlWithUid appends uid query', () => { + const next = resolveKuaishouFeifeiH5UrlWithUid( + { + h5: { + rechargeUrl: 'http://skin-exchange.yiquyou.icu/h5/bind?code=eyJpdi&product_name=demo', + }, + }, + '166909256', + ) + const url = new URL(next) + assert.equal(url.searchParams.get('uid'), '166909256') + assert.equal(url.searchParams.get('code'), 'eyJpdi') +}) + test('buildFeifeiPlatformOrderNo prefers source order number', () => { assert.equal( buildFeifeiPlatformOrderNo(createTask({ diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts index 607081e8..3bb5e4aa 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts @@ -7,17 +7,14 @@ import { logIntegration } from '../../../utils/logger.js' import { parseTaskContext } from '../../../utils/task-json.js' import { nowIso } from '../../../utils/time.js' import { TASK_STATUS } from '../../../domain/task-status.js' +import { appendUidToUrl, getClaimIdentityFromContext } from '../../claim/claim-identity.js' import { createKuaishouFeifeiOrder, queryKuaishouFeifeiOrder, } from '../../platforms/kuaishou-feifei/order-service.js' import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js' import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js' -import { - SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM, - ensureShortLinkForTarget, - normalizeShortLinkPayload, -} from '../../short-links/short-link-service.js' +import { normalizeShortLinkPayload } from '../../short-links/short-link-service.js' import type { TaskRow } from '../../../types/repository/rows.js' type JsonObject = Record @@ -40,15 +37,13 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) { const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow) if (flow.orderNo && existingClaimUrl) { - const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task) - const nextContext = { - ...taskContext, - kuaishouFeifei: nextFlow, - } return updateTask(task.id, { task_status: TASK_STATUS.LINK_GENERATED, user_action_status: 'pending_claim', - context_json: JSON.stringify(nextContext), + context_json: JSON.stringify({ + ...taskContext, + kuaishouFeifei: flow, + }), updated_at: now, }) } @@ -80,11 +75,11 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) { }) } - const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, { + const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { platformOrderNo, claimUrl: feifeiClaimUrl, syncedAt: now, - }), task) + }) const updatedTask = await updateTask(task.id, { task_status: TASK_STATUS.LINK_GENERATED, user_action_status: 'pending_claim', @@ -137,10 +132,10 @@ export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) { let resultCode = task.result_code let resultMessage = task.result_message let lastError = task.last_error - const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, { + const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { syncedAt: now, claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow), - }), task) + }) let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher if (order.rechargeStatus === 30) { @@ -230,6 +225,7 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) { rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), claimUrl: String(source.claimUrl || '').trim(), consumeStatus: String(source.consumeStatus || 'pending').trim(), + // 历史字段保留读取,新流程不再生成 shortLink shortLink: normalizeShortLinkPayload(source.shortLink), h5: { entryUrl: String(h5.entryUrl || '').trim(), @@ -240,38 +236,38 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) { } } +/** 平台原始 H5 链接(不拼 uid、不用短链)。 */ export function resolveKuaishouFeifeiClaimUrl(value: unknown) { const flow = normalizeKuaishouFeifeiFlow(value) - if (flow.shortLink?.url) { - return flow.shortLink.url - } - return resolveKuaishouFeifeiDirectClaimUrl(flow) } -export async function ensureKuaishouFeifeiClaimShortLink(task: TaskRow) { - if (!isKuaishouFeifeiTask(task)) { +/** 在已有 expectedUid 时返回拼好 uid 的 H5 链接。 */ +export function resolveKuaishouFeifeiH5UrlWithUid( + value: unknown, + expectedUid?: unknown, +) { + const directUrl = resolveKuaishouFeifeiClaimUrl(value) + if (!directUrl) { return '' } - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei) - const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task) - const claimUrl = resolveKuaishouFeifeiClaimUrl(nextFlow) - - if (JSON.stringify(flow.shortLink || null) === JSON.stringify(nextFlow.shortLink || null)) { - return claimUrl + const uid = String(expectedUid || '').trim() + if (!uid) { + return directUrl } - await updateTask(task.id, { - context_json: JSON.stringify({ - ...taskContext, - kuaishouFeifei: nextFlow, - }), - updated_at: nowIso(), - }) + return appendUidToUrl(directUrl, uid) +} - return claimUrl +export function resolveKuaishouFeifeiH5UrlWithTaskUid( + task: Partial | null | undefined, + flowValue?: unknown, +) { + const context = parseTaskContext(task) + const identity = getClaimIdentityFromContext(context) + const flow = flowValue === undefined ? context.kuaishouFeifei : flowValue + return resolveKuaishouFeifeiH5UrlWithUid(flow, identity.expectedUid) } function mergeKuaishouFeifeiOrder( @@ -303,30 +299,6 @@ function mergeKuaishouFeifeiOrder( } } -async function ensureKuaishouFeifeiFlowShortLink( - flow: ReturnType, - task: TaskRow, -) { - const targetUrl = resolveKuaishouFeifeiDirectClaimUrl(flow) - if (!targetUrl) { - return flow - } - - if (flow.shortLink?.url && flow.shortLink.targetUrl === targetUrl) { - return flow - } - - const shortLink = await ensureShortLinkForTarget(targetUrl, { - source: SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM, - taskId: task.id, - }) - - return { - ...flow, - shortLink, - } -} - function resolveKuaishouFeifeiDirectClaimUrl( flow: ReturnType, ) { @@ -335,6 +307,7 @@ function resolveKuaishouFeifeiDirectClaimUrl( return h5ClaimUrl } + // 兼容历史 shortLink 中的原始 target if (flow.shortLink?.targetUrl) { return flow.shortLink.targetUrl } diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 5d72cab1..4fa07ee5 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -11,6 +11,7 @@ import { Button, Card, Empty, + Input, Space, Spin, Tooltip, @@ -34,6 +35,7 @@ import { fetchClaimDetail, rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, + submitClaimUid, } from '@/services/claim' import type { ClaimDetailData, @@ -50,9 +52,9 @@ const ROLE_SLOW_POLL_MS = 30_000 const ROLE_IDLE_POLL_MS = 60_000 const ROLE_FAST_WINDOW_MS = 3 * 60_000 const ROLE_IDLE_WINDOW_MS = 10 * 60_000 +const FEIFEI_POLL_MS = 15_000 type ResultVariant = 'success' | 'warning' | 'info' - type ClaimSnapshot = ReturnType export default function ClaimPage() { @@ -60,6 +62,8 @@ export default function ClaimPage() { const token = String(routeToken || '').trim() const [loading, setLoading] = useState(true) + const [submittingUid, setSubmittingUid] = useState(false) + const [uidInput, setUidInput] = useState('') const [refreshingRole, setRefreshingRole] = useState(false) const [confirmingRole, setConfirmingRole] = useState(false) const [rebindingRole, setRebindingRole] = useState(false) @@ -68,7 +72,7 @@ export default function ClaimPage() { const [detail, setDetail] = useState(null) const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') - const pollTimerRef = useRef(0) + const pollTimerRef = useRef(0) const rolePollBaselineAtRef = useRef(0) const rolePollBaselineKeyRef = useRef('') @@ -109,6 +113,10 @@ export default function ClaimPage() { const applyDetail = useCallback( async (nextDetail: ClaimDetailData) => { setDetail(nextDetail) + const expectedUid = String(nextDetail.claimIdentity?.expectedUid || '').trim() + if (expectedUid) { + setUidInput(expectedUid) + } await generateQRCode( String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(), ) @@ -174,19 +182,22 @@ export default function ClaimPage() { const resolveNextPollDelay = useCallback( (nextSnapshot: ClaimSnapshot) => { - if ( - !nextSnapshot.flow || - nextSnapshot.hasRedeemResult || - isClaimInactiveTaskStatus(nextSnapshot.task?.status) - ) { + if (nextSnapshot.hasRedeemResult || isClaimInactiveTaskStatus(nextSnapshot.task?.status)) { return 0 } + if (!nextSnapshot.hasExpectedUid) { + return 0 + } + + if (nextSnapshot.isFeifeiFlow) { + return FEIFEI_POLL_MS + } + if (nextSnapshot.currentStep === 2) { if (!nextSnapshot.isBindingPrepared) { return BINDING_PREPARE_POLL_MS } - return resolveRolePollDelay(nextSnapshot) } @@ -200,6 +211,7 @@ export default function ClaimPage() { rolePollBaselineKeyRef.current = '' setDetail(null) setQrCodeDataUrl('') + setUidInput('') void loadDetail() return () => { @@ -224,20 +236,40 @@ export default function ClaimPage() { } }, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) + async function handleSubmitUid() { + const uid = uidInput.trim() + if (!uid) { + showError('请输入游戏 UID') + return + } + + setSubmittingUid(true) + try { + const response = await submitClaimUid(token, uid) + await applyDetail(response.data) + showSuccess('UID 已提交') + } catch (error) { + showError(error instanceof Error ? error.message : '提交 UID 失败') + } finally { + setSubmittingUid(false) + } + } + async function refreshRole() { setRefreshingRole(true) try { const nextDetail = await loadDetail({ silent: true }) const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) - if (nextSnapshot.isCustomerRoleReady) { - showSuccess('角色信息已刷新') - } else if (nextSnapshot.isDefaultRole) { - showError('当前仍是虚拟机默认角色,请重新绑定自己的角色信息') + if (nextSnapshot.isUidMatched) { + showSuccess('角色信息已匹配,可确认下一步') + } else if (nextSnapshot.roleId) { + showError( + `当前绑定角色 ID(${nextSnapshot.roleId})与填写 UID(${nextSnapshot.expectedUid || '-'})不一致`, + ) } else if (nextDetail) { showError( nextSnapshot.flow?.role.errorMessage || - nextSnapshot.flow?.role.defaultErrorMessage || '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试', ) } @@ -261,7 +293,7 @@ export default function ClaimPage() { async function confirmRedeem() { try { - await showConfirm('兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。', '确认兑换', { + await showConfirm('兑换后不可取消,也不可退货。请确认 UID 与商品信息完全正确。', '确认兑换', { okText: '确认兑换', okButtonProps: { danger: true }, }) @@ -287,7 +319,7 @@ export default function ClaimPage() { async function rebindRole() { try { await showConfirm( - '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。领取商品不会改变,但需要重新扫码绑定角色。', + '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。请使用你填写的 UID 对应角色重新绑定。', '确认换绑角色', { okText: '确认换绑', @@ -330,9 +362,17 @@ export default function ClaimPage() { function openFeifeiUrl(useCurrentPage = false) { const url = String( - snapshot.feifei?.h5.rechargeUrl || snapshot.feifei?.h5.entryUrl || '', + snapshot.feifei?.h5UrlWithUid || + snapshot.feifei?.h5.rechargeUrl || + snapshot.feifei?.h5.entryUrl || + '', ).trim() + if (!snapshot.hasExpectedUid) { + showError('请先填写游戏 UID') + return + } + if (!url) { showError('领取链接还没准备好,请稍后刷新') return @@ -353,6 +393,7 @@ export default function ClaimPage() { product={snapshot.product} currentStep={snapshot.currentStep} progressText={snapshot.progressText} + expectedUid={snapshot.expectedUid} /> {loading ? ( @@ -365,15 +406,41 @@ export default function ClaimPage() {

{errorMessage}

+ ) : !detail ? ( + + + + ) : snapshot.currentStep === 1 ? ( + void handleSubmitUid()} + product={snapshot.product} + /> + ) : snapshot.hasRedeemResult ? ( + snapshot.isFeifeiFlow ? ( + + ) : ( + + ) ) : snapshot.isFeifeiFlow && snapshot.feifei ? ( openFeifeiUrl(false)} + onEditUid={() => { + /* stay on step2; allow re-submit via UID card if needed */ + }} + uidInput={uidInput} + submittingUid={submittingUid} + onUidChange={setUidInput} + onResubmitUid={() => void handleSubmitUid()} /> - ) : detail && snapshot.flow ? ( + ) : snapshot.flow ? ( void handleSubmitUid()} /> ) : ( @@ -406,28 +477,28 @@ function createClaimSnapshot( const orderItem = detail?.orderItem || null const product = detail?.product || null const task = detail?.task || null + const claimIdentity = detail?.claimIdentity || null + const expectedUid = String(claimIdentity?.expectedUid || '').trim() + const hasExpectedUid = Boolean(expectedUid || claimIdentity?.ready) const roleName = flow?.role.name || flow?.binding.roleName || '' const roleId = flow?.role.rid || flow?.binding.roleId || '' const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' - const isTicketVerified = flow?.ticket.status === 'verified' const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') const isBindingPrepared = flow?.binding.prepareStatus === 'ready' && Boolean(String(flow?.binding.bindUrl || '').trim()) && !isBindUrlExpired const isBindingPreparing = flow?.binding.prepareStatus === 'pending' - const canEnterBindingStep = isTicketVerified const isRoleReady = Boolean(roleName || roleId) - const hasDefaultRoleSnapshot = Boolean(flow?.role.defaultName || flow?.role.defaultRid) - const isDefaultRole = flow?.role.isDefaultRole === true - const isCustomerRoleReady = isRoleReady && hasDefaultRoleSnapshot && !isDefaultRole + const isUidMatched = + Boolean(expectedUid) && Boolean(roleId) && normalizeUid(expectedUid) === normalizeUid(roleId) const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ - flow, isBindingPrepared, - hasDefaultRoleSnapshot, isRoleReady, - isDefaultRole, + isUidMatched, + expectedUid, + roleId, rebindingRole: Boolean(options.rebindingRole), }) const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) @@ -436,26 +507,40 @@ function createClaimSnapshot( const isRedeemFailed = normalizedStatus === TASK_STATUS.MANUAL_REVIEW || normalizedStatus === TASK_STATUS.FAILED || - String(flow?.dispatch.status || '').trim() === 'failed' - const isCompleted = isKuaishouCloudCompletedStatus(task?.status) - const hasRedeemResult = isDispatched || hasKuaishouCloudRedeemResultStatus(task?.status) + String(flow?.dispatch.status || '').trim() === 'failed' || + (isFeifeiFlow && [40, 50].includes(Number(feifei?.rechargeStatus || 0))) + const isCompleted = + isKuaishouCloudCompletedStatus(task?.status) || + (isFeifeiFlow && Number(feifei?.rechargeStatus || 0) === 30) + const hasRedeemResult = + isDispatched || + hasKuaishouCloudRedeemResultStatus(task?.status) || + (isFeifeiFlow && [30, 40, 50, 60].includes(Number(feifei?.rechargeStatus || 0))) const currentStep = resolveCurrentStep({ - isFeifeiFlow, - isCompleted, + hasExpectedUid, hasRedeemResult, isRoleConfirmed, - canEnterBindingStep, + isFeifeiFlow, }) const progressText = resolveProgressText({ feifei, isFeifeiFlow, + hasExpectedUid, + expectedUid, hasRedeemResult, isRoleConfirmed, - isTicketVerified, isBindingPrepared, + isUidMatched, }) const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) - const resultDescription = resolveResultDescription({ detail, flow, task, isRedeemFailed, isCompleted }) + const resultDescription = resolveResultDescription({ + detail, + flow, + feifei, + task, + isRedeemFailed, + isCompleted, + }) const resultVariant: ResultVariant = isRedeemFailed ? 'warning' : isCompleted || isDispatched @@ -469,18 +554,17 @@ function createClaimSnapshot( orderItem, product, task, + claimIdentity, + expectedUid, + hasExpectedUid, roleName, roleId, isFeifeiFlow, - isTicketVerified, isBindUrlExpired, isBindingPrepared, isBindingPreparing, - canEnterBindingStep, isRoleReady, - hasDefaultRoleSnapshot, - isDefaultRole, - isCustomerRoleReady, + isUidMatched, confirmRoleDisabledReason, isRoleConfirmed, isDispatched, @@ -495,25 +579,28 @@ function createClaimSnapshot( } } +function normalizeUid(value: unknown) { + return String(value || '') + .trim() + .replace(/\s+/g, '') +} + function resolveConfirmRoleDisabledReason(options: { - flow: ClaimKuaishouCloudFlowInfo | null isBindingPrepared: boolean - hasDefaultRoleSnapshot: boolean isRoleReady: boolean - isDefaultRole: boolean + isUidMatched: boolean + expectedUid: string + roleId: string rebindingRole: boolean }) { if (!options.isBindingPrepared) { return '绑定二维码还在准备中,请稍后自动刷新' } - if (!options.hasDefaultRoleSnapshot) { - return options.flow?.role.defaultErrorMessage || '系统还未获取到虚拟机默认角色信息,请稍后刷新' - } if (!options.isRoleReady) { return '请先扫码绑定自己的角色,并刷新角色信息' } - if (options.isDefaultRole) { - return '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' + if (!options.isUidMatched) { + return `当前绑定角色 ID(${options.roleId || '-'})与填写 UID(${options.expectedUid || '-'})不一致` } if (options.rebindingRole) { return '正在换绑角色,请稍候' @@ -522,37 +609,38 @@ function resolveConfirmRoleDisabledReason(options: { } function resolveCurrentStep(options: { - isFeifeiFlow: boolean - isCompleted: boolean + hasExpectedUid: boolean hasRedeemResult: boolean isRoleConfirmed: boolean - canEnterBindingStep: boolean + isFeifeiFlow: boolean }) { - if (options.isFeifeiFlow) { - return options.isCompleted ? 4 : 2 + if (!options.hasExpectedUid) { + return 1 } if (options.hasRedeemResult) { return 4 } - if (options.isRoleConfirmed) { + if (!options.isFeifeiFlow && options.isRoleConfirmed) { return 3 } - if (options.canEnterBindingStep) { - return 2 - } - return 1 + return 2 } function resolveProgressText(options: { feifei: ClaimKuaishouFeifeiFlowInfo | null isFeifeiFlow: boolean + hasExpectedUid: boolean + expectedUid: string hasRedeemResult: boolean isRoleConfirmed: boolean - isTicketVerified: boolean isBindingPrepared: boolean + isUidMatched: boolean }) { + if (!options.hasExpectedUid) { + return '请填写游戏 UID' + } if (options.isFeifeiFlow) { - return options.feifei?.rechargeStatusLabel || '请打开领取链接' + return options.feifei?.rechargeStatusLabel || `UID ${options.expectedUid},请打开领取链接` } if (options.hasRedeemResult) { return '兑换结果已生成' @@ -560,10 +648,13 @@ function resolveProgressText(options: { if (options.isRoleConfirmed) { return '角色已确认,等待兑换' } - if (options.isTicketVerified) { - return options.isBindingPrepared ? '请完成扫码绑定' : '绑定链接刷新中,请稍候' + if (!options.isBindingPrepared) { + return '绑定链接准备中,请稍候' } - return '等待提交并核销' + if (options.isUidMatched) { + return 'UID 已匹配,请确认下一步' + } + return '请完成扫码绑定并匹配 UID' } function resolveResultTitle(options: { @@ -586,6 +677,7 @@ function resolveResultTitle(options: { function resolveResultDescription(options: { detail: ClaimDetailData | null flow: ClaimKuaishouCloudFlowInfo | null + feifei: ClaimKuaishouFeifeiFlowInfo | null task: ClaimDetailData['task'] | null isRedeemFailed: boolean isCompleted: boolean @@ -594,6 +686,7 @@ function resolveResultDescription(options: { const message = String( options.task?.lastError || options.flow?.dispatch.errorMessage || + options.feifei?.rechargeResultMessage || options.detail?.result?.resultMessage || '', ).trim() @@ -601,10 +694,10 @@ function resolveResultDescription(options: { } if (options.isCompleted) { - return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。' + return '当前兑换流程已经完成。' } - return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。' + return '你的兑换请求已经提交。后续结果会由系统保存在后台任务界面。' } function isDateExpired(value: string | null) { @@ -622,18 +715,21 @@ function ClaimHeaderCard({ product, currentStep, progressText, + expectedUid, }: { order: ClaimOrderInfo | null product: ClaimProductInfo | null currentStep: number progressText: string + expectedUid: string }) { return (
- kuaishou-lewan 客户领取 + 商品领取

{product?.title || '商品领取'}

订单号:{order?.platformOrderId || '-'}

+ {expectedUid ?

游戏 UID:{expectedUid}

: null}
@@ -651,6 +747,57 @@ function ClaimHeaderCard({ ) } +function ClaimUidStep({ + uidInput, + submitting, + onChange, + onSubmit, + product, +}: { + uidInput: string + submitting: boolean + onChange: (value: string) => void + onSubmit: () => void + product: ClaimProductInfo | null +}) { + return ( + + 第 1 步:填写游戏 UID +

+ 请填写你要领取的游戏角色 UID。提交后系统会按平台继续绑定或跳转领取。 +

+ +
+ + +
+ + + + + onChange(event.target.value)} + onPressEnter={onSubmit} + /> + + + + +
+ ) +} + function KuaishouCloudClaimSteps({ snapshot, qrCodeDataUrl, @@ -663,6 +810,10 @@ function KuaishouCloudClaimSteps({ onRebindRole, onConfirmRole, onConfirmRedeem, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, }: { snapshot: ClaimSnapshot qrCodeDataUrl: string @@ -675,36 +826,21 @@ function KuaishouCloudClaimSteps({ onRebindRole: () => void onConfirmRole: () => void onConfirmRedeem: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void }) { if (!snapshot.flow) { return null } - if (snapshot.currentStep === 1) { - return - } - - if (snapshot.currentStep === 2) { - return ( - - ) - } - if (snapshot.currentStep === 3) { return ( -} - -function ClaimDeprecatedTicketStep() { return ( - - 旧领取流程已停用 -

- 当前订单已改用行业电子凭证方案处理,不再支持快手小店核销码提交。请联系商家确认新的领取方式。 -

- - } - message="快手小店核销入口已下线" - description="电子凭证发码、查询和核销请以新的电子凭证流程为准。" - /> -
+ ) } @@ -746,6 +879,10 @@ function ClaimBindingStep({ onRefreshRole, onRebindRole, onConfirmRole, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, }: { snapshot: ClaimSnapshot qrCodeDataUrl: string @@ -756,6 +893,10 @@ function ClaimBindingStep({ onRefreshRole: () => void onRebindRole: () => void onConfirmRole: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void }) { const flow = snapshot.flow if (!flow) { @@ -764,6 +905,18 @@ function ClaimBindingStep({ return ( + 第 2 步:绑定角色 +

+ 请使用与 UID {snapshot.expectedUid || '-'} 对应的角色扫码绑定。 +

+ + + {snapshot.isBindingPrepared && qrCodeDataUrl ? (
@@ -786,13 +939,19 @@ function ClaimBindingStep({ /> )} -
+
+ +
- {flow.role.isDefaultRole ? ( - + {!snapshot.isUidMatched && snapshot.roleId ? ( + ) : null} @@ -818,7 +977,7 @@ function ClaimBindingStep({ disabled={Boolean(snapshot.confirmRoleDisabledReason)} onClick={onConfirmRole} > - 我已完成绑定,下一步 + UID 已匹配,下一步 @@ -827,11 +986,6 @@ function ClaimBindingStep({
最近刷新:{formatAdminDateTime(flow.role.refreshedAt)} 链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)} - {flow.binding.bindProbeMessage ? ( - - 链接检测:{flow.binding.bindProbeStatus || '-'} / {flow.binding.bindProbeMessage} - - ) : null}
) @@ -840,6 +994,7 @@ function ClaimBindingStep({ function ClaimConfirmStep({ roleName, roleId, + expectedUid, product, redeeming, rebindingRole, @@ -848,6 +1003,7 @@ function ClaimConfirmStep({ }: { roleName: string roleId: string + expectedUid: string product: ClaimProductInfo | null redeeming: boolean rebindingRole: boolean @@ -857,9 +1013,10 @@ function ClaimConfirmStep({ return ( 第 3 步:确认兑换信息 -

请再次确认角色和商品信息,确认无误后再继续兑换。

+

请再次确认 UID 和商品信息,确认无误后再继续兑换。

+ @@ -897,9 +1054,6 @@ function ClaimConfirmStep({ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { const flow = snapshot.flow - if (!flow) { - return null - } return ( @@ -916,12 +1070,12 @@ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
- + + -
@@ -941,45 +1095,128 @@ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { function FeifeiClaimPanel({ feifei, + expectedUid, order, product, taskLastError, onOpen, + uidInput, + submittingUid, + onUidChange, + onResubmitUid, }: { feifei: ClaimKuaishouFeifeiFlowInfo + expectedUid: string order: ClaimOrderInfo | null product: ClaimProductInfo | null taskLastError?: string onOpen: () => void + onEditUid?: () => void + uidInput: string + submittingUid: boolean + onUidChange: (value: string) => void + onResubmitUid: () => void }) { + const openReady = Boolean(feifei.h5UrlWithUid || feifei.h5.rechargeUrl || feifei.h5.entryUrl) + return (
kuaishou-feifei - {product?.title || '商品领取'} + 第 2 步:打开领取链接

{feifei.rechargeStatusLabel || '待领取'}

+ +
+ +
+ + - {taskLastError ? : null} + {taskLastError ? : null}
) } +function FeifeiResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { + return ( + +
+ {snapshot.resultVariant === 'warning' ? ( + + ) : ( + + )} +
+ {snapshot.resultTitle} +

{snapshot.resultDescription}

+
+
+ +
+ + + + +
+
+ ) +} + +function UidEditRow({ + uidInput, + submitting, + onChange, + onSubmit, +}: { + uidInput: string + submitting: boolean + onChange: (value: string) => void + onSubmit: () => void +}) { + return ( + + onChange(event.target.value)} + onPressEnter={onSubmit} + /> + + + ) +} + function ClaimProductItems({ product, compact = false, diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index fcaae3fe..869ecbc7 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -5,6 +5,10 @@ export function fetchClaimDetail(token: string) { return apiGet(`/api/v1/claim/${token}`) } +export function submitClaimUid(token: string, uid: string) { + return apiPost(`/api/v1/claim/${token}/uid`, { uid }) +} + export function confirmKuaishouCloudClaimRole(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) } diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index d1370129..4f732760 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -4,6 +4,13 @@ export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (stri export type ClaimTaskStatus = KnownTaskStatus | (string & {}) +export interface ClaimIdentityInfo { + expectedUid: string + submittedAt: string | null + source: string + ready: boolean +} + export interface ClaimTaskInfo { taskId: number taskNo: string @@ -150,6 +157,7 @@ export interface ClaimKuaishouFeifeiFlowInfo { entryUrl: string rechargeUrl: string } + h5UrlWithUid: string lastSyncedAt: string | null } @@ -157,6 +165,7 @@ export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {}) + claimIdentity: ClaimIdentityInfo | null task: ClaimTaskInfo order: ClaimOrderInfo orderItem: ClaimOrderItemInfo