统一领取流: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
@@ -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',