import { createHttpError } from '../../utils/http.js' import { parseTaskContext } from '../../utils/task-json.js' import { isTaskFinalStatus, normalizeTaskStatus, TASK_STATUS } from '../../domain/task-status.js' import type { TaskRow } from '../../types/repository/rows.js' export type ClaimIdentity = { expectedUid: string submittedAt: string | null source: string } type JsonObject = Record const CLAIM_UID_MAX_LENGTH = 64 export function normalizeClaimUid(value: unknown): string { return String(value || '') .trim() .replace(/\s+/g, '') } export function assertValidClaimUid(value: unknown): string { const uid = normalizeClaimUid(value) if (!uid) { throw createHttpError('请输入游戏 UID', { statusCode: 400, errorCode: 'claim_uid_required', }) } if (uid.length > CLAIM_UID_MAX_LENGTH) { throw createHttpError(`UID 长度不能超过 ${CLAIM_UID_MAX_LENGTH} 个字符`, { statusCode: 400, errorCode: 'claim_uid_too_long', }) } if (!/^[A-Za-z0-9_\-]+$/.test(uid)) { throw createHttpError('UID 仅支持字母、数字、下划线和中划线', { statusCode: 400, errorCode: 'claim_uid_invalid', }) } return uid } export function normalizeClaimIdentity(value: unknown): ClaimIdentity { const source = isPlainObject(value) ? value : {} return { expectedUid: normalizeClaimUid(source.expectedUid || source.uid), submittedAt: source.submittedAt ? String(source.submittedAt) : null, source: String(source.source || '').trim(), } } export function getClaimIdentityFromContext(context: unknown): ClaimIdentity { const source = isPlainObject(context) ? context : {} return normalizeClaimIdentity(source.claimIdentity) } export function getClaimIdentityFromTask(task: Partial | null | undefined): ClaimIdentity { return getClaimIdentityFromContext(parseTaskContext(task)) } export function hasClaimExpectedUid(taskOrContext: unknown): boolean { if (taskOrContext && typeof taskOrContext === 'object' && 'context_json' in (taskOrContext as object)) { return Boolean(getClaimIdentityFromTask(taskOrContext as TaskRow).expectedUid) } return Boolean(getClaimIdentityFromContext(taskOrContext).expectedUid) } export function isClaimUidMatched(expectedUid: unknown, boundUid: unknown): boolean { const expected = normalizeClaimUid(expectedUid) const bound = normalizeClaimUid(boundUid) if (!expected || !bound) { return false } return expected === bound } export function resolveBoundRoleUid(flowLike: unknown): string { const flow = isPlainObject(flowLike) ? flowLike : {} const binding = isPlainObject(flow.binding) ? flow.binding : {} const role = isPlainObject(flow.role) ? flow.role : {} return normalizeClaimUid( binding.roleId || role.rid || role.roleId || binding.uid || role.uid || '', ) } export function assertClaimExpectedUidReady(task: Partial | null | undefined): string { const expectedUid = getClaimIdentityFromTask(task).expectedUid if (!expectedUid) { throw createHttpError('请先通过领取页填写游戏 UID(旧单无 UID 不可自动履约)', { statusCode: 409, errorCode: 'claim_uid_not_submitted', }) } return expectedUid } /** * lewan 自动发货前:必须已有 expectedUid(不再回落「仅确认角色」旧路径)。 * mock 任务可跳过。 */ export function assertLewanAutoFulfillmentUidReady( task: Partial | null | undefined, options: { allowMockSkip?: boolean; errorCodePrefix?: string } = {}, ): string { const context = parseTaskContext(task) const flow = context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object' ? (context.kuaishouCloudFulfillment as JsonObject) : {} const mock = flow.mock && typeof flow.mock === 'object' ? (flow.mock as JsonObject) : null if (options.allowMockSkip !== false && mock?.enabled === true) { return getClaimIdentityFromTask(task).expectedUid } const expectedUid = getClaimIdentityFromTask(task).expectedUid if (!expectedUid) { const prefix = String(options.errorCodePrefix || 'kuaishou_cloud').trim() || 'kuaishou_cloud' throw createHttpError( '该 lewan 任务尚未填写游戏 UID,请用户打开领取页提交 UID 后再发货(旧单不再支持无 UID 自动发货)', { statusCode: 409, errorCode: `${prefix}_expected_uid_required`, }, ) } return expectedUid } export function buildClaimIdentityAdminSummary( context: unknown, options: { flowLike?: unknown; taskRoleId?: unknown; taskRoleName?: unknown } = {}, ) { const identity = getClaimIdentityFromContext(context) const boundUid = resolveBoundRoleUid(options.flowLike) || normalizeClaimUid(options.taskRoleId) const flow = isPlainObject(options.flowLike) ? options.flowLike : {} const binding = isPlainObject(flow.binding) ? flow.binding : {} const role = isPlainObject(flow.role) ? flow.role : {} const boundRoleName = String( role.name || binding.roleName || options.taskRoleName || '', ).trim() const ready = Boolean(identity.expectedUid) const uidMatched = ready && boundUid ? isClaimUidMatched(identity.expectedUid, boundUid) : null return { expectedUid: identity.expectedUid, submittedAt: identity.submittedAt, source: identity.source, ready, boundUid, boundRoleName, uidMatched, compatibilityMode: ready ? ('uid' as const) : ('legacy_no_uid' as const), note: ready ? uidMatched === true ? 'UID 已匹配,可继续履约' : uidMatched === false ? '绑定角色 ID 与填写 UID 不一致' : '已填 UID,等待绑定识别' : '旧单或未提交 UID:用户须先打开领取页填写 UID,否则禁止自动发货', } } export function assertBoundUidMatchesExpected( task: Partial | null | undefined, flowLike: unknown, options: { errorCodePrefix?: string } = {}, ): string { const expectedUid = assertClaimExpectedUidReady(task) const boundUid = resolveBoundRoleUid(flowLike) const prefix = String(options.errorCodePrefix || 'claim').trim() || 'claim' if (!boundUid) { throw createHttpError('尚未识别到绑定角色 ID,请完成绑定后刷新再试', { statusCode: 409, errorCode: `${prefix}_bound_uid_missing`, }) } if (!isClaimUidMatched(expectedUid, boundUid)) { throw createHttpError( `绑定角色 ID(${boundUid})与填写的 UID(${expectedUid})不一致,请重新绑定正确角色`, { statusCode: 409, errorCode: `${prefix}_uid_mismatch`, }, ) } return expectedUid } export function canUpdateClaimUid(task: Partial | null | undefined): boolean { const status = normalizeTaskStatus(task?.task_status) if (isTaskFinalStatus(status)) { return false } if ( status === TASK_STATUS.REDEEMING || status === TASK_STATUS.DISPATCHED_PENDING_RETURN || status === TASK_STATUS.COMPLETED || status === TASK_STATUS.REDEEMED ) { return false } const context = parseTaskContext(task) const flow = isPlainObject(context.kuaishouCloudFulfillment) ? context.kuaishouCloudFulfillment : {} const dispatch = isPlainObject(flow.dispatch) ? flow.dispatch : {} if (String(dispatch.status || '').trim() === 'success') { return false } const feifei = isPlainObject(context.kuaishouFeifei) ? context.kuaishouFeifei : {} const rechargeStatus = Number(feifei.rechargeStatus || 0) || 0 if (rechargeStatus === 30) { return false } return true } /** * 将 uid 拼到目标 H5 链接 query 中(覆盖已有 uid 参数)。 */ export function appendUidToUrl(targetUrl: unknown, uid: unknown): string { const rawUrl = String(targetUrl || '').trim() const normalizedUid = normalizeClaimUid(uid) if (!rawUrl || !normalizedUid) { return rawUrl } try { const url = new URL(rawUrl) url.searchParams.set('uid', normalizedUid) return url.toString() } catch { const separator = rawUrl.includes('?') ? '&' : '?' if (/([?&])uid=/i.test(rawUrl)) { return rawUrl.replace(/([?&])uid=[^&]*/i, `$1uid=${encodeURIComponent(normalizedUid)}`) } return `${rawUrl}${separator}uid=${encodeURIComponent(normalizedUid)}` } } export function buildClaimIdentityPayload(value: unknown) { const identity = normalizeClaimIdentity(value) return { expectedUid: identity.expectedUid, submittedAt: identity.submittedAt, source: identity.source, ready: Boolean(identity.expectedUid), } } function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' }