Files
order_site/apps/backend/src/services/claim/claim-identity.ts
T
yml2213 8de56b0354 优化 UID 履约闸门、运营可见性与一键兑换
lewan 发货强制 expectedUid 并取消无 UID 旧路径回落;任务详情展示 UID 匹配态;领取页匹配后可一键兑换;补充 identity 单测并收口短链说明。
2026-07-10 13:51:02 +08:00

266 lines
8.6 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 { 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(旧单无 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 } = {},
) {
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<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]'
}