优化 UID 履约闸门、运营可见性与一键兑换
lewan 发货强制 expectedUid 并取消无 UID 旧路径回落;任务详情展示 UID 匹配态;领取页匹配后可一键兑换;补充 identity 单测并收口短链说明。
This commit is contained in:
@@ -21,7 +21,8 @@ test('任务状态机声明 kuaishou-lewan 主流程跳转', () => {
|
|||||||
test('任务状态机收敛领取和 91 查询的业务判断', () => {
|
test('任务状态机收敛领取和 91 查询的业务判断', () => {
|
||||||
assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), true)
|
assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), true)
|
||||||
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.ROLE_CONFIRMED), true)
|
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.ROLE_CONFIRMED), true)
|
||||||
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.WAITING_BINDING), false)
|
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.WAITING_BINDING), true)
|
||||||
|
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.LINK_GENERATED), false)
|
||||||
assert.equal(isClaimInactiveTaskStatus(TASK_STATUS.MANUAL_REVIEW), true)
|
assert.equal(isClaimInactiveTaskStatus(TASK_STATUS.MANUAL_REVIEW), true)
|
||||||
assert.equal(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true)
|
assert.equal(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -257,7 +257,12 @@ export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
|
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
|
||||||
return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED
|
const normalized = normalizeTaskStatus(status)
|
||||||
|
// 允许 WAITING_BINDING:UID 匹配后可一键确认+兑换
|
||||||
|
return (
|
||||||
|
normalized === TASK_STATUS.ROLE_CONFIRMED ||
|
||||||
|
normalized === TASK_STATUS.WAITING_BINDING
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canRegenerateClaimLinkStatus(status: unknown): boolean {
|
export function canRegenerateClaimLinkStatus(status: unknown): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
|
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
|
||||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||||
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
||||||
@@ -250,6 +251,14 @@ export async function getAdminTaskDetail(
|
|||||||
mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
|
mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
|
||||||
cloudSourceLabelMap,
|
cloudSourceLabelMap,
|
||||||
}) || mapKuaishouCloudTaskStateProjection(task, { 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(
|
const firstKuaishouIndustryVoucher = resolveKuaishouIndustryVoucherForTask(
|
||||||
task,
|
task,
|
||||||
taskContext,
|
taskContext,
|
||||||
@@ -316,6 +325,7 @@ export async function getAdminTaskDetail(
|
|||||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||||
},
|
},
|
||||||
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
|
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
|
||||||
|
claimIdentity,
|
||||||
kuaishouCloudFulfillment,
|
kuaishouCloudFulfillment,
|
||||||
kuaishouIndustryVoucher,
|
kuaishouIndustryVoucher,
|
||||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||||
|
|||||||
@@ -121,11 +121,13 @@ export function mapKuaishouCloudFulfillmentContext(
|
|||||||
bindProbeAt: binding.bindProbeAt || null,
|
bindProbeAt: binding.bindProbeAt || null,
|
||||||
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
|
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
|
||||||
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
|
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
|
||||||
|
roleName: String(binding.roleName || role.name || '').trim(),
|
||||||
|
roleId: String(binding.roleId || role.rid || '').trim(),
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
status: String(role.status || 'pending').trim() || 'pending',
|
status: String(role.status || 'pending').trim() || 'pending',
|
||||||
name: String(role.name || '').trim(),
|
name: String(role.name || binding.roleName || '').trim(),
|
||||||
rid: String(role.rid || '').trim(),
|
rid: String(role.rid || binding.roleId || '').trim(),
|
||||||
refreshedAt: role.refreshedAt || null,
|
refreshedAt: role.refreshedAt || null,
|
||||||
errorMessage: String(role.errorMessage || '').trim(),
|
errorMessage: String(role.errorMessage || '').trim(),
|
||||||
rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
|
rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendUidToUrl,
|
appendUidToUrl,
|
||||||
|
assertLewanAutoFulfillmentUidReady,
|
||||||
assertValidClaimUid,
|
assertValidClaimUid,
|
||||||
|
buildClaimIdentityAdminSummary,
|
||||||
isClaimUidMatched,
|
isClaimUidMatched,
|
||||||
normalizeClaimUid,
|
normalizeClaimUid,
|
||||||
} from './claim-identity.js'
|
} 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')
|
const next = appendUidToUrl('https://example.com/h5/bind?code=abc&uid=old', 'new-uid')
|
||||||
assert.equal(new URL(next).searchParams.get('uid'), '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 {
|
export function assertClaimExpectedUidReady(task: Partial<TaskRow> | null | undefined): string {
|
||||||
const expectedUid = getClaimIdentityFromTask(task).expectedUid
|
const expectedUid = getClaimIdentityFromTask(task).expectedUid
|
||||||
if (!expectedUid) {
|
if (!expectedUid) {
|
||||||
throw createHttpError('请先填写游戏 UID', {
|
throw createHttpError('请先通过领取页填写游戏 UID(旧单无 UID 不可自动履约)', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_uid_not_submitted',
|
errorCode: 'claim_uid_not_submitted',
|
||||||
})
|
})
|
||||||
@@ -99,6 +99,72 @@ export function assertClaimExpectedUidReady(task: Partial<TaskRow> | null | unde
|
|||||||
return expectedUid
|
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(
|
export function assertBoundUidMatchesExpected(
|
||||||
task: Partial<TaskRow> | null | undefined,
|
task: Partial<TaskRow> | null | undefined,
|
||||||
flowLike: unknown,
|
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)) {
|
if (isKuaishouCloudRedeemSettledStatus(currentStatus)) {
|
||||||
return getKuaishouCloudClaimDetail(token)
|
return getKuaishouCloudClaimDetail(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) {
|
if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) {
|
||||||
throw createHttpError('请先确认角色信息', {
|
throw createHttpError('当前状态不可兑换,请先完成绑定并匹配 UID', {
|
||||||
statusCode: 409,
|
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(
|
const flowBeforeRedeem = normalizeKuaishouCloudFlow(
|
||||||
parseTaskContext(context.task).kuaishouCloudFulfillment,
|
parseTaskContext(workingTask).kuaishouCloudFulfillment,
|
||||||
)
|
)
|
||||||
if (!isKuaishouCloudMockTask(context.task)) {
|
if (!isKuaishouCloudMockTask(workingTask)) {
|
||||||
assertBoundUidMatchesExpected(context.task, flowBeforeRedeem, {
|
assertBoundUidMatchesExpected(workingTask, flowBeforeRedeem, {
|
||||||
errorCodePrefix: 'claim_kuaishou_cloud_redeem',
|
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,
|
task_status: TASK_STATUS.REDEEMING,
|
||||||
user_action_status: 'not_required',
|
user_action_status: 'not_required',
|
||||||
last_error: '',
|
last_error: '',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { TaskRow } from "../../../types/repository/rows.js";
|
|||||||
import { createHttpError } from "../../../utils/http.js";
|
import { createHttpError } from "../../../utils/http.js";
|
||||||
import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||||
import {
|
import {
|
||||||
getClaimIdentityFromContext,
|
assertLewanAutoFulfillmentUidReady,
|
||||||
isClaimUidMatched,
|
isClaimUidMatched,
|
||||||
normalizeClaimUid,
|
normalizeClaimUid,
|
||||||
} from "../../claim/claim-identity.js";
|
} from "../../claim/claim-identity.js";
|
||||||
@@ -36,7 +36,12 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
|
|||||||
const flow = normalizeKuaishouCloudFlow(
|
const flow = normalizeKuaishouCloudFlow(
|
||||||
input.flow || taskContext.kuaishouCloudFulfillment
|
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) {
|
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
||||||
throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", {
|
throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", {
|
||||||
@@ -62,17 +67,9 @@ 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);
|
const liveBoundUid = normalizeClaimUid(roleInfo.rid);
|
||||||
|
|
||||||
// 优先:声明 UID 与云端当前绑定角色一致
|
if (expectedUid && !isClaimUidMatched(expectedUid, liveBoundUid)) {
|
||||||
if (expectedUid) {
|
|
||||||
if (!isClaimUidMatched(expectedUid, liveBoundUid)) {
|
|
||||||
await createTaskEvent(
|
await createTaskEvent(
|
||||||
task.id,
|
task.id,
|
||||||
"kuaishou_cloud_dispatch_uid_mismatch",
|
"kuaishou_cloud_dispatch_uid_mismatch",
|
||||||
@@ -95,44 +92,6 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 兼容旧任务:无 expectedUid 时仍比对「确认角色」
|
|
||||||
const roleMatches = isSameKuaishouCloudRole(
|
|
||||||
{
|
|
||||||
name: confirmedRoleName,
|
|
||||||
rid: confirmedRoleId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: roleInfo.name,
|
|
||||||
rid: roleInfo.rid,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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`,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextFlow = normalizeKuaishouCloudFlow({
|
const nextFlow = normalizeKuaishouCloudFlow({
|
||||||
...flow,
|
...flow,
|
||||||
@@ -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 crypto from 'node:crypto'
|
||||||
|
|
||||||
import { runtimeConfig } from '../../config/runtime.js'
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
@@ -15,6 +23,7 @@ const SHORT_LINK_MAX_CREATE_ATTEMPTS = 5
|
|||||||
const SHORT_LINK_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
const SHORT_LINK_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
const DEFAULT_SHORT_LINK_TTL_HOURS = 24 * 30
|
const DEFAULT_SHORT_LINK_TTL_HOURS = 24 * 30
|
||||||
|
|
||||||
|
/** @deprecated feifei claim 已不再使用;保留常量仅兼容历史数据 */
|
||||||
export const SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM = 'kuaishou_feifei_claim'
|
export const SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM = 'kuaishou_feifei_claim'
|
||||||
|
|
||||||
export type ShortLinkPayload = {
|
export type ShortLinkPayload = {
|
||||||
|
|||||||
@@ -681,13 +681,57 @@ function FulfillmentOverviewPanel({
|
|||||||
}) {
|
}) {
|
||||||
const executor = resolveTaskExecutorDisplay(detail.task.executorKey)
|
const executor = resolveTaskExecutorDisplay(detail.task.executorKey)
|
||||||
const steps = buildFulfillmentOverviewSteps(detail, claimUrl, claimLinkInvalid)
|
const steps = buildFulfillmentOverviewSteps(detail, claimUrl, claimLinkInvalid)
|
||||||
const currentBlocker = resolveTaskBlocker(detail.task, claimUrl, claimLinkInvalid)
|
const claimIdentity = detail.claimIdentity
|
||||||
|
const currentBlocker = resolveTaskBlocker(
|
||||||
|
detail.task,
|
||||||
|
claimUrl,
|
||||||
|
claimLinkInvalid,
|
||||||
|
claimIdentity,
|
||||||
|
)
|
||||||
|
const uidMatchTag =
|
||||||
|
claimIdentity?.uidMatched === true
|
||||||
|
? { color: 'success' as const, text: 'UID 已匹配' }
|
||||||
|
: claimIdentity?.uidMatched === false
|
||||||
|
? { color: 'error' as const, text: 'UID 不一致' }
|
||||||
|
: claimIdentity?.ready
|
||||||
|
? { color: 'processing' as const, text: '已填 UID' }
|
||||||
|
: { color: 'warning' as const, text: '未填 UID' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="履约链路总览"
|
title="履约链路总览"
|
||||||
extra={<Tag color={executor.color}>{executor.label}</Tag>}
|
extra={<Tag color={executor.color}>{executor.label}</Tag>}
|
||||||
>
|
>
|
||||||
|
<Descriptions
|
||||||
|
size="small"
|
||||||
|
column={{ xs: 1, sm: 2, md: 3 }}
|
||||||
|
className="platform-section-gap"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="填写 UID">
|
||||||
|
{claimIdentity?.expectedUid || (
|
||||||
|
<Typography.Text type="warning">未提交</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="绑定角色 ID">
|
||||||
|
{claimIdentity?.boundUid || detail.task.roleId || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="绑定角色名">
|
||||||
|
{claimIdentity?.boundRoleName || detail.task.roleName || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="匹配状态">
|
||||||
|
<Tag color={uidMatchTag.color}>{uidMatchTag.text}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="兼容模式">
|
||||||
|
{claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="说明" span={3}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{claimIdentity?.note || '暂无 UID 信息'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
<div className="fulfillment-overview-grid">
|
<div className="fulfillment-overview-grid">
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<div key={step.key} className="fulfillment-step-card">
|
<div key={step.key} className="fulfillment-step-card">
|
||||||
@@ -791,6 +835,7 @@ function resolveTaskBlocker(
|
|||||||
task: AdminTaskDetail['task'],
|
task: AdminTaskDetail['task'],
|
||||||
claimUrl: string,
|
claimUrl: string,
|
||||||
claimLinkInvalid: boolean,
|
claimLinkInvalid: boolean,
|
||||||
|
claimIdentity?: AdminTaskDetail['claimIdentity'],
|
||||||
): { level: 'success' | 'info' | 'warning' | 'error'; title: string; detail: string } {
|
): { level: 'success' | 'info' | 'warning' | 'error'; title: string; detail: string } {
|
||||||
if (['completed', 'redeemed', 'delivered'].includes(task.status) || task.deliveryStatus === 'delivered') {
|
if (['completed', 'redeemed', 'delivered'].includes(task.status) || task.deliveryStatus === 'delivered') {
|
||||||
return {
|
return {
|
||||||
@@ -808,6 +853,29 @@ function resolveTaskBlocker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.executorKey === 'kuaishou_ct_assisted' &&
|
||||||
|
claimIdentity &&
|
||||||
|
!claimIdentity.ready
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
level: 'warning',
|
||||||
|
title: '等待用户填写 UID',
|
||||||
|
detail: '旧单或新单均需用户打开领取页提交游戏 UID,否则禁止自动发货。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.executorKey === 'kuaishou_ct_assisted' &&
|
||||||
|
claimIdentity?.uidMatched === false
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
level: 'error',
|
||||||
|
title: 'UID 与绑定角色不一致',
|
||||||
|
detail: `填写 ${claimIdentity.expectedUid || '-'},绑定 ${claimIdentity.boundUid || '-'},请用户换绑或更正 UID。`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (claimLinkInvalid) {
|
if (claimLinkInvalid) {
|
||||||
return {
|
return {
|
||||||
level: 'warning',
|
level: 'warning',
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ function createClaimSnapshot(
|
|||||||
hasRedeemResult,
|
hasRedeemResult,
|
||||||
isRoleConfirmed,
|
isRoleConfirmed,
|
||||||
isFeifeiFlow,
|
isFeifeiFlow,
|
||||||
|
isUidMatched,
|
||||||
})
|
})
|
||||||
const progressText = resolveProgressText({
|
const progressText = resolveProgressText({
|
||||||
feifei,
|
feifei,
|
||||||
@@ -613,6 +614,7 @@ function resolveCurrentStep(options: {
|
|||||||
hasRedeemResult: boolean
|
hasRedeemResult: boolean
|
||||||
isRoleConfirmed: boolean
|
isRoleConfirmed: boolean
|
||||||
isFeifeiFlow: boolean
|
isFeifeiFlow: boolean
|
||||||
|
isUidMatched?: boolean
|
||||||
}) {
|
}) {
|
||||||
if (!options.hasExpectedUid) {
|
if (!options.hasExpectedUid) {
|
||||||
return 1
|
return 1
|
||||||
@@ -620,6 +622,10 @@ function resolveCurrentStep(options: {
|
|||||||
if (options.hasRedeemResult) {
|
if (options.hasRedeemResult) {
|
||||||
return 4
|
return 4
|
||||||
}
|
}
|
||||||
|
// UID 匹配后仍停留在绑定步,提供一键兑换;已 ROLE_CONFIRMED 走确认页
|
||||||
|
if (!options.isFeifeiFlow && options.isRoleConfirmed && !options.isUidMatched) {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
if (!options.isFeifeiFlow && options.isRoleConfirmed) {
|
if (!options.isFeifeiFlow && options.isRoleConfirmed) {
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
@@ -857,10 +863,12 @@ function KuaishouCloudClaimSteps({
|
|||||||
refreshingRole={refreshingRole}
|
refreshingRole={refreshingRole}
|
||||||
confirmingRole={confirmingRole}
|
confirmingRole={confirmingRole}
|
||||||
rebindingRole={rebindingRole}
|
rebindingRole={rebindingRole}
|
||||||
|
redeeming={redeeming}
|
||||||
onOpenBindUrl={onOpenBindUrl}
|
onOpenBindUrl={onOpenBindUrl}
|
||||||
onRefreshRole={onRefreshRole}
|
onRefreshRole={onRefreshRole}
|
||||||
onRebindRole={onRebindRole}
|
onRebindRole={onRebindRole}
|
||||||
onConfirmRole={onConfirmRole}
|
onConfirmRole={onConfirmRole}
|
||||||
|
onConfirmRedeem={onConfirmRedeem}
|
||||||
uidInput={uidInput}
|
uidInput={uidInput}
|
||||||
submittingUid={submittingUid}
|
submittingUid={submittingUid}
|
||||||
onUidChange={onUidChange}
|
onUidChange={onUidChange}
|
||||||
@@ -875,10 +883,12 @@ function ClaimBindingStep({
|
|||||||
refreshingRole,
|
refreshingRole,
|
||||||
confirmingRole,
|
confirmingRole,
|
||||||
rebindingRole,
|
rebindingRole,
|
||||||
|
redeeming,
|
||||||
onOpenBindUrl,
|
onOpenBindUrl,
|
||||||
onRefreshRole,
|
onRefreshRole,
|
||||||
onRebindRole,
|
onRebindRole,
|
||||||
onConfirmRole,
|
onConfirmRole,
|
||||||
|
onConfirmRedeem,
|
||||||
uidInput,
|
uidInput,
|
||||||
submittingUid,
|
submittingUid,
|
||||||
onUidChange,
|
onUidChange,
|
||||||
@@ -889,10 +899,12 @@ function ClaimBindingStep({
|
|||||||
refreshingRole: boolean
|
refreshingRole: boolean
|
||||||
confirmingRole: boolean
|
confirmingRole: boolean
|
||||||
rebindingRole: boolean
|
rebindingRole: boolean
|
||||||
|
redeeming: boolean
|
||||||
onOpenBindUrl: (useCurrentPage?: boolean) => void
|
onOpenBindUrl: (useCurrentPage?: boolean) => void
|
||||||
onRefreshRole: () => void
|
onRefreshRole: () => void
|
||||||
onRebindRole: () => void
|
onRebindRole: () => void
|
||||||
onConfirmRole: () => void
|
onConfirmRole: () => void
|
||||||
|
onConfirmRedeem: () => void
|
||||||
uidInput: string
|
uidInput: string
|
||||||
submittingUid: boolean
|
submittingUid: boolean
|
||||||
onUidChange: (value: string) => void
|
onUidChange: (value: string) => void
|
||||||
@@ -962,11 +974,24 @@ function ClaimBindingStep({
|
|||||||
size="large"
|
size="large"
|
||||||
icon={<SwapOutlined />}
|
icon={<SwapOutlined />}
|
||||||
loading={rebindingRole}
|
loading={rebindingRole}
|
||||||
disabled={confirmingRole}
|
disabled={confirmingRole || redeeming}
|
||||||
onClick={onRebindRole}
|
onClick={onRebindRole}
|
||||||
>
|
>
|
||||||
换绑角色
|
换绑角色
|
||||||
</Button>
|
</Button>
|
||||||
|
{snapshot.isUidMatched ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
danger
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={redeeming}
|
||||||
|
disabled={rebindingRole}
|
||||||
|
onClick={onConfirmRedeem}
|
||||||
|
>
|
||||||
|
UID 已匹配,确认兑换
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Tooltip title={snapshot.confirmRoleDisabledReason || undefined}>
|
<Tooltip title={snapshot.confirmRoleDisabledReason || undefined}>
|
||||||
<span className="claim-action-tooltip-wrap">
|
<span className="claim-action-tooltip-wrap">
|
||||||
<Button
|
<Button
|
||||||
@@ -977,10 +1002,11 @@ function ClaimBindingStep({
|
|||||||
disabled={Boolean(snapshot.confirmRoleDisabledReason)}
|
disabled={Boolean(snapshot.confirmRoleDisabledReason)}
|
||||||
onClick={onConfirmRole}
|
onClick={onConfirmRole}
|
||||||
>
|
>
|
||||||
UID 已匹配,下一步
|
刷新后重试匹配
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div className="claim-meta-footer">
|
<div className="claim-meta-footer">
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ export interface AdminTaskDetail {
|
|||||||
resultMessage: string
|
resultMessage: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
claimIdentity: null | {
|
||||||
|
expectedUid: string
|
||||||
|
submittedAt: string | null
|
||||||
|
source: string
|
||||||
|
ready: boolean
|
||||||
|
boundUid: string
|
||||||
|
boundRoleName: string
|
||||||
|
uidMatched: boolean | null
|
||||||
|
compatibilityMode: 'uid' | 'legacy_no_uid'
|
||||||
|
note: string
|
||||||
|
}
|
||||||
kuaishouCloudFulfillment: null | {
|
kuaishouCloudFulfillment: null | {
|
||||||
flowType: string
|
flowType: string
|
||||||
configId: string
|
configId: string
|
||||||
@@ -135,6 +146,8 @@ export interface AdminTaskDetail {
|
|||||||
bindProbeAt: string | null
|
bindProbeAt: string | null
|
||||||
bindProbeStatus: string
|
bindProbeStatus: string
|
||||||
bindProbeMessage: string
|
bindProbeMessage: string
|
||||||
|
roleName?: string
|
||||||
|
roleId?: string
|
||||||
}
|
}
|
||||||
role: {
|
role: {
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# UID 领取闸门与旧单兼容
|
||||||
|
|
||||||
|
## 规则(现行)
|
||||||
|
|
||||||
|
1. **所有 lewan 自动发货**必须存在 `context.claimIdentity.expectedUid`。
|
||||||
|
2. 绑定角色 ID(`rid`)必须与 expectedUid 一致,否则禁止 confirm / redeem / dispatch。
|
||||||
|
3. **旧单无 UID**:用户打开领取页补填 UID 后继续;后台发货也会被拦截并提示补 UID。
|
||||||
|
4. **不再**使用「仅确认角色、无 UID」的静默兼容路径。
|
||||||
|
5. feifei:用户填 UID 后拼入 H5;对外仍只返回本站 claimUrl。
|
||||||
|
|
||||||
|
## 运营可见
|
||||||
|
|
||||||
|
任务详情「履约链路总览」展示:
|
||||||
|
|
||||||
|
- 填写 UID
|
||||||
|
- 绑定角色 ID / 名
|
||||||
|
- 匹配状态
|
||||||
|
- 兼容模式(UID 闸门 / 旧单无 UID)
|
||||||
|
|
||||||
|
## 用户领取
|
||||||
|
|
||||||
|
- Step1:填 UID
|
||||||
|
- Step2:绑定;匹配成功后可 **一键确认兑换**(无需再点「下一步」)
|
||||||
|
- mock 任务仍可不调真实 CT
|
||||||
|
|
||||||
|
## 短链
|
||||||
|
|
||||||
|
feifei claim 不再生成 shortLink;`/s/{code}` 仅解析历史链接。
|
||||||
Reference in New Issue
Block a user