修复 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
@@ -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,