diff --git a/apps/backend/src/services/claim/claim-identity.test.ts b/apps/backend/src/services/claim/claim-identity.test.ts index faa68fbd..adbc5c46 100644 --- a/apps/backend/src/services/claim/claim-identity.test.ts +++ b/apps/backend/src/services/claim/claim-identity.test.ts @@ -90,6 +90,7 @@ test('buildClaimIdentityAdminSummary reports match state', () => { assert.equal(summary.uidMatched, true) assert.equal(summary.compatibilityMode, 'uid') assert.equal(summary.boundUid, '10001') + assert.equal(summary.displaySource, 'live') }) test('buildClaimIdentityAdminSummary marks legacy without uid', () => { @@ -97,4 +98,36 @@ test('buildClaimIdentityAdminSummary marks legacy without uid', () => { assert.equal(summary.ready, false) assert.equal(summary.compatibilityMode, 'legacy_no_uid') assert.equal(summary.uidMatched, null) + assert.equal(summary.displaySource, 'live') +}) + +test('buildClaimIdentityAdminSummary prefers shipped snapshot after dispatch pollution', () => { + const summary = buildClaimIdentityAdminSummary( + { + claimIdentity: { expectedUid: '3781015014', submittedAt: 't', source: 'claim_page' }, + }, + { + flowLike: { + dispatch: { status: 'success', dispatchAt: '2026-07-16T06:25:13.000Z' }, + shipped: { + expectedUid: '3781015014', + roleId: '3781015014', + roleName: 'l迈', + vnId: 189363, + vnPhone: '18641685924', + dispatchedAt: '2026-07-16T06:25:13.000Z', + }, + // 发货后换号污染:当前绑定已变成别的角色 + binding: { roleId: '3805904834', roleName: '他爆炸' }, + role: { rid: '3805904834', name: '他爆炸' }, + }, + }, + ) + assert.equal(summary.displaySource, 'shipped') + assert.equal(summary.boundUid, '3781015014') + assert.equal(summary.boundRoleName, 'l迈') + assert.equal(summary.uidMatched, true) + assert.equal(summary.shippedUid, '3781015014') + assert.match(summary.note, /已发货/) + assert.match(summary.note, /当前虚拟号角色已变化/) }) diff --git a/apps/backend/src/services/claim/claim-identity.ts b/apps/backend/src/services/claim/claim-identity.ts index 92e4909f..e771b752 100644 --- a/apps/backend/src/services/claim/claim-identity.ts +++ b/apps/backend/src/services/claim/claim-identity.ts @@ -140,15 +140,61 @@ export function buildClaimIdentityAdminSummary( options: { flowLike?: unknown; taskRoleId?: unknown; taskRoleName?: unknown } = {}, ): ClaimIdentityAdminSummary { 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( + const shipped = isPlainObject(flow.shipped) ? flow.shipped : null + const dispatch = isPlainObject(flow.dispatch) ? flow.dispatch : {} + const shippedUid = normalizeClaimUid(shipped?.roleId || shipped?.expectedUid || '') + const shippedRoleName = String(shipped?.roleName || '').trim() + const shippedAt = shipped?.dispatchedAt ? String(shipped.dispatchedAt) : null + const dispatchSucceeded = String(dispatch.status || '').trim() === 'success' + // 已发货优先用快照,避免 claim 轮询换号后误报 UID 不一致 + const useShipped = Boolean(shippedUid && (dispatchSucceeded || shippedAt)) + const liveBoundUid = + resolveBoundRoleUid(options.flowLike) || normalizeClaimUid(options.taskRoleId) + const liveBoundRoleName = String( role.name || binding.roleName || options.taskRoleName || '', ).trim() + const boundUid = useShipped ? shippedUid : liveBoundUid + const boundRoleName = useShipped + ? shippedRoleName || liveBoundRoleName + : liveBoundRoleName const ready = Boolean(identity.expectedUid) const uidMatched = ready && boundUid ? isClaimUidMatched(identity.expectedUid, boundUid) : null + const liveMismatched = + useShipped && + liveBoundUid && + shippedUid && + !isClaimUidMatched(shippedUid, liveBoundUid) + + let note = '旧单或未提交 UID:用户须先打开领取页填写 UID,否则禁止自动发货' + if (ready) { + if (useShipped) { + if (uidMatched === true) { + note = liveMismatched + ? `已发货:道具发至 UID ${shippedUid}${shippedRoleName ? `(${shippedRoleName})` : ''};当前虚拟号角色已变化,请勿以当前绑定为准` + : `已发货:道具发至 UID ${shippedUid}${shippedRoleName ? `(${shippedRoleName})` : ''},与填写 UID 一致` + } else if (uidMatched === false) { + note = `已发货快照角色 ID(${boundUid})与填写 UID(${identity.expectedUid})不一致,请核对事件日志` + } else { + note = '已发货,但快照缺少角色 ID' + } + } else if (dispatchSucceeded) { + note = + uidMatched === true + ? '已发货:当前绑定角色与填写 UID 一致(无发货快照,建议以事件日志为准)' + : uidMatched === false + ? '已发货,但当前绑定角色与填写 UID 不一致(可能为发货后换号污染;请核对 dispatch 事件中的 roleId)' + : '已发货,等待核对绑定角色' + } else if (uidMatched === true) { + note = 'UID 已匹配,可继续履约' + } else if (uidMatched === false) { + note = '绑定角色 ID 与填写 UID 不一致' + } else { + note = '已填 UID,等待绑定识别' + } + } return { expectedUid: identity.expectedUid, @@ -159,13 +205,11 @@ export function buildClaimIdentityAdminSummary( boundRoleName, uidMatched, compatibilityMode: ready ? ('uid' as const) : ('legacy_no_uid' as const), - note: ready - ? uidMatched === true - ? 'UID 已匹配,可继续履约' - : uidMatched === false - ? '绑定角色 ID 与填写 UID 不一致' - : '已填 UID,等待绑定识别' - : '旧单或未提交 UID:用户须先打开领取页填写 UID,否则禁止自动发货', + note, + displaySource: useShipped ? 'shipped' : 'live', + shippedUid, + shippedRoleName, + shippedAt, } } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index 80f0027d..b2f1adbe 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -2,13 +2,21 @@ import { prepareFulfillmentBinding, refreshFulfillmentRole, } from '../fulfillment/executors/registry.js' -import { normalizeKuaishouCloudFlow } from '../fulfillment/kuaishou-cloud/index.js' +import { + isKuaishouCloudBindingMutationFrozen, + normalizeKuaishouCloudFlow, +} from '../fulfillment/kuaishou-cloud/index.js' import type { TaskRow } from '../../types/repository/rows.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' export async function syncKuaishouCloudRoleInfo(task: TaskRow) { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) + // 已发货/兑换中:禁止 claim 轮询换号或覆盖角色(脏单 status 可能已回退 waiting_binding) + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + return task + } + if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) { // ticket verified 或绑定尚未 ready:补 prepare(覆盖提前核销后未取号场景) if ( diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts new file mode 100644 index 00000000..b5b8cae1 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { TASK_STATUS } from '../../../domain/task-status.js' +import { + buildKuaishouCloudShippedSnapshot, + isKuaishouCloudBindingMutationFrozen, + isKuaishouCloudDispatchSucceeded, + normalizeKuaishouCloudFlow, +} from './domain.js' + +test('isKuaishouCloudDispatchSucceeded 识别 dispatch.success', () => { + assert.equal( + isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'success' } }), + true, + ) + assert.equal( + isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'pending' } }), + false, + ) +}) + +test('isKuaishouCloudBindingMutationFrozen:dispatch.success 即使 status=waiting_binding 也冻结', () => { + assert.equal( + isKuaishouCloudBindingMutationFrozen( + { task_status: TASK_STATUS.WAITING_BINDING }, + { dispatch: { status: 'success' } }, + ), + true, + ) +}) + +test('isKuaishouCloudBindingMutationFrozen:兑换中/已完成冻结', () => { + assert.equal( + isKuaishouCloudBindingMutationFrozen( + { task_status: TASK_STATUS.REDEEMING }, + { dispatch: { status: 'pending' } }, + ), + true, + ) + assert.equal( + isKuaishouCloudBindingMutationFrozen( + { task_status: TASK_STATUS.COMPLETED }, + { dispatch: { status: 'pending' } }, + ), + true, + ) +}) + +test('isKuaishouCloudBindingMutationFrozen:待绑定未发货不冻结', () => { + assert.equal( + isKuaishouCloudBindingMutationFrozen( + { task_status: TASK_STATUS.WAITING_BINDING }, + { dispatch: { status: 'pending' } }, + ), + false, + ) +}) + +test('normalizeKuaishouCloudFlow 保留 shipped 快照', () => { + const flow = normalizeKuaishouCloudFlow({ + dispatch: { status: 'success' }, + shipped: { + expectedUid: '3781015014', + roleId: '3781015014', + roleName: 'l迈', + vnId: 189363, + vnPhone: '18641685924', + vnKey: '1', + dispatchedAt: '2026-07-16T06:25:13.000Z', + }, + binding: { roleId: '3805904834', roleName: '他爆炸' }, + }) + assert.equal(flow.shipped?.roleId, '3781015014') + assert.equal(flow.shipped?.expectedUid, '3781015014') + assert.equal(flow.binding.roleId, '3805904834') +}) + +test('buildKuaishouCloudShippedSnapshot 固化发货身份', () => { + const snapshot = buildKuaishouCloudShippedSnapshot({ + expectedUid: '3781015014', + roleId: '3781015014', + roleName: '安卓-微信-l迈', + vnId: 189363, + vnPhone: '18641685924', + dispatchedAt: 'now', + }) + assert.deepEqual(snapshot, { + expectedUid: '3781015014', + roleId: '3781015014', + roleName: '安卓-微信-l迈', + vnId: 189363, + vnPhone: '18641685924', + vnKey: '1', + dispatchedAt: 'now', + }) +}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts index 4d902e15..5c07a580 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts @@ -4,7 +4,9 @@ import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' import type { TaskRow } from '../../../types/repository/rows.js' +import { getClaimIdentityFromTask } from '../../claim/claim-identity.js' import { + buildKuaishouCloudShippedSnapshot, isIndustryEVoucherTask, isKuaishouCloudTask, maskCode, @@ -148,6 +150,17 @@ export async function dispatchKuaishouCloudFulfillmentTask( 'cloudtentacles 发货成功', ).trim() + const claimIdentity = getClaimIdentityFromTask(synced.task || task) + const shippedSnapshot = buildKuaishouCloudShippedSnapshot({ + expectedUid: claimIdentity.expectedUid, + roleId: syncedFlow.binding.roleId || syncedFlow.role.rid, + roleName: syncedFlow.binding.roleName || syncedFlow.role.name, + vnId: syncedFlow.binding.vnId, + vnPhone: syncedFlow.binding.vnPhone, + vnKey: syncedFlow.binding.vnKey, + dispatchedAt: now, + }) + const nextContext = { ...syncedTaskContext, kuaishouCloudFulfillment: { @@ -169,6 +182,8 @@ export async function dispatchKuaishouCloudFulfillmentTask( note: dispatchSummary, items: dispatchResults, }, + // 冻结发货身份:后续 claim 轮询/过期换号不得改写展示与匹配依据 + shipped: syncedFlow.shipped || shippedSnapshot, purchase: { ...syncedFlow.purchase, usedKnapsack: stockResult.usedKnapsack, @@ -209,11 +224,15 @@ export async function dispatchKuaishouCloudFulfillmentTask( skuId: syncedFlow.binding.skuId, vnId: syncedFlow.binding.vnId, vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), + expectedUid: shippedSnapshot.expectedUid, + roleId: shippedSnapshot.roleId, + roleName: shippedSnapshot.roleName, sendType: firstDispatchResult.sendType, note: dispatchSummary, deliveryItems, dispatchResults, stockResult, + shipped: shippedSnapshot, actor, }, now, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index e85d484d..5a8bb100 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -1,6 +1,11 @@ import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js' -import { asJsonObject, type JsonObject } from '../../../types/json.js' +import { + isTaskFinalStatus, + normalizeTaskStatus, + TASK_STATUS, +} from '../../../domain/task-status.js' +import { asJsonObject, type JsonObject } from '../../../types/json.js' export type { JsonObject } @@ -91,6 +96,19 @@ export type KuaishouCloudFlow = { note: string items: unknown[] } + /** + * 发货成功瞬间冻结的身份快照。 + * claim 轮询 / 过期换号不得覆盖;后台 UID 匹配以本快照为准。 + */ + shipped: { + expectedUid: string + roleId: string + roleName: string + vnId: number + vnPhone: string + vnKey: string + dispatchedAt: string | null + } | null returnNumber: { status: string returnedAt: unknown @@ -125,6 +143,7 @@ export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow { const consume = asJsonObject(source.consume) const ticket = asJsonObject(source.ticket) const rebind = asJsonObject(source.rebind) + const shippedSource = asJsonObject(source.shipped) const deliveryItems = normalizeDeliveryItems(source, binding) const roleName = String(role.name || binding.roleName || '').trim() @@ -211,6 +230,7 @@ export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow { note: String(dispatch.note || '').trim(), items: Array.isArray(dispatch.items) ? dispatch.items : [], }, + shipped: normalizeKuaishouCloudShippedSnapshot(shippedSource), returnNumber: { status: String(returnNumber.status || 'pending').trim() || 'pending', returnedAt: returnNumber.returnedAt || null, @@ -238,6 +258,85 @@ export function hasKuaishouCloudDefaultRoleSnapshot(flowLike: unknown) { return Boolean(flow.role.defaultName || flow.role.defaultRid) } +export type KuaishouCloudShippedSnapshot = NonNullable + +export function normalizeKuaishouCloudShippedSnapshot( + value: unknown, +): KuaishouCloudShippedSnapshot | null { + const source = asJsonObject(value) + const roleId = String(source.roleId || source.rid || '').trim() + const expectedUid = String(source.expectedUid || '').trim() + const vnId = Number(source.vnId || 0) || 0 + const dispatchedAt = source.dispatchedAt ? String(source.dispatchedAt) : null + + // 至少要有发货角色或 UID 之一,否则视为无快照 + if (!roleId && !expectedUid && !vnId && !dispatchedAt) { + return null + } + + return { + expectedUid, + roleId, + roleName: String(source.roleName || source.name || '').trim(), + vnId, + vnPhone: String(source.vnPhone || '').trim(), + vnKey: String(source.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, + dispatchedAt, + } +} + +export function isKuaishouCloudDispatchSucceeded(flowLike: unknown): boolean { + const flow = normalizeKuaishouCloudFlow(flowLike) + return String(flow.dispatch.status || '').trim() === 'success' +} + +/** + * 发货后禁止再改绑定资源/角色(claim 轮询换号、刷角色、过期重取等)。 + * 以 dispatch.success 为准(兼容状态机被污染回 waiting_binding 的脏单)。 + */ +export function isKuaishouCloudBindingMutationFrozen( + task: { task_status?: unknown } | null | undefined, + flowLike?: unknown, +): boolean { + const status = normalizeTaskStatus(task?.task_status) + if ( + status === TASK_STATUS.REDEEMING || + status === TASK_STATUS.DISPATCHED_PENDING_RETURN || + status === TASK_STATUS.COMPLETED || + status === TASK_STATUS.REDEEMED || + isTaskFinalStatus(status) + ) { + return true + } + + if (flowLike !== undefined) { + return isKuaishouCloudDispatchSucceeded(flowLike) + } + + return false +} + +export function buildKuaishouCloudShippedSnapshot(input: { + expectedUid?: unknown + roleId?: unknown + roleName?: unknown + vnId?: unknown + vnPhone?: unknown + vnKey?: unknown + dispatchedAt?: unknown +}): KuaishouCloudShippedSnapshot { + return { + expectedUid: String(input.expectedUid || '').trim(), + roleId: String(input.roleId || '').trim(), + roleName: String(input.roleName || '').trim(), + vnId: Number(input.vnId || 0) || 0, + vnPhone: String(input.vnPhone || '').trim(), + vnKey: + String(input.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, + dispatchedAt: input.dispatchedAt ? String(input.dispatchedAt) : null, + } +} + /** * 是否已有可展示的绑定角色信息(诊断用)。 * diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index 35db4be4..4062e0bc 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -10,6 +10,10 @@ export { isKuaishouCloudTask, hasKuaishouCloudCustomerRole, hasKuaishouCloudDefaultRoleSnapshot, + isKuaishouCloudDispatchSucceeded, + isKuaishouCloudBindingMutationFrozen, + buildKuaishouCloudShippedSnapshot, + normalizeKuaishouCloudShippedSnapshot, maskCode, maskPhone, normalizeKuaishouCloudFlow, @@ -20,6 +24,7 @@ export { export type { KuaishouCloudDeliveryItem, KuaishouCloudFlow, + KuaishouCloudShippedSnapshot, } from './domain.js' export { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' export { ensureTaskClaimLink } from './ensure-claim-link.js' diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts index aa2218ff..318c7340 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -14,6 +14,7 @@ import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js import { KUAISHOU_CLOUD_FIXED_VN_KEY, isKuaishouCloudBindUrlFresh, + isKuaishouCloudBindingMutationFrozen, isKuaishouCloudTask, maskPhone, normalizeKuaishouCloudFlow, @@ -65,6 +66,16 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options const claimLinkState = await ensureTaskClaimLink(task) const source = String(options.source || 'system').trim() || 'system' + // 已发货:禁止 prepare / 过期换号把状态打回 waiting_binding + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + return { + task, + claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), + token: String(task.primary_claim_token || task.claim_token || ''), + flow, + } + } + if ( !force && flow.binding.prepareStatus === 'ready' && @@ -373,6 +384,13 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js const allowSkipBack = options.allowSkipBack !== false const preferReuseVn = options.preferReuseVn !== false + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + throw createHttpError('当前任务已经发货,不能刷新或更换绑定链接', { + statusCode: 409, + errorCode: 'kuaishou_cloud_bind_url_refresh_after_dispatch_forbidden', + }) + } + if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { statusCode: 409, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts index 0282b858..c72b1a80 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts @@ -8,6 +8,7 @@ import { nowIso } from '../../../utils/time.js' import { TASK_STATUS } from '../../../domain/task-status.js' import { isKuaishouCloudBindUrlFresh, + isKuaishouCloudBindingMutationFrozen, isKuaishouCloudTask, normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, @@ -31,6 +32,15 @@ export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: Json const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + // 已发货:禁止 probe 触发过期换号 / 覆盖角色 + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + return { + task, + flow, + probe: null as null, + } + } + if (!flow.binding.bindUrl) { return { task, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts index 4c5411d2..6c12fc34 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts @@ -4,6 +4,7 @@ import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtua import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' import { + isKuaishouCloudBindingMutationFrozen, isKuaishouCloudTask, normalizeKuaishouCloudFlow, normalizeKuaishouCloudRoleInfo, @@ -28,6 +29,19 @@ export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: J const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + // 已发货:禁止 live 刷角色覆盖发货快照(claim 轮询 / 后台误触) + if (isKuaishouCloudBindingMutationFrozen(task, flow)) { + return { + task, + roleInfo: { + name: flow.shipped?.roleName || flow.binding.roleName || flow.role.name, + rid: flow.shipped?.roleId || flow.binding.roleId || flow.role.rid, + rawInfo: flow.role.rawInfo, + }, + flow, + } + } + if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务还没有可查询的绑定角色信息,请先准备绑定资源', { statusCode: 409, diff --git a/apps/backend/src/types/claim-identity.ts b/apps/backend/src/types/claim-identity.ts index 4a13c4a6..1fe76139 100644 --- a/apps/backend/src/types/claim-identity.ts +++ b/apps/backend/src/types/claim-identity.ts @@ -20,6 +20,11 @@ export type ClaimIdentityAdminSummary = ClaimIdentityPayload & { uidMatched: boolean | null compatibilityMode: 'uid' | 'legacy_no_uid' note: string + /** 展示依据:shipped=发货快照;live=当前绑定 */ + displaySource: 'shipped' | 'live' + shippedUid: string + shippedRoleName: string + shippedAt: string | null } /** context_json 中 claimIdentity 的宽松写入形态(兼容历史 uid 字段) */ diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index f7c8f954..345c58a8 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -715,6 +715,11 @@ function FulfillmentOverviewPanel({ {claimIdentity?.boundUid || detail.task.roleId || '-'} + {claimIdentity?.displaySource === 'shipped' ? ( + + 发货快照 + + ) : null} {claimIdentity?.boundRoleName || detail.task.roleName || '-'} @@ -725,6 +730,17 @@ function FulfillmentOverviewPanel({ {claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'} + {claimIdentity?.shippedUid ? ( + + {claimIdentity.shippedUid} + {claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''} + {claimIdentity.shippedAt ? ( + + {claimIdentity.shippedAt} + + ) : null} + + ) : null} {claimIdentity?.note || '暂无 UID 信息'} diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 79378840..485d0a41 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -69,6 +69,11 @@ export interface AdminClaimIdentitySummary { uidMatched: boolean | null compatibilityMode: 'uid' | 'legacy_no_uid' note: string + /** shipped=发货快照;live=当前绑定 */ + displaySource?: 'shipped' | 'live' + shippedUid?: string + shippedRoleName?: string + shippedAt?: string | null } export interface AdminTaskDetail {