统一领取流:Step1 填 UID,按平台分叉履约
领取页改为统一 UID 入口;lewan 强制角色 ID 匹配后才发货核销,feifei 将 uid 拼入 H5 并去掉 claim 短链,对外仍返回本站 claimUrl。
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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<string, any>
|
||||
|
||||
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<TaskRow> | 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<TaskRow> | 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<TaskRow> | 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<TaskRow> | 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]'
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Claim
|
||||
}
|
||||
}
|
||||
|
||||
// 已提交 UID 的 lewan 任务:详情查询时顺带刷新角色,便于前端轮询匹配状态
|
||||
if (
|
||||
executorKey === 'kuaishou_ct_assisted' &&
|
||||
!isKuaishouCloudMockTask(task)
|
||||
!isKuaishouCloudMockTask(task) &&
|
||||
getClaimIdentityFromTask(task).expectedUid
|
||||
) {
|
||||
task = (await syncKuaishouCloudRoleInfo(task)) || task
|
||||
}
|
||||
@@ -194,6 +202,81 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一领取 Step1:提交游戏 UID。
|
||||
*/
|
||||
export async function submitClaimUid(
|
||||
token: unknown,
|
||||
payload: { uid?: unknown } = {},
|
||||
): Promise<ClaimDetailPayload> {
|
||||
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<TaskRow> | 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',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<TaskRow | null> {
|
||||
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<FulfillmentDeliveryLink | null> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, any>
|
||||
@@ -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<TaskRow> | 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<typeof normalizeKuaishouFeifeiFlow>,
|
||||
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<typeof normalizeKuaishouFeifeiFlow>,
|
||||
) {
|
||||
@@ -335,6 +307,7 @@ function resolveKuaishouFeifeiDirectClaimUrl(
|
||||
return h5ClaimUrl
|
||||
}
|
||||
|
||||
// 兼容历史 shortLink 中的原始 target
|
||||
if (flow.shortLink?.targetUrl) {
|
||||
return flow.shortLink.targetUrl
|
||||
}
|
||||
|
||||
@@ -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<typeof createClaimSnapshot>
|
||||
|
||||
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<ClaimDetailData | null>(null)
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
|
||||
|
||||
const pollTimerRef = useRef<number>(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() {
|
||||
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
|
||||
<p>{errorMessage}</p>
|
||||
</Card>
|
||||
) : !detail ? (
|
||||
<Card className="claim-content-card claim-state-card">
|
||||
<Empty description="流程数据不完整,请联系客服处理" />
|
||||
</Card>
|
||||
) : snapshot.currentStep === 1 ? (
|
||||
<ClaimUidStep
|
||||
uidInput={uidInput}
|
||||
submitting={submittingUid}
|
||||
onChange={setUidInput}
|
||||
onSubmit={() => void handleSubmitUid()}
|
||||
product={snapshot.product}
|
||||
/>
|
||||
) : snapshot.hasRedeemResult ? (
|
||||
snapshot.isFeifeiFlow ? (
|
||||
<FeifeiResultStep snapshot={snapshot} />
|
||||
) : (
|
||||
<ClaimResultStep snapshot={snapshot} />
|
||||
)
|
||||
) : snapshot.isFeifeiFlow && snapshot.feifei ? (
|
||||
<FeifeiClaimPanel
|
||||
feifei={snapshot.feifei}
|
||||
expectedUid={snapshot.expectedUid}
|
||||
order={snapshot.order}
|
||||
product={snapshot.product}
|
||||
taskLastError={snapshot.task?.lastError}
|
||||
onOpen={() => 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 ? (
|
||||
<KuaishouCloudClaimSteps
|
||||
snapshot={snapshot}
|
||||
qrCodeDataUrl={qrCodeDataUrl}
|
||||
@@ -386,6 +453,10 @@ export default function ClaimPage() {
|
||||
onRebindRole={rebindRole}
|
||||
onConfirmRole={confirmRole}
|
||||
onConfirmRedeem={confirmRedeem}
|
||||
uidInput={uidInput}
|
||||
submittingUid={submittingUid}
|
||||
onUidChange={setUidInput}
|
||||
onResubmitUid={() => void handleSubmitUid()}
|
||||
/>
|
||||
) : (
|
||||
<Card className="claim-content-card claim-state-card">
|
||||
@@ -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 (
|
||||
<Card className="claim-header-card">
|
||||
<div className="claim-header-copy">
|
||||
<span className="eyebrow">kuaishou-lewan 客户领取</span>
|
||||
<span className="eyebrow">商品领取</span>
|
||||
<h1>{product?.title || '商品领取'}</h1>
|
||||
<p>订单号:{order?.platformOrderId || '-'}</p>
|
||||
{expectedUid ? <p>游戏 UID:{expectedUid}</p> : null}
|
||||
<ClaimProductItems product={product} compact />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Card className="claim-content-card">
|
||||
<Typography.Title level={2}>第 1 步:填写游戏 UID</Typography.Title>
|
||||
<p className="claim-muted">
|
||||
请填写你要领取的游戏角色 UID。提交后系统会按平台继续绑定或跳转领取。
|
||||
</p>
|
||||
|
||||
<div className="claim-info-grid">
|
||||
<InfoTile label="领取商品" value={product?.title || '-'} />
|
||||
<InfoTile label="商品数量" value={String(product?.quantity || 0)} />
|
||||
</div>
|
||||
|
||||
<ClaimProductItems product={product} />
|
||||
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="请输入游戏 UID"
|
||||
value={uidInput}
|
||||
maxLength={64}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onPressEnter={onSubmit}
|
||||
/>
|
||||
<Button type="primary" size="large" loading={submitting} onClick={onSubmit} block>
|
||||
确认 UID,下一步
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
message="UID 需与游戏内角色 ID 一致,填错将导致无法发货。"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 <ClaimDeprecatedTicketStep />
|
||||
}
|
||||
|
||||
if (snapshot.currentStep === 2) {
|
||||
return (
|
||||
<ClaimBindingStep
|
||||
snapshot={snapshot}
|
||||
qrCodeDataUrl={qrCodeDataUrl}
|
||||
refreshingRole={refreshingRole}
|
||||
confirmingRole={confirmingRole}
|
||||
rebindingRole={rebindingRole}
|
||||
onOpenBindUrl={onOpenBindUrl}
|
||||
onRefreshRole={onRefreshRole}
|
||||
onRebindRole={onRebindRole}
|
||||
onConfirmRole={onConfirmRole}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (snapshot.currentStep === 3) {
|
||||
return (
|
||||
<ClaimConfirmStep
|
||||
roleName={snapshot.roleName}
|
||||
roleId={snapshot.roleId}
|
||||
expectedUid={snapshot.expectedUid}
|
||||
product={snapshot.product}
|
||||
redeeming={redeeming}
|
||||
rebindingRole={rebindingRole}
|
||||
@@ -714,25 +850,22 @@ function KuaishouCloudClaimSteps({
|
||||
)
|
||||
}
|
||||
|
||||
return <ClaimResultStep snapshot={snapshot} />
|
||||
}
|
||||
|
||||
function ClaimDeprecatedTicketStep() {
|
||||
return (
|
||||
<Card className="claim-content-card">
|
||||
<Typography.Title level={2}>旧领取流程已停用</Typography.Title>
|
||||
<p className="claim-muted">
|
||||
当前订单已改用行业电子凭证方案处理,不再支持快手小店核销码提交。请联系商家确认新的领取方式。
|
||||
</p>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message="快手小店核销入口已下线"
|
||||
description="电子凭证发码、查询和核销请以新的电子凭证流程为准。"
|
||||
/>
|
||||
</Card>
|
||||
<ClaimBindingStep
|
||||
snapshot={snapshot}
|
||||
qrCodeDataUrl={qrCodeDataUrl}
|
||||
refreshingRole={refreshingRole}
|
||||
confirmingRole={confirmingRole}
|
||||
rebindingRole={rebindingRole}
|
||||
onOpenBindUrl={onOpenBindUrl}
|
||||
onRefreshRole={onRefreshRole}
|
||||
onRebindRole={onRebindRole}
|
||||
onConfirmRole={onConfirmRole}
|
||||
uidInput={uidInput}
|
||||
submittingUid={submittingUid}
|
||||
onUidChange={onUidChange}
|
||||
onResubmitUid={onResubmitUid}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card className="claim-content-card claim-binding-card">
|
||||
<Typography.Title level={2}>第 2 步:绑定角色</Typography.Title>
|
||||
<p className="claim-muted">
|
||||
请使用与 UID <strong>{snapshot.expectedUid || '-'}</strong> 对应的角色扫码绑定。
|
||||
</p>
|
||||
|
||||
<UidEditRow
|
||||
uidInput={uidInput}
|
||||
submitting={submittingUid}
|
||||
onChange={onUidChange}
|
||||
onSubmit={onResubmitUid}
|
||||
/>
|
||||
|
||||
{snapshot.isBindingPrepared && qrCodeDataUrl ? (
|
||||
<div className="claim-qr-block">
|
||||
<div className="claim-qr-guide-panel">
|
||||
@@ -786,13 +939,19 @@ function ClaimBindingStep({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={snapshot.isCustomerRoleReady ? 'claim-role-panel' : 'claim-role-panel pending'}>
|
||||
<div className={snapshot.isUidMatched ? 'claim-role-panel' : 'claim-role-panel pending'}>
|
||||
<InfoRow label="填写 UID" value={snapshot.expectedUid || '-'} />
|
||||
<InfoRow label="当前角色" value={snapshot.roleName || '待识别'} />
|
||||
<InfoRow label="角色 ID" value={snapshot.roleId || '-'} />
|
||||
<InfoRow label="匹配状态" value={snapshot.isUidMatched ? '已匹配' : '未匹配'} />
|
||||
</div>
|
||||
|
||||
{flow.role.isDefaultRole ? (
|
||||
<Alert type="warning" showIcon message="当前仍是虚拟机默认角色,请重新绑定自己的角色信息。" />
|
||||
{!snapshot.isUidMatched && snapshot.roleId ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`当前绑定角色 ID(${snapshot.roleId})与填写 UID(${snapshot.expectedUid})不一致,请换绑正确角色。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Space size={12} wrap className="claim-action-row">
|
||||
@@ -818,7 +977,7 @@ function ClaimBindingStep({
|
||||
disabled={Boolean(snapshot.confirmRoleDisabledReason)}
|
||||
onClick={onConfirmRole}
|
||||
>
|
||||
我已完成绑定,下一步
|
||||
UID 已匹配,下一步
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -827,11 +986,6 @@ function ClaimBindingStep({
|
||||
<div className="claim-meta-footer">
|
||||
<span>最近刷新:{formatAdminDateTime(flow.role.refreshedAt)}</span>
|
||||
<span>链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)}</span>
|
||||
{flow.binding.bindProbeMessage ? (
|
||||
<span>
|
||||
链接检测:{flow.binding.bindProbeStatus || '-'} / {flow.binding.bindProbeMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
@@ -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 (
|
||||
<Card className="claim-content-card">
|
||||
<Typography.Title level={2}>第 3 步:确认兑换信息</Typography.Title>
|
||||
<p className="claim-muted">请再次确认角色和商品信息,确认无误后再继续兑换。</p>
|
||||
<p className="claim-muted">请再次确认 UID 和商品信息,确认无误后再继续兑换。</p>
|
||||
|
||||
<div className="claim-info-grid">
|
||||
<InfoTile label="填写 UID" value={expectedUid || '-'} />
|
||||
<InfoTile label="角色名称" value={roleName || '-'} />
|
||||
<InfoTile label="角色 ID" value={roleId || '-'} />
|
||||
<InfoTile label="领取商品" value={product?.title || '-'} />
|
||||
@@ -897,9 +1054,6 @@ function ClaimConfirmStep({
|
||||
|
||||
function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
|
||||
const flow = snapshot.flow
|
||||
if (!flow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="claim-content-card">
|
||||
@@ -916,12 +1070,12 @@ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
|
||||
</div>
|
||||
|
||||
<div className="claim-info-grid">
|
||||
<InfoTile label="结果时间" value={formatAdminDateTime(flow.dispatch.dispatchAt)} />
|
||||
<InfoTile label="填写 UID" value={snapshot.expectedUid || '-'} />
|
||||
<InfoTile label="结果时间" value={formatAdminDateTime(flow?.dispatch.dispatchAt || null)} />
|
||||
<InfoTile label="领取商品" value={snapshot.product?.title || '-'} />
|
||||
<InfoTile label="角色名称" value={snapshot.roleName || '-'} />
|
||||
<InfoTile label="角色 ID" value={snapshot.roleId || '-'} />
|
||||
<InfoTile label="订单号" value={snapshot.order?.platformOrderId || '-'} />
|
||||
<InfoTile label="购买数量" value={String(snapshot.product?.quantity || 0)} />
|
||||
</div>
|
||||
|
||||
<ClaimProductItems product={snapshot.product} />
|
||||
@@ -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 (
|
||||
<Card className="claim-content-card">
|
||||
<div className="claim-feifei-main">
|
||||
<span className="claim-feifei-label">kuaishou-feifei</span>
|
||||
<Typography.Title level={2}>{product?.title || '商品领取'}</Typography.Title>
|
||||
<Typography.Title level={2}>第 2 步:打开领取链接</Typography.Title>
|
||||
<p>{feifei.rechargeStatusLabel || '待领取'}</p>
|
||||
</div>
|
||||
|
||||
<UidEditRow
|
||||
uidInput={uidInput}
|
||||
submitting={submittingUid}
|
||||
onChange={onUidChange}
|
||||
onSubmit={onResubmitUid}
|
||||
/>
|
||||
|
||||
<div className="claim-info-grid">
|
||||
<InfoTile label="填写 UID" value={expectedUid || '-'} />
|
||||
<InfoTile label="订单号" value={order?.platformOrderId || '-'} />
|
||||
<InfoTile label="平台单号" value={feifei.orderNo || feifei.platformOrderNo || '-'} />
|
||||
<InfoTile label="领取商品" value={product?.title || '-'} />
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="将携带你填写的 UID 打开领取页,请确认游戏内角色一致。"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<LinkOutlined />}
|
||||
disabled={!feifei.h5.rechargeUrl && !feifei.h5.entryUrl}
|
||||
disabled={!openReady || !expectedUid}
|
||||
onClick={onOpen}
|
||||
block
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
打开领取链接
|
||||
</Button>
|
||||
|
||||
{taskLastError ? <Alert type="warning" showIcon message={taskLastError} /> : null}
|
||||
{taskLastError ? <Alert type="warning" showIcon message={taskLastError} style={{ marginTop: 12 }} /> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FeifeiResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
|
||||
return (
|
||||
<Card className="claim-content-card">
|
||||
<div className="claim-result-header">
|
||||
{snapshot.resultVariant === 'warning' ? (
|
||||
<ExclamationCircleOutlined className="claim-large-icon claim-icon-warning" />
|
||||
) : (
|
||||
<CheckCircleOutlined className={`claim-large-icon claim-icon-${snapshot.resultVariant}`} />
|
||||
)}
|
||||
<div>
|
||||
<Typography.Title level={2}>{snapshot.resultTitle}</Typography.Title>
|
||||
<p className="claim-muted">{snapshot.resultDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="claim-info-grid">
|
||||
<InfoTile label="填写 UID" value={snapshot.expectedUid || '-'} />
|
||||
<InfoTile label="状态" value={snapshot.feifei?.rechargeStatusLabel || '-'} />
|
||||
<InfoTile label="订单号" value={snapshot.order?.platformOrderId || '-'} />
|
||||
<InfoTile label="领取商品" value={snapshot.product?.title || '-'} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function UidEditRow({
|
||||
uidInput,
|
||||
submitting,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
uidInput: string
|
||||
submitting: boolean
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
size="large"
|
||||
value={uidInput}
|
||||
maxLength={64}
|
||||
placeholder="游戏 UID"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onPressEnter={onSubmit}
|
||||
/>
|
||||
<Button size="large" loading={submitting} onClick={onSubmit}>
|
||||
更新 UID
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
)
|
||||
}
|
||||
|
||||
function ClaimProductItems({
|
||||
product,
|
||||
compact = false,
|
||||
|
||||
@@ -5,6 +5,10 @@ export function fetchClaimDetail(token: string) {
|
||||
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}`)
|
||||
}
|
||||
|
||||
export function submitClaimUid(token: string, uid: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/uid`, { uid })
|
||||
}
|
||||
|
||||
export function confirmKuaishouCloudClaimRole(token: string) {
|
||||
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user