From 8de56b03548ad2659b946783d22a2be4ccca6673 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 10 Jul 2026 13:51:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20UID=20=E5=B1=A5=E7=BA=A6?= =?UTF-8?q?=E9=97=B8=E9=97=A8=E3=80=81=E8=BF=90=E8=90=A5=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E4=B8=8E=E4=B8=80=E9=94=AE=E5=85=91=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lewan 发货强制 expectedUid 并取消无 UID 旧路径回落;任务详情展示 UID 匹配态;领取页匹配后可一键兑换;补充 identity 单测并收口短链说明。 --- apps/backend/src/domain/task-status.test.ts | 3 +- apps/backend/src/domain/task-status.ts | 7 +- .../src/services/admin/admin-read-service.ts | 10 ++ .../admin/admin-read-shared-helpers.ts | 6 +- .../src/services/claim/claim-identity.test.ts | 60 +++++++ .../src/services/claim/claim-identity.ts | 68 +++++++- .../claim/kuaishou-cloud-claim-service.ts | 56 ++++++- .../kuaishou-cloud/dispatch-role-sync.ts | 149 +++--------------- .../short-links/short-link-service.ts | 9 ++ .../src/pages/admin/AdminTaskDetailPage.tsx | 70 +++++++- apps/frontend/src/pages/claim/ClaimPage.tsx | 56 +++++-- apps/frontend/src/types/admin/tasks.ts | 13 ++ docs/履约配置/UID领取与旧单兼容.md | 28 ++++ 13 files changed, 383 insertions(+), 152 deletions(-) create mode 100644 docs/履约配置/UID领取与旧单兼容.md diff --git a/apps/backend/src/domain/task-status.test.ts b/apps/backend/src/domain/task-status.test.ts index 077185d0..f5448df5 100644 --- a/apps/backend/src/domain/task-status.test.ts +++ b/apps/backend/src/domain/task-status.test.ts @@ -21,7 +21,8 @@ test('任务状态机声明 kuaishou-lewan 主流程跳转', () => { test('任务状态机收敛领取和 91 查询的业务判断', () => { assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), 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(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true) }) diff --git a/apps/backend/src/domain/task-status.ts b/apps/backend/src/domain/task-status.ts index 50683d4a..d27a3499 100644 --- a/apps/backend/src/domain/task-status.ts +++ b/apps/backend/src/domain/task-status.ts @@ -257,7 +257,12 @@ export function isKuaishouCloudRedeemSettledStatus(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 { diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index d69b4a81..a2615c66 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -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), diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 46a137e8..0d5ff8f0 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -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, diff --git a/apps/backend/src/services/claim/claim-identity.test.ts b/apps/backend/src/services/claim/claim-identity.test.ts index c2e5fb82..faa68fbd 100644 --- a/apps/backend/src/services/claim/claim-identity.test.ts +++ b/apps/backend/src/services/claim/claim-identity.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/claim/claim-identity.ts b/apps/backend/src/services/claim/claim-identity.ts index 687a3f64..5e95c486 100644 --- a/apps/backend/src/services/claim/claim-identity.ts +++ b/apps/backend/src/services/claim/claim-identity.ts @@ -91,7 +91,7 @@ export function resolveBoundRoleUid(flowLike: unknown): string { export function assertClaimExpectedUidReady(task: Partial | 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 | null | unde return expectedUid } +/** + * lewan 自动发货前:必须已有 expectedUid(不再回落「仅确认角色」旧路径)。 + * mock 任务可跳过。 + */ +export function assertLewanAutoFulfillmentUidReady( + task: Partial | 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 | null | undefined, flowLike: unknown, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 1aa5b798..d6171a6c 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -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: '', diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts index 2b7b4772..9dc6582f 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts @@ -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, ""); -} diff --git a/apps/backend/src/services/short-links/short-link-service.ts b/apps/backend/src/services/short-links/short-link-service.ts index 6b4c5f95..f66d3e3d 100644 --- a/apps/backend/src/services/short-links/short-link-service.ts +++ b/apps/backend/src/services/short-links/short-link-service.ts @@ -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 = { diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index c8d41cb7..9f1c7b91 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -681,13 +681,57 @@ function FulfillmentOverviewPanel({ }) { const executor = resolveTaskExecutorDisplay(detail.task.executorKey) 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 ( {executor.label}} > + + + {claimIdentity?.expectedUid || ( + 未提交 + )} + + + {claimIdentity?.boundUid || detail.task.roleId || '-'} + + + {claimIdentity?.boundRoleName || detail.task.roleName || '-'} + + + {uidMatchTag.text} + + + {claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'} + + + + {claimIdentity?.note || '暂无 UID 信息'} + + + +
{steps.map((step) => (
@@ -791,6 +835,7 @@ function resolveTaskBlocker( task: AdminTaskDetail['task'], claimUrl: string, claimLinkInvalid: boolean, + claimIdentity?: AdminTaskDetail['claimIdentity'], ): { level: 'success' | 'info' | 'warning' | 'error'; title: string; detail: string } { if (['completed', 'redeemed', 'delivered'].includes(task.status) || task.deliveryStatus === 'delivered') { 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) { return { level: 'warning', diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 4fa07ee5..5a2abaf6 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -521,6 +521,7 @@ function createClaimSnapshot( hasRedeemResult, isRoleConfirmed, isFeifeiFlow, + isUidMatched, }) const progressText = resolveProgressText({ feifei, @@ -613,6 +614,7 @@ function resolveCurrentStep(options: { hasRedeemResult: boolean isRoleConfirmed: boolean isFeifeiFlow: boolean + isUidMatched?: boolean }) { if (!options.hasExpectedUid) { return 1 @@ -620,6 +622,10 @@ function resolveCurrentStep(options: { if (options.hasRedeemResult) { return 4 } + // UID 匹配后仍停留在绑定步,提供一键兑换;已 ROLE_CONFIRMED 走确认页 + if (!options.isFeifeiFlow && options.isRoleConfirmed && !options.isUidMatched) { + return 3 + } if (!options.isFeifeiFlow && options.isRoleConfirmed) { return 3 } @@ -857,10 +863,12 @@ function KuaishouCloudClaimSteps({ refreshingRole={refreshingRole} confirmingRole={confirmingRole} rebindingRole={rebindingRole} + redeeming={redeeming} onOpenBindUrl={onOpenBindUrl} onRefreshRole={onRefreshRole} onRebindRole={onRebindRole} onConfirmRole={onConfirmRole} + onConfirmRedeem={onConfirmRedeem} uidInput={uidInput} submittingUid={submittingUid} onUidChange={onUidChange} @@ -875,10 +883,12 @@ function ClaimBindingStep({ refreshingRole, confirmingRole, rebindingRole, + redeeming, onOpenBindUrl, onRefreshRole, onRebindRole, onConfirmRole, + onConfirmRedeem, uidInput, submittingUid, onUidChange, @@ -889,10 +899,12 @@ function ClaimBindingStep({ refreshingRole: boolean confirmingRole: boolean rebindingRole: boolean + redeeming: boolean onOpenBindUrl: (useCurrentPage?: boolean) => void onRefreshRole: () => void onRebindRole: () => void onConfirmRole: () => void + onConfirmRedeem: () => void uidInput: string submittingUid: boolean onUidChange: (value: string) => void @@ -962,25 +974,39 @@ function ClaimBindingStep({ size="large" icon={} loading={rebindingRole} - disabled={confirmingRole} + disabled={confirmingRole || redeeming} onClick={onRebindRole} > 换绑角色 - - - - - + {snapshot.isUidMatched ? ( + + ) : ( + + + + + + )}
diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 23b58c7a..9e9b84db 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -105,6 +105,17 @@ export interface AdminTaskDetail { 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 | { flowType: string configId: string @@ -135,6 +146,8 @@ export interface AdminTaskDetail { bindProbeAt: string | null bindProbeStatus: string bindProbeMessage: string + roleName?: string + roleId?: string } role: { status: string diff --git a/docs/履约配置/UID领取与旧单兼容.md b/docs/履约配置/UID领取与旧单兼容.md new file mode 100644 index 00000000..d484bc38 --- /dev/null +++ b/docs/履约配置/UID领取与旧单兼容.md @@ -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}` 仅解析历史链接。