修复 lewan 发货后 claim 轮询换号污染绑定与 UID 展示

发货成功时写入 shipped 身份快照,并冻结绑定资源变更;后台 UID 匹配优先使用发货快照,避免误报不一致。
This commit is contained in:
yml2213
2026-07-16 15:52:54 +08:00
parent be7347e600
commit 1216f252de
13 changed files with 384 additions and 11 deletions
@@ -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, /当前虚拟号角色已变化/)
})
@@ -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,
}
}
@@ -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 (
@@ -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('isKuaishouCloudBindingMutationFrozendispatch.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',
})
})
@@ -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,
@@ -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<KuaishouCloudFlow['shipped']>
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,
}
}
/**
* 是否已有可展示的绑定角色信息(诊断用)。
*
@@ -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'
@@ -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,
@@ -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,
@@ -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,
+5
View File
@@ -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 字段) */
@@ -715,6 +715,11 @@ function FulfillmentOverviewPanel({
</Descriptions.Item>
<Descriptions.Item label="绑定角色 ID">
{claimIdentity?.boundUid || detail.task.roleId || '-'}
{claimIdentity?.displaySource === 'shipped' ? (
<Tag color="blue" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</Descriptions.Item>
<Descriptions.Item label="绑定角色名">
{claimIdentity?.boundRoleName || detail.task.roleName || '-'}
@@ -725,6 +730,17 @@ function FulfillmentOverviewPanel({
<Descriptions.Item label="兼容模式">
{claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'}
</Descriptions.Item>
{claimIdentity?.shippedUid ? (
<Descriptions.Item label="发货 UID / 角色" span={2}>
{claimIdentity.shippedUid}
{claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''}
{claimIdentity.shippedAt ? (
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{claimIdentity.shippedAt}
</Typography.Text>
) : null}
</Descriptions.Item>
) : null}
<Descriptions.Item label="说明" span={3}>
<Typography.Text type="secondary">
{claimIdentity?.note || '暂无 UID 信息'}
+5
View File
@@ -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 {