lewan 发货强制 expectedUid 并取消无 UID 旧路径回落;任务详情展示 UID 匹配态;领取页匹配后可一键兑换;补充 identity 单测并收口短链说明。
266 lines
8.6 KiB
TypeScript
266 lines
8.6 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 { 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]'
|
||
}
|