统一领取流:Step1 填 UID,按平台分叉履约

领取页改为统一 UID 入口;lewan 强制角色 ID 匹配后才发货核销,feifei 将 uid 拼入 H5 并去掉 claim 短链,对外仍返回本站 claimUrl。
This commit is contained in:
yml2213
2026-07-10 13:30:22 +08:00
parent 1155b3c608
commit bb060e81a4
15 changed files with 898 additions and 285 deletions
+11 -4
View File
@@ -83,11 +83,18 @@ function assertMigrationFilesOrdered() {
const versions = files.map((name) => Number(name.slice(0, 3))) const versions = files.map((name) => Number(name.slice(0, 3)))
for (let index = 1; index < versions.length; index += 1) { for (let index = 1; index < versions.length; index += 1) {
if (versions[index] < versions[index - 1]) { const current = versions[index]
throw new Error(`迁移序号乱序: ${files[index - 1]} 之后出现 ${files[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]) { if (current < previous) {
throw new Error(`迁移序号重复: ${files[index - 1]} ${files[index]}`) throw new Error(`迁移序号乱序: ${previousFile} 之后出现 ${currentFile}`)
}
if (current === previous) {
throw new Error(`迁移序号重复: ${previousFile}${currentFile}`)
} }
} }
+11
View File
@@ -6,6 +6,7 @@ import {
getKuaishouCloudClaimDetail, getKuaishouCloudClaimDetail,
rebindKuaishouCloudClaimRole, rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim, redeemKuaishouCloudClaim,
submitClaimUid,
} from '../services/claim/kuaishou-cloud-claim-service.js' } from '../services/claim/kuaishou-cloud-claim-service.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.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( router.post(
'/:token/kuaishou-cloud/confirm-role', '/:token/kuaishou-cloud/confirm-role',
claimWriteRateLimit, claimWriteRateLimit,
@@ -9,7 +9,7 @@ import {
listKuaishouIndustryVouchersByTaskId, listKuaishouIndustryVouchersByTaskId,
} from '../../repositories/kuaishou-industry-voucher-repo.js' } from '../../repositories/kuaishou-industry-voucher-repo.js'
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.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 { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { import {
@@ -242,10 +242,8 @@ export async function getAdminTaskDetail(
const taskContext = parseTaskContext(task) const taskContext = parseTaskContext(task)
const taskState = parseTaskState(task) const taskState = parseTaskState(task)
const viewerContext = createAdminViewerContext(session) const viewerContext = createAdminViewerContext(session)
const directClaimUrl = String(task.executor_key || '').trim() === 'kuaishou_feifei' // 统一对外领取入口为本站 claim 链接;平台 H5 仅作内部上下文
? resolveKuaishouFeifeiClaimUrl(taskContext.kuaishouFeifei) const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
: ''
const claimUrl = directClaimUrl || (claimToken ? buildClaimUrl(claimToken.token) : '')
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
const cloudSourceLabelMap = buildCloudSourceLabelMap() const cloudSourceLabelMap = buildCloudSourceLabelMap()
const kuaishouCloudFulfillment = const kuaishouCloudFulfillment =
@@ -300,12 +298,12 @@ export async function getAdminTaskDetail(
quantity: orderItem.quantity, quantity: orderItem.quantity,
} }
: null, : null,
claimToken: claimToken || directClaimUrl claimToken: claimToken
? { ? {
primaryClaimTokenId: claimToken?.id || 0, primaryClaimTokenId: claimToken.id,
token: viewerContext.canViewSensitiveTaskData ? (claimToken?.token || '') : '', token: viewerContext.canViewSensitiveTaskData ? claimToken.token || '' : '',
status: claimToken?.status || 'external', status: claimToken.status || '',
expiredAt: claimToken?.expired_at || '', expiredAt: claimToken.expired_at || '',
claimUrl, claimUrl,
} }
: null, : 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 { nowIso } from '../../utils/time.js'
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.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' import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED]) 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 }) return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
} }
const taskContext = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(taskContext.claimIdentity)
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task) const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
const displaySkuName = resolveClaimOrderItemDisplaySkuName( const displaySkuName = resolveClaimOrderItemDisplaySkuName(
@@ -102,6 +106,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
tokenStatus: claimToken.status, tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token), claimUrl: buildClaimUrl(claimToken.token),
flowType: 'kuaishou_cloud', flowType: 'kuaishou_cloud',
claimIdentity,
task: { task: {
taskId: task.id, taskId: task.id,
taskNo: task.task_no, taskNo: task.task_no,
@@ -149,7 +154,9 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task) 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 = { const product = {
title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
skuCode: String(orderItem.sku_code || '').trim(), skuCode: String(orderItem.sku_code || '').trim(),
@@ -166,6 +173,7 @@ function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderI
tokenStatus: claimToken.status, tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token), claimUrl: buildClaimUrl(claimToken.token),
flowType: 'kuaishou_feifei', flowType: 'kuaishou_feifei',
claimIdentity,
task: { task: {
taskId: task.id, taskId: task.id,
taskNo: task.task_no, 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 source = isPlainObject(value) ? value : {}
const h5 = isPlainObject(source.h5) ? source.h5 : {} 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 { return {
flowType: 'kuaishou_feifei', flowType: 'kuaishou_feifei',
@@ -227,9 +238,10 @@ function mapClaimKuaishouFeifeiFulfillment(value: unknown) {
claimUrl: String(source.claimUrl || '').trim(), claimUrl: String(source.claimUrl || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(), consumeStatus: String(source.consumeStatus || 'pending').trim(),
h5: { h5: {
entryUrl: String(h5.entryUrl || '').trim(), entryUrl,
rechargeUrl: String(h5.rechargeUrl || '').trim(), rechargeUrl,
}, },
h5UrlWithUid: expectedUid ? h5UrlWithUid : '',
lastSyncedAt: source.lastSyncedAt || null, lastSyncedAt: source.lastSyncedAt || null,
} }
} }
@@ -1,6 +1,7 @@
import { createTaskEvent } from '../../repositories/task-event-repo.js' import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js' import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { addHours, nowIso } from '../../utils/time.js' import { addHours, nowIso } from '../../utils/time.js'
import { import {
TASK_STATUS, TASK_STATUS,
@@ -11,14 +12,19 @@ import {
} from '../../domain/task-status.js' } from '../../domain/task-status.js'
import { import {
dispatchKuaishouCloudFulfillmentTask, dispatchKuaishouCloudFulfillmentTask,
hasKuaishouCloudCustomerRole,
hasKuaishouCloudDefaultRoleSnapshot,
normalizeKuaishouCloudFlow, normalizeKuaishouCloudFlow,
prepareKuaishouCloudFulfillmentTask, prepareKuaishouCloudFulfillmentTask,
rebindKuaishouCloudTaskRole, rebindKuaishouCloudTaskRole,
refreshKuaishouCloudTaskRoleInfo, refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud/index.js' } from '../fulfillment/kuaishou-cloud/index.js'
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/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 { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
import type { TaskRow } from '../../types/repository/rows.js' import type { TaskRow } from '../../types/repository/rows.js'
@@ -179,9 +185,11 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
} }
} }
// 已提交 UID 的 lewan 任务:详情查询时顺带刷新角色,便于前端轮询匹配状态
if ( if (
executorKey === 'kuaishou_ct_assisted' && executorKey === 'kuaishou_ct_assisted' &&
!isKuaishouCloudMockTask(task) !isKuaishouCloudMockTask(task) &&
getClaimIdentityFromTask(task).expectedUid
) { ) {
task = (await syncKuaishouCloudRoleInfo(task)) || task 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) { export async function rebindKuaishouCloudClaimRole(token: unknown) {
const context = await getClaimContext(token) const context = await getClaimContext(token)
@@ -213,20 +296,7 @@ export async function rebindKuaishouCloudClaimRole(token: unknown) {
} }
function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject { function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject {
const rawValue = task?.context_json return parseTaskContextValue(task)
if (!rawValue) {
return {}
}
if (typeof rawValue === 'object') {
return rawValue
}
try {
return JSON.parse(String(rawValue || '{}'))
} catch {
return {}
}
} }
function hasUsableIndustryVoucher(context: JsonObject = {}) { function hasUsableIndustryVoucher(context: JsonObject = {}) {
@@ -273,6 +343,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
return getKuaishouCloudClaimDetail(token) return getKuaishouCloudClaimDetail(token)
} }
const expectedUid = assertClaimExpectedUidReady(context.task)
const contextSource = parseTaskContext(context.task) const contextSource = parseTaskContext(context.task)
const mockMode = isKuaishouCloudMockContext(contextSource) const mockMode = isKuaishouCloudMockContext(contextSource)
const refreshed = mockMode const refreshed = mockMode
@@ -285,13 +356,6 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
}) })
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment) 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) { if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) {
throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', { throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', {
statusCode: 409, statusCode: 409,
@@ -299,16 +363,9 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
}) })
} }
const hasDefaultRoleSnapshot = hasKuaishouCloudDefaultRoleSnapshot(flow) if (!mockMode) {
if (!mockMode && !hasKuaishouCloudCustomerRole(flow)) { assertBoundUidMatchesExpected(refreshed.task, flow, {
const message = hasDefaultRoleSnapshot errorCodePrefix: 'claim_kuaishou_cloud',
? '当前仍是虚拟机默认角色,请先重新绑定自己的角色信息'
: '系统还未获取到虚拟机默认角色信息,请稍后刷新角色信息后再试'
throw createHttpError(message, {
statusCode: 409,
errorCode: hasDefaultRoleSnapshot
? 'claim_kuaishou_cloud_default_role_not_rebound'
: 'claim_kuaishou_cloud_default_role_not_ready',
}) })
} }
@@ -326,6 +383,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
context.task.id, context.task.id,
'kuaishou_cloud_role_confirmed', 'kuaishou_cloud_role_confirmed',
{ {
expectedUid,
vnPhone: flow.binding.vnPhone, vnPhone: flow.binding.vnPhone,
roleName: flow.binding.roleName, roleName: flow.binding.roleName,
roleId: flow.binding.roleId, 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, { const lockedTask = await updateTaskStatusIfCurrent(context.task.id, TASK_STATUS.ROLE_CONFIRMED, {
task_status: TASK_STATUS.REDEEMING, task_status: TASK_STATUS.REDEEMING,
user_action_status: 'not_required', 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({ const result = await resolveTaskDeliveryLink(createTask({
executor_key: 'kuaishou_feifei', executor_key: 'kuaishou_feifei',
primary_claim_token: 'feifei-token',
primary_claim_expires_at: '2026-07-09T12:00:00.000Z',
context_json: { context_json: {
kuaishouFeifei: { kuaishouFeifei: {
shortLink: {
code: 'AbCd1234',
url: 'https://ks.khhao.com/s/AbCd1234',
targetUrl: 'https://feifei.example.com/h5/bind?code=very-long',
},
h5: { h5: {
rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long',
}, },
@@ -49,8 +46,8 @@ test('resolveTaskDeliveryLink 为 feifei 任务返回已保存短链', async ()
})) }))
assert.deepEqual(result, { assert.deepEqual(result, {
claimUrl: 'https://ks.khhao.com/s/AbCd1234', claimUrl: buildClaimUrl('feifei-token'),
expireTime: '', 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 { TASK_STATUS } from '../../../domain/task-status.js'
import { import { prepareKuaishouFeifeiTask } from '../kuaishou-feifei/index.js'
ensureKuaishouFeifeiClaimShortLink,
prepareKuaishouFeifeiTask,
resolveKuaishouFeifeiClaimUrl,
} from '../kuaishou-feifei/index.js'
import { parseTaskContext } from '../../../utils/task-json.js'
import { import {
FULFILLMENT_EXECUTOR_KEYS, FULFILLMENT_EXECUTOR_KEYS,
type FulfillmentDeliveryLink, type FulfillmentDeliveryLink,
@@ -24,7 +20,19 @@ async function preparePaidTask(
deps: FulfillmentPrepareDeps, deps: FulfillmentPrepareDeps,
): Promise<TaskRow | null> { ): Promise<TaskRow | null> {
try { 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) { } catch (error) {
const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败'
const updatedTask = await deps.updateTask(task.id, { 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> { async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLink | null> {
let claimUrl = await ensureKuaishouFeifeiClaimShortLink(task) const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim()
if (!claimUrl) { const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim()
const context = parseTaskContext(task)
claimUrl = resolveKuaishouFeifeiClaimUrl(context.kuaishouFeifei)
}
if (!claimUrl) { if (!primaryToken) {
return null return null
} }
return { return {
claimUrl, claimUrl: buildClaimUrl(primaryToken),
expireTime: '', expireTime,
} }
} }
@@ -3,6 +3,11 @@ import { updateTask } from "../../../repositories/task-repo.js";
import type { TaskRow } from "../../../types/repository/rows.js"; import type { TaskRow } from "../../../types/repository/rows.js";
import { createHttpError } from "../../../utils/http.js"; import { createHttpError } from "../../../utils/http.js";
import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js";
import {
getClaimIdentityFromContext,
isClaimUidMatched,
normalizeClaimUid,
} from "../../claim/claim-identity.js";
import { import {
normalizeKuaishouCloudFlow, normalizeKuaishouCloudFlow,
normalizeKuaishouCloudRoleInfo, normalizeKuaishouCloudRoleInfo,
@@ -31,6 +36,7 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
const flow = normalizeKuaishouCloudFlow( const flow = normalizeKuaishouCloudFlow(
input.flow || taskContext.kuaishouCloudFulfillment input.flow || taskContext.kuaishouCloudFulfillment
); );
const expectedUid = getClaimIdentityFromContext(taskContext).expectedUid;
if (!flow.binding.vnId || !flow.binding.vnKey) { if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", { throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", {
@@ -62,40 +68,70 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
const confirmedRoleId = String( const confirmedRoleId = String(
flow.binding.roleId || flow.role.rid || task.role_id || "" flow.binding.roleId || flow.role.rid || task.role_id || ""
).trim(); ).trim();
const roleMatches = isSameKuaishouCloudRole( const liveBoundUid = normalizeClaimUid(roleInfo.rid);
{
name: confirmedRoleName, // 优先:声明 UID 与云端当前绑定角色一致
rid: confirmedRoleId, if (expectedUid) {
}, if (!isClaimUidMatched(expectedUid, liveBoundUid)) {
{ await createTaskEvent(
name: roleInfo.name, task.id,
rid: roleInfo.rid, "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`,
}
);
} }
); } else {
// 兼容旧任务:无 expectedUid 时仍比对「确认角色」
if (!roleMatches) { const roleMatches = isSameKuaishouCloudRole(
await createTaskEvent(
task.id,
"kuaishou_cloud_dispatch_role_mismatch",
{ {
source, name: confirmedRoleName,
vnId: flow.binding.vnId, rid: confirmedRoleId,
confirmedRoleName,
confirmedRoleId,
cloudtentaclesRoleName: roleInfo.name,
cloudtentaclesRoleId: roleInfo.rid,
actor: input.actor || null,
}, },
now
);
throw createHttpError(
`cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`,
{ {
statusCode: 409, name: roleInfo.name,
errorCode: `${errorCodePrefix}_dispatch_role_mismatch`, 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({ const nextFlow = normalizeKuaishouCloudFlow({
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict'
import { import {
buildFeifeiPlatformOrderNo, buildFeifeiPlatformOrderNo,
resolveKuaishouFeifeiClaimUrl, resolveKuaishouFeifeiClaimUrl,
resolveKuaishouFeifeiH5UrlWithUid,
resolveKuaishouFeifeiPlatformAmount, resolveKuaishouFeifeiPlatformAmount,
} from './index.js' } from './index.js'
import type { TaskRow } from '../../../types/repository/rows.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( assert.equal(
resolveKuaishouFeifeiClaimUrl({ resolveKuaishouFeifeiClaimUrl({
shortLink: { shortLink: {
@@ -43,10 +44,24 @@ test('resolveKuaishouFeifeiClaimUrl prefers internal short link', () => {
rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', 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', () => { test('buildFeifeiPlatformOrderNo prefers source order number', () => {
assert.equal( assert.equal(
buildFeifeiPlatformOrderNo(createTask({ buildFeifeiPlatformOrderNo(createTask({
@@ -7,17 +7,14 @@ import { logIntegration } from '../../../utils/logger.js'
import { parseTaskContext } from '../../../utils/task-json.js' import { parseTaskContext } from '../../../utils/task-json.js'
import { nowIso } from '../../../utils/time.js' import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js' import { TASK_STATUS } from '../../../domain/task-status.js'
import { appendUidToUrl, getClaimIdentityFromContext } from '../../claim/claim-identity.js'
import { import {
createKuaishouFeifeiOrder, createKuaishouFeifeiOrder,
queryKuaishouFeifeiOrder, queryKuaishouFeifeiOrder,
} from '../../platforms/kuaishou-feifei/order-service.js' } from '../../platforms/kuaishou-feifei/order-service.js'
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js' import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js' import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js'
import { import { normalizeShortLinkPayload } from '../../short-links/short-link-service.js'
SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM,
ensureShortLinkForTarget,
normalizeShortLinkPayload,
} from '../../short-links/short-link-service.js'
import type { TaskRow } from '../../../types/repository/rows.js' import type { TaskRow } from '../../../types/repository/rows.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
@@ -40,15 +37,13 @@ export async function prepareKuaishouFeifeiTask(task: TaskRow) {
const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow) const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow)
if (flow.orderNo && existingClaimUrl) { if (flow.orderNo && existingClaimUrl) {
const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task)
const nextContext = {
...taskContext,
kuaishouFeifei: nextFlow,
}
return updateTask(task.id, { return updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED, task_status: TASK_STATUS.LINK_GENERATED,
user_action_status: 'pending_claim', user_action_status: 'pending_claim',
context_json: JSON.stringify(nextContext), context_json: JSON.stringify({
...taskContext,
kuaishouFeifei: flow,
}),
updated_at: now, 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, platformOrderNo,
claimUrl: feifeiClaimUrl, claimUrl: feifeiClaimUrl,
syncedAt: now, syncedAt: now,
}), task) })
const updatedTask = await updateTask(task.id, { const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED, task_status: TASK_STATUS.LINK_GENERATED,
user_action_status: 'pending_claim', user_action_status: 'pending_claim',
@@ -137,10 +132,10 @@ export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
let resultCode = task.result_code let resultCode = task.result_code
let resultMessage = task.result_message let resultMessage = task.result_message
let lastError = task.last_error let lastError = task.last_error
const nextFlow = await ensureKuaishouFeifeiFlowShortLink(mergeKuaishouFeifeiOrder(flow, order, { const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
syncedAt: now, syncedAt: now,
claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow), claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow),
}), task) })
let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
if (order.rechargeStatus === 30) { if (order.rechargeStatus === 30) {
@@ -230,6 +225,7 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) {
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
claimUrl: String(source.claimUrl || '').trim(), claimUrl: String(source.claimUrl || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(), consumeStatus: String(source.consumeStatus || 'pending').trim(),
// 历史字段保留读取,新流程不再生成 shortLink
shortLink: normalizeShortLinkPayload(source.shortLink), shortLink: normalizeShortLinkPayload(source.shortLink),
h5: { h5: {
entryUrl: String(h5.entryUrl || '').trim(), entryUrl: String(h5.entryUrl || '').trim(),
@@ -240,38 +236,38 @@ export function normalizeKuaishouFeifeiFlow(value: unknown) {
} }
} }
/** 平台原始 H5 链接(不拼 uid、不用短链)。 */
export function resolveKuaishouFeifeiClaimUrl(value: unknown) { export function resolveKuaishouFeifeiClaimUrl(value: unknown) {
const flow = normalizeKuaishouFeifeiFlow(value) const flow = normalizeKuaishouFeifeiFlow(value)
if (flow.shortLink?.url) {
return flow.shortLink.url
}
return resolveKuaishouFeifeiDirectClaimUrl(flow) return resolveKuaishouFeifeiDirectClaimUrl(flow)
} }
export async function ensureKuaishouFeifeiClaimShortLink(task: TaskRow) { /** 在已有 expectedUid 时返回拼好 uid 的 H5 链接。 */
if (!isKuaishouFeifeiTask(task)) { export function resolveKuaishouFeifeiH5UrlWithUid(
value: unknown,
expectedUid?: unknown,
) {
const directUrl = resolveKuaishouFeifeiClaimUrl(value)
if (!directUrl) {
return '' return ''
} }
const taskContext = parseTaskContext(task) const uid = String(expectedUid || '').trim()
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei) if (!uid) {
const nextFlow = await ensureKuaishouFeifeiFlowShortLink(flow, task) return directUrl
const claimUrl = resolveKuaishouFeifeiClaimUrl(nextFlow)
if (JSON.stringify(flow.shortLink || null) === JSON.stringify(nextFlow.shortLink || null)) {
return claimUrl
} }
await updateTask(task.id, { return appendUidToUrl(directUrl, uid)
context_json: JSON.stringify({ }
...taskContext,
kuaishouFeifei: nextFlow,
}),
updated_at: nowIso(),
})
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( 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( function resolveKuaishouFeifeiDirectClaimUrl(
flow: ReturnType<typeof normalizeKuaishouFeifeiFlow>, flow: ReturnType<typeof normalizeKuaishouFeifeiFlow>,
) { ) {
@@ -335,6 +307,7 @@ function resolveKuaishouFeifeiDirectClaimUrl(
return h5ClaimUrl return h5ClaimUrl
} }
// 兼容历史 shortLink 中的原始 target
if (flow.shortLink?.targetUrl) { if (flow.shortLink?.targetUrl) {
return flow.shortLink.targetUrl return flow.shortLink.targetUrl
} }
+357 -120
View File
@@ -11,6 +11,7 @@ import {
Button, Button,
Card, Card,
Empty, Empty,
Input,
Space, Space,
Spin, Spin,
Tooltip, Tooltip,
@@ -34,6 +35,7 @@ import {
fetchClaimDetail, fetchClaimDetail,
rebindKuaishouCloudClaimRole, rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim, redeemKuaishouCloudClaim,
submitClaimUid,
} from '@/services/claim' } from '@/services/claim'
import type { import type {
ClaimDetailData, ClaimDetailData,
@@ -50,9 +52,9 @@ const ROLE_SLOW_POLL_MS = 30_000
const ROLE_IDLE_POLL_MS = 60_000 const ROLE_IDLE_POLL_MS = 60_000
const ROLE_FAST_WINDOW_MS = 3 * 60_000 const ROLE_FAST_WINDOW_MS = 3 * 60_000
const ROLE_IDLE_WINDOW_MS = 10 * 60_000 const ROLE_IDLE_WINDOW_MS = 10 * 60_000
const FEIFEI_POLL_MS = 15_000
type ResultVariant = 'success' | 'warning' | 'info' type ResultVariant = 'success' | 'warning' | 'info'
type ClaimSnapshot = ReturnType<typeof createClaimSnapshot> type ClaimSnapshot = ReturnType<typeof createClaimSnapshot>
export default function ClaimPage() { export default function ClaimPage() {
@@ -60,6 +62,8 @@ export default function ClaimPage() {
const token = String(routeToken || '').trim() const token = String(routeToken || '').trim()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submittingUid, setSubmittingUid] = useState(false)
const [uidInput, setUidInput] = useState('')
const [refreshingRole, setRefreshingRole] = useState(false) const [refreshingRole, setRefreshingRole] = useState(false)
const [confirmingRole, setConfirmingRole] = useState(false) const [confirmingRole, setConfirmingRole] = useState(false)
const [rebindingRole, setRebindingRole] = useState(false) const [rebindingRole, setRebindingRole] = useState(false)
@@ -68,7 +72,7 @@ export default function ClaimPage() {
const [detail, setDetail] = useState<ClaimDetailData | null>(null) const [detail, setDetail] = useState<ClaimDetailData | null>(null)
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
const pollTimerRef = useRef<number>(0) const pollTimerRef = useRef(0)
const rolePollBaselineAtRef = useRef(0) const rolePollBaselineAtRef = useRef(0)
const rolePollBaselineKeyRef = useRef('') const rolePollBaselineKeyRef = useRef('')
@@ -109,6 +113,10 @@ export default function ClaimPage() {
const applyDetail = useCallback( const applyDetail = useCallback(
async (nextDetail: ClaimDetailData) => { async (nextDetail: ClaimDetailData) => {
setDetail(nextDetail) setDetail(nextDetail)
const expectedUid = String(nextDetail.claimIdentity?.expectedUid || '').trim()
if (expectedUid) {
setUidInput(expectedUid)
}
await generateQRCode( await generateQRCode(
String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(), String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(),
) )
@@ -174,19 +182,22 @@ export default function ClaimPage() {
const resolveNextPollDelay = useCallback( const resolveNextPollDelay = useCallback(
(nextSnapshot: ClaimSnapshot) => { (nextSnapshot: ClaimSnapshot) => {
if ( if (nextSnapshot.hasRedeemResult || isClaimInactiveTaskStatus(nextSnapshot.task?.status)) {
!nextSnapshot.flow ||
nextSnapshot.hasRedeemResult ||
isClaimInactiveTaskStatus(nextSnapshot.task?.status)
) {
return 0 return 0
} }
if (!nextSnapshot.hasExpectedUid) {
return 0
}
if (nextSnapshot.isFeifeiFlow) {
return FEIFEI_POLL_MS
}
if (nextSnapshot.currentStep === 2) { if (nextSnapshot.currentStep === 2) {
if (!nextSnapshot.isBindingPrepared) { if (!nextSnapshot.isBindingPrepared) {
return BINDING_PREPARE_POLL_MS return BINDING_PREPARE_POLL_MS
} }
return resolveRolePollDelay(nextSnapshot) return resolveRolePollDelay(nextSnapshot)
} }
@@ -200,6 +211,7 @@ export default function ClaimPage() {
rolePollBaselineKeyRef.current = '' rolePollBaselineKeyRef.current = ''
setDetail(null) setDetail(null)
setQrCodeDataUrl('') setQrCodeDataUrl('')
setUidInput('')
void loadDetail() void loadDetail()
return () => { return () => {
@@ -224,20 +236,40 @@ export default function ClaimPage() {
} }
}, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) }, [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() { async function refreshRole() {
setRefreshingRole(true) setRefreshingRole(true)
try { try {
const nextDetail = await loadDetail({ silent: true }) const nextDetail = await loadDetail({ silent: true })
const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole })
if (nextSnapshot.isCustomerRoleReady) { if (nextSnapshot.isUidMatched) {
showSuccess('角色信息已刷新') showSuccess('角色信息已匹配,可确认下一步')
} else if (nextSnapshot.isDefaultRole) { } else if (nextSnapshot.roleId) {
showError('当前仍是虚拟机默认角色,请重新绑定自己的角色信息') showError(
`当前绑定角色 ID${nextSnapshot.roleId})与填写 UID${nextSnapshot.expectedUid || '-'})不一致`,
)
} else if (nextDetail) { } else if (nextDetail) {
showError( showError(
nextSnapshot.flow?.role.errorMessage || nextSnapshot.flow?.role.errorMessage ||
nextSnapshot.flow?.role.defaultErrorMessage ||
'暂时还没有识别到角色信息,请完成绑定后稍等片刻再试', '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试',
) )
} }
@@ -261,7 +293,7 @@ export default function ClaimPage() {
async function confirmRedeem() { async function confirmRedeem() {
try { try {
await showConfirm('兑换后不可取消,也不可退货。请确认角色和商品信息完全正确。', '确认兑换', { await showConfirm('兑换后不可取消,也不可退货。请确认 UID 与商品信息完全正确。', '确认兑换', {
okText: '确认兑换', okText: '确认兑换',
okButtonProps: { danger: true }, okButtonProps: { danger: true },
}) })
@@ -287,7 +319,7 @@ export default function ClaimPage() {
async function rebindRole() { async function rebindRole() {
try { try {
await showConfirm( await showConfirm(
'换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。领取商品不会改变,但需要重新扫码绑定角色。', '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。请使用你填写的 UID 对应角色重新绑定。',
'确认换绑角色', '确认换绑角色',
{ {
okText: '确认换绑', okText: '确认换绑',
@@ -330,9 +362,17 @@ export default function ClaimPage() {
function openFeifeiUrl(useCurrentPage = false) { function openFeifeiUrl(useCurrentPage = false) {
const url = String( const url = String(
snapshot.feifei?.h5.rechargeUrl || snapshot.feifei?.h5.entryUrl || '', snapshot.feifei?.h5UrlWithUid ||
snapshot.feifei?.h5.rechargeUrl ||
snapshot.feifei?.h5.entryUrl ||
'',
).trim() ).trim()
if (!snapshot.hasExpectedUid) {
showError('请先填写游戏 UID')
return
}
if (!url) { if (!url) {
showError('领取链接还没准备好,请稍后刷新') showError('领取链接还没准备好,请稍后刷新')
return return
@@ -353,6 +393,7 @@ export default function ClaimPage() {
product={snapshot.product} product={snapshot.product}
currentStep={snapshot.currentStep} currentStep={snapshot.currentStep}
progressText={snapshot.progressText} progressText={snapshot.progressText}
expectedUid={snapshot.expectedUid}
/> />
{loading ? ( {loading ? (
@@ -365,15 +406,41 @@ export default function ClaimPage() {
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" /> <ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
<p>{errorMessage}</p> <p>{errorMessage}</p>
</Card> </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 ? ( ) : snapshot.isFeifeiFlow && snapshot.feifei ? (
<FeifeiClaimPanel <FeifeiClaimPanel
feifei={snapshot.feifei} feifei={snapshot.feifei}
expectedUid={snapshot.expectedUid}
order={snapshot.order} order={snapshot.order}
product={snapshot.product} product={snapshot.product}
taskLastError={snapshot.task?.lastError} taskLastError={snapshot.task?.lastError}
onOpen={() => openFeifeiUrl(false)} 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 <KuaishouCloudClaimSteps
snapshot={snapshot} snapshot={snapshot}
qrCodeDataUrl={qrCodeDataUrl} qrCodeDataUrl={qrCodeDataUrl}
@@ -386,6 +453,10 @@ export default function ClaimPage() {
onRebindRole={rebindRole} onRebindRole={rebindRole}
onConfirmRole={confirmRole} onConfirmRole={confirmRole}
onConfirmRedeem={confirmRedeem} onConfirmRedeem={confirmRedeem}
uidInput={uidInput}
submittingUid={submittingUid}
onUidChange={setUidInput}
onResubmitUid={() => void handleSubmitUid()}
/> />
) : ( ) : (
<Card className="claim-content-card claim-state-card"> <Card className="claim-content-card claim-state-card">
@@ -406,28 +477,28 @@ function createClaimSnapshot(
const orderItem = detail?.orderItem || null const orderItem = detail?.orderItem || null
const product = detail?.product || null const product = detail?.product || null
const task = detail?.task || 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 roleName = flow?.role.name || flow?.binding.roleName || ''
const roleId = flow?.role.rid || flow?.binding.roleId || '' const roleId = flow?.role.rid || flow?.binding.roleId || ''
const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei'
const isTicketVerified = flow?.ticket.status === 'verified'
const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '')
const isBindingPrepared = const isBindingPrepared =
flow?.binding.prepareStatus === 'ready' && flow?.binding.prepareStatus === 'ready' &&
Boolean(String(flow?.binding.bindUrl || '').trim()) && Boolean(String(flow?.binding.bindUrl || '').trim()) &&
!isBindUrlExpired !isBindUrlExpired
const isBindingPreparing = flow?.binding.prepareStatus === 'pending' const isBindingPreparing = flow?.binding.prepareStatus === 'pending'
const canEnterBindingStep = isTicketVerified
const isRoleReady = Boolean(roleName || roleId) const isRoleReady = Boolean(roleName || roleId)
const hasDefaultRoleSnapshot = Boolean(flow?.role.defaultName || flow?.role.defaultRid) const isUidMatched =
const isDefaultRole = flow?.role.isDefaultRole === true Boolean(expectedUid) && Boolean(roleId) && normalizeUid(expectedUid) === normalizeUid(roleId)
const isCustomerRoleReady = isRoleReady && hasDefaultRoleSnapshot && !isDefaultRole
const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({
flow,
isBindingPrepared, isBindingPrepared,
hasDefaultRoleSnapshot,
isRoleReady, isRoleReady,
isDefaultRole, isUidMatched,
expectedUid,
roleId,
rebindingRole: Boolean(options.rebindingRole), rebindingRole: Boolean(options.rebindingRole),
}) })
const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status)
@@ -436,26 +507,40 @@ function createClaimSnapshot(
const isRedeemFailed = const isRedeemFailed =
normalizedStatus === TASK_STATUS.MANUAL_REVIEW || normalizedStatus === TASK_STATUS.MANUAL_REVIEW ||
normalizedStatus === TASK_STATUS.FAILED || normalizedStatus === TASK_STATUS.FAILED ||
String(flow?.dispatch.status || '').trim() === 'failed' String(flow?.dispatch.status || '').trim() === 'failed' ||
const isCompleted = isKuaishouCloudCompletedStatus(task?.status) (isFeifeiFlow && [40, 50].includes(Number(feifei?.rechargeStatus || 0)))
const hasRedeemResult = isDispatched || hasKuaishouCloudRedeemResultStatus(task?.status) 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({ const currentStep = resolveCurrentStep({
isFeifeiFlow, hasExpectedUid,
isCompleted,
hasRedeemResult, hasRedeemResult,
isRoleConfirmed, isRoleConfirmed,
canEnterBindingStep, isFeifeiFlow,
}) })
const progressText = resolveProgressText({ const progressText = resolveProgressText({
feifei, feifei,
isFeifeiFlow, isFeifeiFlow,
hasExpectedUid,
expectedUid,
hasRedeemResult, hasRedeemResult,
isRoleConfirmed, isRoleConfirmed,
isTicketVerified,
isBindingPrepared, isBindingPrepared,
isUidMatched,
}) })
const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, isDispatched }) 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 const resultVariant: ResultVariant = isRedeemFailed
? 'warning' ? 'warning'
: isCompleted || isDispatched : isCompleted || isDispatched
@@ -469,18 +554,17 @@ function createClaimSnapshot(
orderItem, orderItem,
product, product,
task, task,
claimIdentity,
expectedUid,
hasExpectedUid,
roleName, roleName,
roleId, roleId,
isFeifeiFlow, isFeifeiFlow,
isTicketVerified,
isBindUrlExpired, isBindUrlExpired,
isBindingPrepared, isBindingPrepared,
isBindingPreparing, isBindingPreparing,
canEnterBindingStep,
isRoleReady, isRoleReady,
hasDefaultRoleSnapshot, isUidMatched,
isDefaultRole,
isCustomerRoleReady,
confirmRoleDisabledReason, confirmRoleDisabledReason,
isRoleConfirmed, isRoleConfirmed,
isDispatched, isDispatched,
@@ -495,25 +579,28 @@ function createClaimSnapshot(
} }
} }
function normalizeUid(value: unknown) {
return String(value || '')
.trim()
.replace(/\s+/g, '')
}
function resolveConfirmRoleDisabledReason(options: { function resolveConfirmRoleDisabledReason(options: {
flow: ClaimKuaishouCloudFlowInfo | null
isBindingPrepared: boolean isBindingPrepared: boolean
hasDefaultRoleSnapshot: boolean
isRoleReady: boolean isRoleReady: boolean
isDefaultRole: boolean isUidMatched: boolean
expectedUid: string
roleId: string
rebindingRole: boolean rebindingRole: boolean
}) { }) {
if (!options.isBindingPrepared) { if (!options.isBindingPrepared) {
return '绑定二维码还在准备中,请稍后自动刷新' return '绑定二维码还在准备中,请稍后自动刷新'
} }
if (!options.hasDefaultRoleSnapshot) {
return options.flow?.role.defaultErrorMessage || '系统还未获取到虚拟机默认角色信息,请稍后刷新'
}
if (!options.isRoleReady) { if (!options.isRoleReady) {
return '请先扫码绑定自己的角色,并刷新角色信息' return '请先扫码绑定自己的角色,并刷新角色信息'
} }
if (options.isDefaultRole) { if (!options.isUidMatched) {
return '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' return `当前绑定角色 ID${options.roleId || '-'})与填写 UID${options.expectedUid || '-'})不一致`
} }
if (options.rebindingRole) { if (options.rebindingRole) {
return '正在换绑角色,请稍候' return '正在换绑角色,请稍候'
@@ -522,37 +609,38 @@ function resolveConfirmRoleDisabledReason(options: {
} }
function resolveCurrentStep(options: { function resolveCurrentStep(options: {
isFeifeiFlow: boolean hasExpectedUid: boolean
isCompleted: boolean
hasRedeemResult: boolean hasRedeemResult: boolean
isRoleConfirmed: boolean isRoleConfirmed: boolean
canEnterBindingStep: boolean isFeifeiFlow: boolean
}) { }) {
if (options.isFeifeiFlow) { if (!options.hasExpectedUid) {
return options.isCompleted ? 4 : 2 return 1
} }
if (options.hasRedeemResult) { if (options.hasRedeemResult) {
return 4 return 4
} }
if (options.isRoleConfirmed) { if (!options.isFeifeiFlow && options.isRoleConfirmed) {
return 3 return 3
} }
if (options.canEnterBindingStep) { return 2
return 2
}
return 1
} }
function resolveProgressText(options: { function resolveProgressText(options: {
feifei: ClaimKuaishouFeifeiFlowInfo | null feifei: ClaimKuaishouFeifeiFlowInfo | null
isFeifeiFlow: boolean isFeifeiFlow: boolean
hasExpectedUid: boolean
expectedUid: string
hasRedeemResult: boolean hasRedeemResult: boolean
isRoleConfirmed: boolean isRoleConfirmed: boolean
isTicketVerified: boolean
isBindingPrepared: boolean isBindingPrepared: boolean
isUidMatched: boolean
}) { }) {
if (!options.hasExpectedUid) {
return '请填写游戏 UID'
}
if (options.isFeifeiFlow) { if (options.isFeifeiFlow) {
return options.feifei?.rechargeStatusLabel || '请打开领取链接' return options.feifei?.rechargeStatusLabel || `UID ${options.expectedUid}请打开领取链接`
} }
if (options.hasRedeemResult) { if (options.hasRedeemResult) {
return '兑换结果已生成' return '兑换结果已生成'
@@ -560,10 +648,13 @@ function resolveProgressText(options: {
if (options.isRoleConfirmed) { if (options.isRoleConfirmed) {
return '角色已确认,等待兑换' return '角色已确认,等待兑换'
} }
if (options.isTicketVerified) { if (!options.isBindingPrepared) {
return options.isBindingPrepared ? '请完成扫码绑定' : '绑定链接刷新中,请稍候' return '绑定链接准备中,请稍候'
} }
return '等待提交并核销' if (options.isUidMatched) {
return 'UID 已匹配,请确认下一步'
}
return '请完成扫码绑定并匹配 UID'
} }
function resolveResultTitle(options: { function resolveResultTitle(options: {
@@ -586,6 +677,7 @@ function resolveResultTitle(options: {
function resolveResultDescription(options: { function resolveResultDescription(options: {
detail: ClaimDetailData | null detail: ClaimDetailData | null
flow: ClaimKuaishouCloudFlowInfo | null flow: ClaimKuaishouCloudFlowInfo | null
feifei: ClaimKuaishouFeifeiFlowInfo | null
task: ClaimDetailData['task'] | null task: ClaimDetailData['task'] | null
isRedeemFailed: boolean isRedeemFailed: boolean
isCompleted: boolean isCompleted: boolean
@@ -594,6 +686,7 @@ function resolveResultDescription(options: {
const message = String( const message = String(
options.task?.lastError || options.task?.lastError ||
options.flow?.dispatch.errorMessage || options.flow?.dispatch.errorMessage ||
options.feifei?.rechargeResultMessage ||
options.detail?.result?.resultMessage || options.detail?.result?.resultMessage ||
'', '',
).trim() ).trim()
@@ -601,10 +694,10 @@ function resolveResultDescription(options: {
} }
if (options.isCompleted) { if (options.isCompleted) {
return '当前兑换流程已经完成。发货、退号和核销结果会保存在后台任务界面。' return '当前兑换流程已经完成。'
} }
return '你的兑换请求已经提交。发货、退号和核销结果会由系统保存在后台任务界面,无需在此页面等待。' return '你的兑换请求已经提交。后续结果会由系统保存在后台任务界面。'
} }
function isDateExpired(value: string | null) { function isDateExpired(value: string | null) {
@@ -622,18 +715,21 @@ function ClaimHeaderCard({
product, product,
currentStep, currentStep,
progressText, progressText,
expectedUid,
}: { }: {
order: ClaimOrderInfo | null order: ClaimOrderInfo | null
product: ClaimProductInfo | null product: ClaimProductInfo | null
currentStep: number currentStep: number
progressText: string progressText: string
expectedUid: string
}) { }) {
return ( return (
<Card className="claim-header-card"> <Card className="claim-header-card">
<div className="claim-header-copy"> <div className="claim-header-copy">
<span className="eyebrow">kuaishou-lewan </span> <span className="eyebrow"></span>
<h1>{product?.title || '商品领取'}</h1> <h1>{product?.title || '商品领取'}</h1>
<p>{order?.platformOrderId || '-'}</p> <p>{order?.platformOrderId || '-'}</p>
{expectedUid ? <p> UID{expectedUid}</p> : null}
<ClaimProductItems product={product} compact /> <ClaimProductItems product={product} compact />
</div> </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({ function KuaishouCloudClaimSteps({
snapshot, snapshot,
qrCodeDataUrl, qrCodeDataUrl,
@@ -663,6 +810,10 @@ function KuaishouCloudClaimSteps({
onRebindRole, onRebindRole,
onConfirmRole, onConfirmRole,
onConfirmRedeem, onConfirmRedeem,
uidInput,
submittingUid,
onUidChange,
onResubmitUid,
}: { }: {
snapshot: ClaimSnapshot snapshot: ClaimSnapshot
qrCodeDataUrl: string qrCodeDataUrl: string
@@ -675,36 +826,21 @@ function KuaishouCloudClaimSteps({
onRebindRole: () => void onRebindRole: () => void
onConfirmRole: () => void onConfirmRole: () => void
onConfirmRedeem: () => void onConfirmRedeem: () => void
uidInput: string
submittingUid: boolean
onUidChange: (value: string) => void
onResubmitUid: () => void
}) { }) {
if (!snapshot.flow) { if (!snapshot.flow) {
return null 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) { if (snapshot.currentStep === 3) {
return ( return (
<ClaimConfirmStep <ClaimConfirmStep
roleName={snapshot.roleName} roleName={snapshot.roleName}
roleId={snapshot.roleId} roleId={snapshot.roleId}
expectedUid={snapshot.expectedUid}
product={snapshot.product} product={snapshot.product}
redeeming={redeeming} redeeming={redeeming}
rebindingRole={rebindingRole} rebindingRole={rebindingRole}
@@ -714,25 +850,22 @@ function KuaishouCloudClaimSteps({
) )
} }
return <ClaimResultStep snapshot={snapshot} />
}
function ClaimDeprecatedTicketStep() {
return ( return (
<Card className="claim-content-card"> <ClaimBindingStep
<Typography.Title level={2}></Typography.Title> snapshot={snapshot}
<p className="claim-muted"> qrCodeDataUrl={qrCodeDataUrl}
refreshingRole={refreshingRole}
</p> confirmingRole={confirmingRole}
rebindingRole={rebindingRole}
<Alert onOpenBindUrl={onOpenBindUrl}
type="warning" onRefreshRole={onRefreshRole}
showIcon onRebindRole={onRebindRole}
icon={<ExclamationCircleOutlined />} onConfirmRole={onConfirmRole}
message="快手小店核销入口已下线" uidInput={uidInput}
description="电子凭证发码、查询和核销请以新的电子凭证流程为准。" submittingUid={submittingUid}
/> onUidChange={onUidChange}
</Card> onResubmitUid={onResubmitUid}
/>
) )
} }
@@ -746,6 +879,10 @@ function ClaimBindingStep({
onRefreshRole, onRefreshRole,
onRebindRole, onRebindRole,
onConfirmRole, onConfirmRole,
uidInput,
submittingUid,
onUidChange,
onResubmitUid,
}: { }: {
snapshot: ClaimSnapshot snapshot: ClaimSnapshot
qrCodeDataUrl: string qrCodeDataUrl: string
@@ -756,6 +893,10 @@ function ClaimBindingStep({
onRefreshRole: () => void onRefreshRole: () => void
onRebindRole: () => void onRebindRole: () => void
onConfirmRole: () => void onConfirmRole: () => void
uidInput: string
submittingUid: boolean
onUidChange: (value: string) => void
onResubmitUid: () => void
}) { }) {
const flow = snapshot.flow const flow = snapshot.flow
if (!flow) { if (!flow) {
@@ -764,6 +905,18 @@ function ClaimBindingStep({
return ( return (
<Card className="claim-content-card claim-binding-card"> <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 ? ( {snapshot.isBindingPrepared && qrCodeDataUrl ? (
<div className="claim-qr-block"> <div className="claim-qr-block">
<div className="claim-qr-guide-panel"> <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="当前角色" value={snapshot.roleName || '待识别'} />
<InfoRow label="角色 ID" value={snapshot.roleId || '-'} /> <InfoRow label="角色 ID" value={snapshot.roleId || '-'} />
<InfoRow label="匹配状态" value={snapshot.isUidMatched ? '已匹配' : '未匹配'} />
</div> </div>
{flow.role.isDefaultRole ? ( {!snapshot.isUidMatched && snapshot.roleId ? (
<Alert type="warning" showIcon message="当前仍是虚拟机默认角色,请重新绑定自己的角色信息。" /> <Alert
type="warning"
showIcon
message={`当前绑定角色 ID${snapshot.roleId})与填写 UID${snapshot.expectedUid})不一致,请换绑正确角色。`}
/>
) : null} ) : null}
<Space size={12} wrap className="claim-action-row"> <Space size={12} wrap className="claim-action-row">
@@ -818,7 +977,7 @@ function ClaimBindingStep({
disabled={Boolean(snapshot.confirmRoleDisabledReason)} disabled={Boolean(snapshot.confirmRoleDisabledReason)}
onClick={onConfirmRole} onClick={onConfirmRole}
> >
UID
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
@@ -827,11 +986,6 @@ function ClaimBindingStep({
<div className="claim-meta-footer"> <div className="claim-meta-footer">
<span>{formatAdminDateTime(flow.role.refreshedAt)}</span> <span>{formatAdminDateTime(flow.role.refreshedAt)}</span>
<span>{formatAdminDateTime(flow.binding.bindExpiresAt)}</span> <span>{formatAdminDateTime(flow.binding.bindExpiresAt)}</span>
{flow.binding.bindProbeMessage ? (
<span>
{flow.binding.bindProbeStatus || '-'} / {flow.binding.bindProbeMessage}
</span>
) : null}
</div> </div>
</Card> </Card>
) )
@@ -840,6 +994,7 @@ function ClaimBindingStep({
function ClaimConfirmStep({ function ClaimConfirmStep({
roleName, roleName,
roleId, roleId,
expectedUid,
product, product,
redeeming, redeeming,
rebindingRole, rebindingRole,
@@ -848,6 +1003,7 @@ function ClaimConfirmStep({
}: { }: {
roleName: string roleName: string
roleId: string roleId: string
expectedUid: string
product: ClaimProductInfo | null product: ClaimProductInfo | null
redeeming: boolean redeeming: boolean
rebindingRole: boolean rebindingRole: boolean
@@ -857,9 +1013,10 @@ function ClaimConfirmStep({
return ( return (
<Card className="claim-content-card"> <Card className="claim-content-card">
<Typography.Title level={2}> 3 </Typography.Title> <Typography.Title level={2}> 3 </Typography.Title>
<p className="claim-muted"></p> <p className="claim-muted"> UID </p>
<div className="claim-info-grid"> <div className="claim-info-grid">
<InfoTile label="填写 UID" value={expectedUid || '-'} />
<InfoTile label="角色名称" value={roleName || '-'} /> <InfoTile label="角色名称" value={roleName || '-'} />
<InfoTile label="角色 ID" value={roleId || '-'} /> <InfoTile label="角色 ID" value={roleId || '-'} />
<InfoTile label="领取商品" value={product?.title || '-'} /> <InfoTile label="领取商品" value={product?.title || '-'} />
@@ -897,9 +1054,6 @@ function ClaimConfirmStep({
function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
const flow = snapshot.flow const flow = snapshot.flow
if (!flow) {
return null
}
return ( return (
<Card className="claim-content-card"> <Card className="claim-content-card">
@@ -916,12 +1070,12 @@ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
</div> </div>
<div className="claim-info-grid"> <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.product?.title || '-'} />
<InfoTile label="角色名称" value={snapshot.roleName || '-'} /> <InfoTile label="角色名称" value={snapshot.roleName || '-'} />
<InfoTile label="角色 ID" value={snapshot.roleId || '-'} /> <InfoTile label="角色 ID" value={snapshot.roleId || '-'} />
<InfoTile label="订单号" value={snapshot.order?.platformOrderId || '-'} /> <InfoTile label="订单号" value={snapshot.order?.platformOrderId || '-'} />
<InfoTile label="购买数量" value={String(snapshot.product?.quantity || 0)} />
</div> </div>
<ClaimProductItems product={snapshot.product} /> <ClaimProductItems product={snapshot.product} />
@@ -941,45 +1095,128 @@ function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) {
function FeifeiClaimPanel({ function FeifeiClaimPanel({
feifei, feifei,
expectedUid,
order, order,
product, product,
taskLastError, taskLastError,
onOpen, onOpen,
uidInput,
submittingUid,
onUidChange,
onResubmitUid,
}: { }: {
feifei: ClaimKuaishouFeifeiFlowInfo feifei: ClaimKuaishouFeifeiFlowInfo
expectedUid: string
order: ClaimOrderInfo | null order: ClaimOrderInfo | null
product: ClaimProductInfo | null product: ClaimProductInfo | null
taskLastError?: string taskLastError?: string
onOpen: () => void 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 ( return (
<Card className="claim-content-card"> <Card className="claim-content-card">
<div className="claim-feifei-main"> <div className="claim-feifei-main">
<span className="claim-feifei-label">kuaishou-feifei</span> <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> <p>{feifei.rechargeStatusLabel || '待领取'}</p>
</div> </div>
<UidEditRow
uidInput={uidInput}
submitting={submittingUid}
onChange={onUidChange}
onSubmit={onResubmitUid}
/>
<div className="claim-info-grid"> <div className="claim-info-grid">
<InfoTile label="填写 UID" value={expectedUid || '-'} />
<InfoTile label="订单号" value={order?.platformOrderId || '-'} /> <InfoTile label="订单号" value={order?.platformOrderId || '-'} />
<InfoTile label="平台单号" value={feifei.orderNo || feifei.platformOrderNo || '-'} /> <InfoTile label="平台单号" value={feifei.orderNo || feifei.platformOrderNo || '-'} />
<InfoTile label="领取商品" value={product?.title || '-'} />
</div> </div>
<Alert
type="info"
showIcon
message="将携带你填写的 UID 打开领取页,请确认游戏内角色一致。"
/>
<Button <Button
type="primary" type="primary"
size="large" size="large"
icon={<LinkOutlined />} icon={<LinkOutlined />}
disabled={!feifei.h5.rechargeUrl && !feifei.h5.entryUrl} disabled={!openReady || !expectedUid}
onClick={onOpen} onClick={onOpen}
block
style={{ marginTop: 16 }}
> >
</Button> </Button>
{taskLastError ? <Alert type="warning" showIcon message={taskLastError} /> : null} {taskLastError ? <Alert type="warning" showIcon message={taskLastError} style={{ marginTop: 12 }} /> : null}
</Card> </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({ function ClaimProductItems({
product, product,
compact = false, compact = false,
+4
View File
@@ -5,6 +5,10 @@ export function fetchClaimDetail(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}`) 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) { export function confirmKuaishouCloudClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {})
} }
+9
View File
@@ -4,6 +4,13 @@ export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (stri
export type ClaimTaskStatus = KnownTaskStatus | (string & {}) export type ClaimTaskStatus = KnownTaskStatus | (string & {})
export interface ClaimIdentityInfo {
expectedUid: string
submittedAt: string | null
source: string
ready: boolean
}
export interface ClaimTaskInfo { export interface ClaimTaskInfo {
taskId: number taskId: number
taskNo: string taskNo: string
@@ -150,6 +157,7 @@ export interface ClaimKuaishouFeifeiFlowInfo {
entryUrl: string entryUrl: string
rechargeUrl: string rechargeUrl: string
} }
h5UrlWithUid: string
lastSyncedAt: string | null lastSyncedAt: string | null
} }
@@ -157,6 +165,7 @@ export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus tokenStatus: ClaimTokenStatus
claimUrl: string claimUrl: string
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {}) flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {})
claimIdentity: ClaimIdentityInfo | null
task: ClaimTaskInfo task: ClaimTaskInfo
order: ClaimOrderInfo order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo orderItem: ClaimOrderItemInfo