优化 UID 履约闸门、运营可见性与一键兑换

lewan 发货强制 expectedUid 并取消无 UID 旧路径回落;任务详情展示 UID 匹配态;领取页匹配后可一键兑换;补充 identity 单测并收口短链说明。
This commit is contained in:
yml2213
2026-07-10 13:51:02 +08:00
parent dc89188f7a
commit 8de56b0354
13 changed files with 383 additions and 152 deletions
@@ -1,4 +1,5 @@
import { buildClaimUrl } from '../claim/claim-service.js'
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
@@ -250,6 +251,14 @@ export async function getAdminTaskDetail(
mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
cloudSourceLabelMap,
}) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
const claimIdentity = buildClaimIdentityAdminSummary(taskContext, {
flowLike:
taskContext.kuaishouCloudFulfillment ||
taskContext.kuaishouFeifei ||
null,
taskRoleId: task.role_id,
taskRoleName: task.role_name,
})
const firstKuaishouIndustryVoucher = resolveKuaishouIndustryVoucherForTask(
task,
taskContext,
@@ -316,6 +325,7 @@ export async function getAdminTaskDetail(
roleName: String(taskState.reviewRoleName || '').trim() || '',
},
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
claimIdentity,
kuaishouCloudFulfillment,
kuaishouIndustryVoucher,
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
@@ -121,11 +121,13 @@ export function mapKuaishouCloudFulfillmentContext(
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName: String(binding.roleName || role.name || '').trim(),
roleId: String(binding.roleId || role.rid || '').trim(),
},
role: {
status: String(role.status || 'pending').trim() || 'pending',
name: String(role.name || '').trim(),
rid: String(role.rid || '').trim(),
name: String(role.name || binding.roleName || '').trim(),
rid: String(role.rid || binding.roleId || '').trim(),
refreshedAt: role.refreshedAt || null,
errorMessage: String(role.errorMessage || '').trim(),
rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
@@ -3,7 +3,9 @@ import test from 'node:test'
import {
appendUidToUrl,
assertLewanAutoFulfillmentUidReady,
assertValidClaimUid,
buildClaimIdentityAdminSummary,
isClaimUidMatched,
normalizeClaimUid,
} from './claim-identity.js'
@@ -38,3 +40,61 @@ test('appendUidToUrl overwrites existing uid', () => {
const next = appendUidToUrl('https://example.com/h5/bind?code=abc&uid=old', 'new-uid')
assert.equal(new URL(next).searchParams.get('uid'), 'new-uid')
})
test('assertLewanAutoFulfillmentUidReady rejects legacy task without uid', () => {
assert.throws(
() =>
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({ kuaishouCloudFulfillment: {} }),
}),
/尚未填写游戏 UID/,
)
})
test('assertLewanAutoFulfillmentUidReady allows mock without uid', () => {
assert.equal(
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({
kuaishouCloudFulfillment: { mock: { enabled: true } },
}),
}),
'',
)
})
test('assertLewanAutoFulfillmentUidReady accepts submitted uid', () => {
assert.equal(
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({
claimIdentity: { expectedUid: '10001', submittedAt: '2026-07-10T00:00:00.000Z' },
kuaishouCloudFulfillment: {},
}),
}),
'10001',
)
})
test('buildClaimIdentityAdminSummary reports match state', () => {
const summary = buildClaimIdentityAdminSummary(
{
claimIdentity: { expectedUid: '10001', submittedAt: 't', source: 'claim_page' },
},
{
flowLike: {
binding: { roleId: '10001', roleName: '测试角色' },
role: { rid: '10001', name: '测试角色' },
},
},
)
assert.equal(summary.ready, true)
assert.equal(summary.uidMatched, true)
assert.equal(summary.compatibilityMode, 'uid')
assert.equal(summary.boundUid, '10001')
})
test('buildClaimIdentityAdminSummary marks legacy without uid', () => {
const summary = buildClaimIdentityAdminSummary({}, { flowLike: { binding: {} } })
assert.equal(summary.ready, false)
assert.equal(summary.compatibilityMode, 'legacy_no_uid')
assert.equal(summary.uidMatched, null)
})
@@ -91,7 +91,7 @@ export function resolveBoundRoleUid(flowLike: unknown): string {
export function assertClaimExpectedUidReady(task: Partial<TaskRow> | null | undefined): string {
const expectedUid = getClaimIdentityFromTask(task).expectedUid
if (!expectedUid) {
throw createHttpError('请先填写游戏 UID', {
throw createHttpError('请先通过领取页填写游戏 UID(旧单无 UID 不可自动履约)', {
statusCode: 409,
errorCode: 'claim_uid_not_submitted',
})
@@ -99,6 +99,72 @@ export function assertClaimExpectedUidReady(task: Partial<TaskRow> | null | unde
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,
@@ -405,28 +405,70 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
})
}
const currentStatus = normalizeTaskStatus(context.task.task_status)
let workingTask = context.task
const currentStatus = normalizeTaskStatus(workingTask.task_status)
if (isKuaishouCloudRedeemSettledStatus(currentStatus)) {
return getKuaishouCloudClaimDetail(token)
}
if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) {
throw createHttpError('请先确认角色信息', {
throw createHttpError('当前状态不可兑换,请先完成绑定并匹配 UID', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_role_not_confirmed',
errorCode: 'claim_kuaishou_cloud_not_ready_for_redeem',
})
}
// WAITING_BINDING + UID 匹配:自动升为 ROLE_CONFIRMED,实现一键兑换
if (currentStatus === TASK_STATUS.WAITING_BINDING) {
const flow = normalizeKuaishouCloudFlow(
parseTaskContext(workingTask).kuaishouCloudFulfillment,
)
if (!isKuaishouCloudMockTask(workingTask)) {
assertBoundUidMatchesExpected(workingTask, flow, {
errorCodePrefix: 'claim_kuaishou_cloud_redeem',
})
} else {
assertClaimExpectedUidReady(workingTask)
}
const confirmed = await updateTask(workingTask.id, {
task_status: TASK_STATUS.ROLE_CONFIRMED,
role_id: flow.binding.roleId || workingTask.role_id || '',
role_name: flow.binding.roleName || workingTask.role_name || '',
user_action_status: TASK_STATUS.ROLE_CONFIRMED,
role_confirmed_at: now,
last_error: '',
updated_at: now,
})
if (confirmed) {
workingTask = confirmed
}
await createTaskEvent(
workingTask.id,
'kuaishou_cloud_role_confirmed',
{
expectedUid: getClaimIdentityFromTask(workingTask).expectedUid,
vnPhone: flow.binding.vnPhone,
roleName: flow.binding.roleName,
roleId: flow.binding.roleId,
source: 'claim_page_one_click_redeem',
},
now,
)
}
const flowBeforeRedeem = normalizeKuaishouCloudFlow(
parseTaskContext(context.task).kuaishouCloudFulfillment,
parseTaskContext(workingTask).kuaishouCloudFulfillment,
)
if (!isKuaishouCloudMockTask(context.task)) {
assertBoundUidMatchesExpected(context.task, flowBeforeRedeem, {
if (!isKuaishouCloudMockTask(workingTask)) {
assertBoundUidMatchesExpected(workingTask, flowBeforeRedeem, {
errorCodePrefix: 'claim_kuaishou_cloud_redeem',
})
} else {
assertClaimExpectedUidReady(workingTask)
}
const lockedTask = await updateTaskStatusIfCurrent(context.task.id, TASK_STATUS.ROLE_CONFIRMED, {
const lockedTask = await updateTaskStatusIfCurrent(workingTask.id, TASK_STATUS.ROLE_CONFIRMED, {
task_status: TASK_STATUS.REDEEMING,
user_action_status: 'not_required',
last_error: '',
@@ -4,7 +4,7 @@ import type { TaskRow } from "../../../types/repository/rows.js";
import { createHttpError } from "../../../utils/http.js";
import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js";
import {
getClaimIdentityFromContext,
assertLewanAutoFulfillmentUidReady,
isClaimUidMatched,
normalizeClaimUid,
} from "../../claim/claim-identity.js";
@@ -36,7 +36,12 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
const flow = normalizeKuaishouCloudFlow(
input.flow || taskContext.kuaishouCloudFulfillment
);
const expectedUid = getClaimIdentityFromContext(taskContext).expectedUid;
// 新策略:lewan 自动发货必须有 expectedUid,旧单无 UID 不可静默回落
const expectedUid = assertLewanAutoFulfillmentUidReady(task, {
allowMockSkip: true,
errorCodePrefix,
});
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", {
@@ -62,76 +67,30 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
);
}
const confirmedRoleName = String(
flow.binding.roleName || flow.role.name || task.role_name || ""
).trim();
const confirmedRoleId = String(
flow.binding.roleId || flow.role.rid || task.role_id || ""
).trim();
const liveBoundUid = normalizeClaimUid(roleInfo.rid);
// 优先:声明 UID 与云端当前绑定角色一致
if (expectedUid) {
if (!isClaimUidMatched(expectedUid, liveBoundUid)) {
await createTaskEvent(
task.id,
"kuaishou_cloud_dispatch_uid_mismatch",
{
source,
vnId: flow.binding.vnId,
expectedUid,
cloudtentaclesRoleName: roleInfo.name,
cloudtentaclesRoleId: roleInfo.rid,
actor: input.actor || null,
},
now
);
throw createHttpError(
`cloudtentacles 当前绑定角色 ID${roleInfo.rid})与用户填写 UID${expectedUid})不一致,不能发货`,
{
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_uid_mismatch`,
}
);
}
} else {
// 兼容旧任务:无 expectedUid 时仍比对「确认角色」
const roleMatches = isSameKuaishouCloudRole(
if (expectedUid && !isClaimUidMatched(expectedUid, liveBoundUid)) {
await createTaskEvent(
task.id,
"kuaishou_cloud_dispatch_uid_mismatch",
{
name: confirmedRoleName,
rid: confirmedRoleId,
source,
vnId: flow.binding.vnId,
expectedUid,
cloudtentaclesRoleName: roleInfo.name,
cloudtentaclesRoleId: roleInfo.rid,
actor: input.actor || null,
},
{
name: roleInfo.name,
rid: roleInfo.rid,
}
now
);
if (!roleMatches) {
await createTaskEvent(
task.id,
"kuaishou_cloud_dispatch_role_mismatch",
{
source,
vnId: flow.binding.vnId,
confirmedRoleName,
confirmedRoleId,
cloudtentaclesRoleName: roleInfo.name,
cloudtentaclesRoleId: roleInfo.rid,
actor: input.actor || null,
},
now
);
throw createHttpError(
`cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`,
{
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_role_mismatch`,
}
);
}
throw createHttpError(
`cloudtentacles 当前绑定角色 ID${roleInfo.rid})与用户填写 UID${expectedUid})不一致,不能发货`,
{
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_uid_mismatch`,
}
);
}
const nextFlow = normalizeKuaishouCloudFlow({
@@ -191,62 +150,4 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
};
}
function isSameKuaishouCloudRole(
confirmedRole: { name: string; rid: string },
cloudtentaclesRole: { name: string; rid: string }
) {
if (confirmedRole.rid && cloudtentaclesRole.rid) {
return confirmedRole.rid === cloudtentaclesRole.rid;
}
const confirmedName = normalizeKuaishouCloudRoleNameForCompare(
confirmedRole.name
);
const cloudtentaclesName = normalizeKuaishouCloudRoleNameForCompare(
cloudtentaclesRole.name
);
if (!confirmedName || !cloudtentaclesName) {
return true;
}
return confirmedName === cloudtentaclesName;
}
function normalizeKuaishouCloudRoleNameForCompare(value: unknown) {
const name = String(value || "").trim();
if (!name) {
return "";
}
const parts = name
.split("-")
.map((part) => part.trim())
.filter(Boolean);
const devicePart = parts[0] || "";
const channelPart = parts[1] || "";
if (
parts.length >= 3 &&
isKuaishouCloudDevicePart(devicePart) &&
isKuaishouCloudChannelPart(channelPart)
) {
return normalizeTextForCompare(parts.slice(2).join("-"));
}
return normalizeTextForCompare(name);
}
function isKuaishouCloudDevicePart(value: string) {
return ["安卓", "android", "ios", "苹果"].includes(value.toLowerCase());
}
function isKuaishouCloudChannelPart(value: string) {
return ["qq", "微信", "vx", "wechat", "v"].includes(value.toLowerCase());
}
function normalizeTextForCompare(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "");
}
@@ -1,3 +1,11 @@
/**
* 内部短链服务。
*
* 注意(2026-07 起):
* - 领取主路径已统一为本站 `/#/claim/{token}`feifei 不再为 claim 生成 shortLink。
* - 本模块仅保留:历史 `/s/{code}` 解析、其它潜在内部用途。
* - 新业务不要再依赖 shortLink 作为对外交付链接。
*/
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
@@ -15,6 +23,7 @@ const SHORT_LINK_MAX_CREATE_ATTEMPTS = 5
const SHORT_LINK_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const DEFAULT_SHORT_LINK_TTL_HOURS = 24 * 30
/** @deprecated feifei claim 已不再使用;保留常量仅兼容历史数据 */
export const SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM = 'kuaishou_feifei_claim'
export type ShortLinkPayload = {