修复 lewan 发货后 claim 轮询换号污染绑定与 UID 展示
发货成功时写入 shipped 身份快照,并冻结绑定资源变更;后台 UID 匹配优先使用发货快照,避免误报不一致。
This commit is contained in:
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user