Files
order_site/apps/backend/src/services/claim/claim-identity.ts
T

314 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]'
}