统一领取流: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)))
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}`)
}
}
+11
View File
@@ -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
}
+357 -120
View File
@@ -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,
+4
View File
@@ -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`, {})
}
+9
View File
@@ -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