添加快手 Cloud 换绑角色功能

This commit is contained in:
yml
2026-05-30 11:23:52 +08:00
parent 681ff44cbc
commit 1c50569ea9
22 changed files with 1537 additions and 953 deletions
@@ -1,98 +1,77 @@
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/helpers.js";
import {
maskCode as maskCodeValue,
maskPhone as maskPhoneValue,
} from "../../../utils/masking.js";
import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js'
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js'
export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1";
export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1'
export type JsonObject = Record<string, any>;
export type JsonObject = Record<string, any>
export function isKuaishouCloudTask(task: unknown) {
const source = task && typeof task === "object" ? task as JsonObject : {};
return String(source.executor_key || "").trim() === "kuaishou_ct_assisted";
const source = task && typeof task === 'object' ? (task as JsonObject) : {}
return String(source.executor_key || '').trim() === 'kuaishou_ct_assisted'
}
export function normalizeKuaishouCloudFlow(value: unknown) {
const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {};
const binding =
source.binding && typeof source.binding === "object" ? source.binding : {};
const role =
source.role && typeof source.role === "object" ? source.role : {};
const purchase =
source.purchase && typeof source.purchase === "object"
? source.purchase
: {};
const dispatch =
source.dispatch && typeof source.dispatch === "object"
? source.dispatch
: {};
const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {}
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
const role = source.role && typeof source.role === 'object' ? source.role : {}
const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {}
const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {}
const returnNumber =
source.returnNumber && typeof source.returnNumber === "object"
? source.returnNumber
: {};
const consume =
source.consume && typeof source.consume === "object" ? source.consume : {};
const ticket =
source.ticket && typeof source.ticket === "object" ? source.ticket : {};
const deliveryItems = normalizeDeliveryItems(source, binding);
source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {}
const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
const rebind = source.rebind && typeof source.rebind === 'object' ? source.rebind : {}
const deliveryItems = normalizeDeliveryItems(source, binding)
const roleName = String(role.name || binding.roleName || "").trim();
const roleId = String(role.rid || binding.roleId || "").trim();
const bindPreparedAt = binding.bindPreparedAt || null;
const roleName = String(role.name || binding.roleName || '').trim()
const roleId = String(role.rid || binding.roleId || '').trim()
const bindPreparedAt = binding.bindPreparedAt || null
const bindExpiresAt =
binding.bindExpiresAt ||
resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt);
binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt)
return {
...source,
configId: String(source.configId || "").trim(),
internalSkuCode: String(source.internalSkuCode || "").trim(),
internalSkuName: String(source.internalSkuName || "").trim(),
configId: String(source.configId || '').trim(),
internalSkuCode: String(source.internalSkuCode || '').trim(),
internalSkuName: String(source.internalSkuName || '').trim(),
deliveryItems,
ticket: {
code: String(ticket.code || "").trim(),
status: String(ticket.status || "pending").trim() || "pending",
code: String(ticket.code || '').trim(),
status: String(ticket.status || 'pending').trim() || 'pending',
capturedAt: ticket.capturedAt || null,
capturedBy: ticket.capturedBy || null,
verifiedAt: ticket.verifiedAt || null,
oid: String(ticket.oid || "").trim(),
formToken: String(ticket.formToken || "").trim(),
oid: String(ticket.oid || '').trim(),
formToken: String(ticket.formToken || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
goodsTitle: String(ticket.goodsTitle || "").trim(),
goodsTitle: String(ticket.goodsTitle || '').trim(),
},
binding: {
prepareStatus:
String(binding.prepareStatus || "pending").trim() || "pending",
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys),
resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(),
resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || "").trim(),
skuName: String(binding.skuName || '').trim(),
vnKey:
String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() ||
KUAISHOU_CLOUD_FIXED_VN_KEY,
String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || "").trim(),
bindUrl: String(binding.bindUrl || "").trim(),
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt,
bindExpiresAt,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || "").trim(),
bindProbeMessage: String(binding.bindProbeMessage || "").trim(),
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName,
roleId,
},
role: {
status:
String(
role.status || (roleName || roleId ? "ready" : "pending")
).trim() || "pending",
status: String(role.status || (roleName || roleId ? 'ready' : 'pending')).trim() || 'pending',
name: roleName,
rid: roleId,
refreshedAt: role.refreshedAt || null,
errorMessage: String(role.errorMessage || "").trim(),
rawInfo:
role.rawInfo && typeof role.rawInfo === "object" ? role.rawInfo : null,
errorMessage: String(role.errorMessage || '').trim(),
rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null,
},
purchase: {
autoBuyEnabled: purchase.autoBuyEnabled !== false,
@@ -105,171 +84,166 @@ export function normalizeKuaishouCloudFlow(value: unknown) {
items: Array.isArray(purchase.items) ? purchase.items : [],
},
dispatch: {
status: String(dispatch.status || "pending").trim() || "pending",
status: String(dispatch.status || 'pending').trim() || 'pending',
dispatchAt: dispatch.dispatchAt || null,
dispatchBy: dispatch.dispatchBy || null,
sendType: Number(dispatch.sendType || 0) || 0,
note: String(dispatch.note || "").trim(),
note: String(dispatch.note || '').trim(),
items: Array.isArray(dispatch.items) ? dispatch.items : [],
},
returnNumber: {
status: String(returnNumber.status || "pending").trim() || "pending",
status: String(returnNumber.status || 'pending').trim() || 'pending',
returnedAt: returnNumber.returnedAt || null,
returnedBy: returnNumber.returnedBy || null,
autoReturnEnabled: returnNumber.autoReturnEnabled === true,
},
consume: {
status: String(consume.status || "pending").trim() || "pending",
shopId: String(consume.shopId || "").trim(),
shopName: String(consume.shopName || "").trim(),
status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || '').trim(),
shopName: String(consume.shopName || '').trim(),
autoConsumeEnabled: consume.autoConsumeEnabled === true,
consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || "").trim(),
errorMessage: String(consume.errorMessage || '').trim(),
},
notes: String(source.notes || "").trim(),
};
rebind: {
currentAttempt: Math.max(0, Number(rebind.currentAttempt || 0) || 0),
history: Array.isArray(rebind.history) ? rebind.history : [],
},
notes: String(source.notes || '').trim(),
}
}
export function normalizeKuaishouCloudDeliveryItems(value: unknown) {
const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {};
const binding =
source.binding && typeof source.binding === "object" ? source.binding : {};
return normalizeDeliveryItems(source, binding);
const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {}
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
return normalizeDeliveryItems(source, binding)
}
export function normalizeKuaishouCloudRoleInfo(value: unknown) {
const rawInfo: JsonObject | null = value && typeof value === "object" ? value as JsonObject : null;
const rawInfo: JsonObject | null =
value && typeof value === 'object' ? (value as JsonObject) : null
const nestedBindInfo =
rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object"
? rawInfo.sBindInfo
: null;
const source = nestedBindInfo || rawInfo;
rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null
const source = nestedBindInfo || rawInfo
return {
name: String(
source?.name ||
source?.roleName ||
source?.nickname ||
source?.sRoleName ||
""
source?.name || source?.roleName || source?.nickname || source?.sRoleName || '',
).trim(),
rid: String(
source?.rid ||
source?.roleId ||
source?.uid ||
source?.sRoleId ||
source?.sUserId ||
""
source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '',
).trim(),
rawInfo,
};
}
}
export function maskPhone(value: unknown) {
return maskPhoneValue(value);
return maskPhoneValue(value)
}
export function maskCode(value: unknown) {
return maskCodeValue(value);
return maskCodeValue(value)
}
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) {
const preparedTime = Date.parse(String(preparedAt || ""));
const preparedTime = Date.parse(String(preparedAt || ''))
if (!Number.isFinite(preparedTime)) {
return null;
return null
}
const config = resolveCloudtentaclesConfig();
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600);
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString();
const config = resolveCloudtentaclesConfig()
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600)
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString()
}
export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) {
const normalizedFlow = normalizeKuaishouCloudFlow(flow);
const normalizedFlow = normalizeKuaishouCloudFlow(flow)
if (!normalizedFlow.binding.bindUrl) {
return false;
return false
}
const expiresAt = normalizedFlow.binding.bindExpiresAt;
const expiresAt = normalizedFlow.binding.bindExpiresAt
if (!expiresAt) {
return false;
return false
}
const expiresTime = Date.parse(String(expiresAt || ""));
const expiresTime = Date.parse(String(expiresAt || ''))
if (!Number.isFinite(expiresTime)) {
return false;
return false
}
return expiresTime > now.getTime();
return expiresTime > now.getTime()
}
export function normalizeStringArray(value: unknown) {
if (Array.isArray(value)) {
return value.map((v) => String(v || "").trim()).filter(Boolean);
return value.map((v) => String(v || '').trim()).filter(Boolean)
}
if (typeof value === "string") {
if (typeof value === 'string') {
return value
.split(",")
.split(',')
.map((v) => v.trim())
.filter(Boolean);
.filter(Boolean)
}
return [];
return []
}
function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) {
const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : [];
const items = rawItems
.map((item) => normalizeDeliveryItem(item))
.filter(Boolean) as Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>;
const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : []
const items = rawItems.map((item) => normalizeDeliveryItem(item)).filter(Boolean) as Array<{
cloudSkuId: number
cloudSkuName: string
quantity: number
}>
if (items.length > 0) {
return mergeDeliveryItems(items);
return mergeDeliveryItems(items)
}
const skuId = Number(binding.skuId || 0) || 0;
const skuId = Number(binding.skuId || 0) || 0
if (!skuId) {
return [];
return []
}
return [
{
cloudSkuId: skuId,
cloudSkuName: String(binding.skuName || "").trim(),
cloudSkuName: String(binding.skuName || '').trim(),
quantity: 1,
},
];
]
}
function normalizeDeliveryItem(value: unknown) {
const source = value && typeof value === "object" ? value as JsonObject : {};
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0;
const source = value && typeof value === 'object' ? (value as JsonObject) : {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) {
return null;
return null
}
const quantity = Number(source.quantity || 1) || 1;
const quantity = Number(source.quantity || 1) || 1
return {
cloudSkuId,
cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(),
cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(),
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
};
}
}
function mergeDeliveryItems(
items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>
items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>,
) {
const merged = new Map<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>();
const merged = new Map<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>()
for (const item of items) {
const existing = merged.get(item.cloudSkuId);
const existing = merged.get(item.cloudSkuId)
if (existing) {
existing.quantity += item.quantity;
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName;
continue;
existing.quantity += item.quantity
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
continue
}
merged.set(item.cloudSkuId, { ...item });
merged.set(item.cloudSkuId, { ...item })
}
return Array.from(merged.values());
return Array.from(merged.values())
}
@@ -456,6 +456,286 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
}
}
export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
}
const normalizedStatus = normalizeTaskStatus(task.task_status)
if (
![
TASK_STATUS.WAITING_BINDING,
TASK_STATUS.ROLE_CONFIRMED,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.RETRY_PENDING,
].includes(normalizedStatus as any)
) {
throw createHttpError('当前任务状态不可换绑角色', {
statusCode: 409,
errorCode: 'kuaishou_cloud_rebind_not_allowed',
})
}
const now = nowIso()
const actor = normalizeActor(options.actor)
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
if (
flow.dispatch.status === 'success' ||
normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN
) {
throw createHttpError('当前任务已经发货,不能换绑角色', {
statusCode: 409,
errorCode: 'kuaishou_cloud_rebind_after_dispatch_forbidden',
})
}
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少可退还的虚拟号信息,请先准备绑定资源', {
statusCode: 409,
errorCode: 'kuaishou_cloud_rebind_missing_binding_context',
})
}
const source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role'
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
flow.binding.resolvedSourceKey,
...flow.binding.cloudSourceKeys,
])
const oldBinding = {
vnKey: flow.binding.vnKey,
vnId: flow.binding.vnId,
vnPhone: flow.binding.vnPhone,
bindUrl: flow.binding.bindUrl,
bindPreparedAt: flow.binding.bindPreparedAt,
bindExpiresAt: flow.binding.bindExpiresAt,
roleName: flow.binding.roleName || flow.role.name || task.role_name || '',
roleId: flow.binding.roleId || flow.role.rid || task.role_id || '',
}
const previousRebind: JsonObject =
flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {}
const history = Array.isArray(previousRebind.history) ? previousRebind.history : []
const attempt = Math.max(1, Number(previousRebind.currentAttempt || history.length || 0) + 1)
const baseHistoryItem = {
attempt,
source,
requestedAt: now,
requestedBy: actor,
oldBinding,
}
await createTaskEvent(
task.id,
'kuaishou_cloud_rebind_requested',
{
source,
attempt,
oldVnId: oldBinding.vnId,
oldVnPhoneMasked: maskPhone(oldBinding.vnPhone),
oldRoleName: oldBinding.roleName,
oldRoleId: oldBinding.roleId,
actor,
},
now,
)
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: oldBinding.vnKey,
id: oldBinding.vnId,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_rebind_old_number_returned',
{
source,
attempt,
vnKey: oldBinding.vnKey,
vnId: oldBinding.vnId,
vnPhoneMasked: maskPhone(oldBinding.vnPhone),
actor,
},
now,
)
let preparedBinding
try {
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
cloudContext,
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
flow,
binding: flow.binding,
}),
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '新绑定资源准备失败'
const failedContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
prepareStatus: 'pending',
vnId: 0,
vnPhone: '',
bindUrl: '',
bindPreparedAt: null,
bindExpiresAt: null,
bindProbeAt: now,
bindProbeStatus: 'rebind_failed',
bindProbeMessage: errorMessage,
roleName: '',
roleId: '',
},
role: {
status: 'pending',
name: '',
rid: '',
refreshedAt: now,
errorMessage: `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`,
rawInfo: null,
},
rebind: {
...previousRebind,
currentAttempt: attempt,
history: [
...history,
{
...baseHistoryItem,
status: 'failed',
errorMessage,
},
],
},
},
}
const failedTask = await updateTask(task.id, {
task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
role_id: '',
role_name: '',
role_confirmed_at: null,
last_error: `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`,
context_json: JSON.stringify(failedContext),
updated_at: now,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_rebind_failed',
{
source,
attempt,
errorMessage,
actor,
},
now,
)
return {
task: failedTask || task,
claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')),
token: String(task.primary_claim_token || task.claim_token || ''),
flow: normalizeKuaishouCloudFlow(
parseTaskContext(failedTask || task).kuaishouCloudFulfillment,
),
}
}
const claimLinkState = await ensureTaskClaimLink(task)
const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now)
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
prepareStatus: 'ready',
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone,
bindUrl: preparedBinding.bindUrl,
bindPreparedAt: now,
bindExpiresAt: nextBindExpiresAt,
bindProbeAt: null,
bindProbeStatus: 'pending',
bindProbeMessage: '',
roleName: '',
roleId: '',
},
role: {
status: 'pending',
name: '',
rid: '',
refreshedAt: null,
errorMessage: '',
rawInfo: null,
},
rebind: {
...previousRebind,
currentAttempt: attempt,
history: [
...history,
{
...baseHistoryItem,
newBinding: {
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone,
bindUrl: preparedBinding.bindUrl,
bindPreparedAt: now,
bindExpiresAt: nextBindExpiresAt,
},
status: 'success',
errorMessage: '',
},
],
},
},
}
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
role_id: '',
role_name: '',
role_confirmed_at: null,
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(
task.id,
'kuaishou_cloud_rebind_prepared',
{
source,
attempt,
oldVnId: oldBinding.vnId,
oldVnPhoneMasked: maskPhone(oldBinding.vnPhone),
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
bindUrl: preparedBinding.bindUrl,
actor,
},
now,
)
return {
task: updatedTask,
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
}
}
export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {