314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
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 {
|
||
ClaimIdentity,
|
||
ClaimIdentityAdminSummary,
|
||
ClaimIdentityPayload,
|
||
} from '../../types/claim-identity.js'
|
||
import type { TaskRow } from '../../types/repository/rows.js'
|
||
import type { JsonObject } from '../../types/json.js'
|
||
|
||
export type {
|
||
ClaimIdentity,
|
||
ClaimIdentityAdminSummary,
|
||
ClaimIdentityPayload,
|
||
} from '../../types/claim-identity.js'
|
||
|
||
// 游戏 UID(角色编号)长度上限:91 单号为 16 位纯数字,防止用户把 91 单号误当 UID 提交。
|
||
const CLAIM_UID_MAX_LENGTH = 15
|
||
|
||
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 (!/^[0-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(旧单无 UID 不可自动履约)', {
|
||
statusCode: 409,
|
||
errorCode: 'claim_uid_not_submitted',
|
||
})
|
||
}
|
||
return expectedUid
|
||
}
|
||
|
||
/**
|
||
* lewan 自动发货前:必须已有 expectedUid(不再回落「仅确认角色」旧路径)。
|
||
* mock 任务可跳过。
|
||
*/
|
||
export function assertLewanAutoFulfillmentUidReady(
|
||
task: Partial<TaskRow> | 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 } = {},
|
||
): ClaimIdentityAdminSummary {
|
||
const identity = getClaimIdentityFromContext(context)
|
||
const flow = isPlainObject(options.flowLike) ? options.flowLike : {}
|
||
const binding = isPlainObject(flow.binding) ? flow.binding : {}
|
||
const role = isPlainObject(flow.role) ? flow.role : {}
|
||
const shipped = isPlainObject(flow.shipped) ? flow.shipped : null
|
||
const dispatch = isPlainObject(flow.dispatch) ? flow.dispatch : {}
|
||
const shippedUid = normalizeClaimUid(shipped?.roleId || shipped?.expectedUid || '')
|
||
const shippedRoleName = String(shipped?.roleName || '').trim()
|
||
const shippedAt = shipped?.dispatchedAt ? String(shipped.dispatchedAt) : null
|
||
const dispatchSucceeded = String(dispatch.status || '').trim() === 'success'
|
||
// 已发货优先用快照,避免 claim 轮询换号后误报 UID 不一致
|
||
const useShipped = Boolean(shippedUid && (dispatchSucceeded || shippedAt))
|
||
const liveBoundUid =
|
||
resolveBoundRoleUid(options.flowLike) || normalizeClaimUid(options.taskRoleId)
|
||
const liveBoundRoleName = String(
|
||
role.name || binding.roleName || options.taskRoleName || '',
|
||
).trim()
|
||
const boundUid = useShipped ? shippedUid : liveBoundUid
|
||
const boundRoleName = useShipped ? shippedRoleName || liveBoundRoleName : liveBoundRoleName
|
||
const ready = Boolean(identity.expectedUid)
|
||
const uidMatched = ready && boundUid ? isClaimUidMatched(identity.expectedUid, boundUid) : null
|
||
const liveMismatched =
|
||
useShipped && liveBoundUid && shippedUid && !isClaimUidMatched(shippedUid, liveBoundUid)
|
||
|
||
let note = '旧单或未提交 UID:用户须先打开领取页填写 UID,否则禁止自动发货'
|
||
if (ready) {
|
||
if (useShipped) {
|
||
if (uidMatched === true) {
|
||
note = liveMismatched
|
||
? `已发货:道具发至 UID ${shippedUid}${shippedRoleName ? `(${shippedRoleName})` : ''};当前虚拟号角色已变化,请勿以当前绑定为准`
|
||
: `已发货:道具发至 UID ${shippedUid}${shippedRoleName ? `(${shippedRoleName})` : ''},与填写 UID 一致`
|
||
} else if (uidMatched === false) {
|
||
note = `已发货快照角色 ID(${boundUid})与填写 UID(${identity.expectedUid})不一致,请核对事件日志`
|
||
} else {
|
||
note = '已发货,但快照缺少角色 ID'
|
||
}
|
||
} else if (dispatchSucceeded) {
|
||
note =
|
||
uidMatched === true
|
||
? '已发货:当前绑定角色与填写 UID 一致(无发货快照,建议以事件日志为准)'
|
||
: uidMatched === false
|
||
? '已发货,但当前绑定角色与填写 UID 不一致(可能为发货后换号污染;请核对 dispatch 事件中的 roleId)'
|
||
: '已发货,等待核对绑定角色'
|
||
} else if (uidMatched === true) {
|
||
note = 'UID 已匹配,可继续履约'
|
||
} else if (uidMatched === false) {
|
||
note = '绑定角色 ID 与填写 UID 不一致'
|
||
} else {
|
||
note = '已填 UID,等待绑定识别'
|
||
}
|
||
}
|
||
|
||
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,
|
||
displaySource: useShipped ? 'shipped' : 'live',
|
||
shippedUid,
|
||
shippedRoleName,
|
||
shippedAt,
|
||
}
|
||
}
|
||
|
||
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): ClaimIdentityPayload {
|
||
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]'
|
||
}
|