完整流程-调试

This commit is contained in:
yml
2026-05-03 18:44:44 +08:00
parent 063c3fd8a1
commit 90cb7aa154
25 changed files with 2337 additions and 27 deletions
@@ -22,6 +22,23 @@ import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../cl
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.js'
import {
buyCloudtentaclesSku,
getCloudtentaclesAsset,
listCloudtentaclesSku,
useCloudtentaclesSku,
} from '../platforms/cloudtentacles/catalog-service.js'
import { getCloudtentaclesKnapsack } from '../platforms/cloudtentacles/knapsack-service.js'
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
fetchCloudtentaclesVirtualNumberCode,
generateCloudtentaclesLoginCode,
getCloudtentaclesBindUrl,
verifyCloudtentaclesLoginCode,
} from '../platforms/cloudtentacles/virtual-number-service.js'
import { closeTencentBrowserSession } from '../session/session.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
@@ -56,6 +73,7 @@ import {
/** @typedef {import('../../types/admin-write-models.js').AdminTaskBindingReleaseResponse} AdminTaskBindingReleaseResponse */
/** @typedef {import('../../types/admin-write-models.js').AdminTaskManualDispatchResponse} AdminTaskManualDispatchResponse */
/** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */
/** @typedef {import('../../types/admin-write-inputs.js').AdminTaskKuaishouCloudDispatchInput} AdminTaskKuaishouCloudDispatchInput */
/** @returns {Promise<AdminInventoryMutationResponse>} */
/** @param {AdminInventoryCreateInput} [payload] */
@@ -675,6 +693,352 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess
}
}
/** @returns {Promise<AdminTaskActionResponse>} */
/** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */
export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = null) {
const task = await getRequiredTask(taskId)
const now = nowIso()
const viewerContext = createAdminViewerContext(session)
if (!viewerContext.canManageTaskLifecycle) {
throw createHttpError('当前账号没有权限准备绑定资源', {
statusCode: 403,
errorCode: 'admin_task_prepare_kuaishou_cloud_forbidden',
})
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
}
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const cloudContext = resolvePersistedCloudtentaclesContext()
if (!flow.binding.skuId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少 cloud SKU 或 VN Key 配置', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_binding_config',
})
}
const knapsack = await getCloudtentaclesKnapsack(cloudContext)
const knapsackItem = (Array.isArray(knapsack.items) ? knapsack.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null
let usedKnapsack = Number(knapsackItem?.count || 0) > 0
let purchaseTriggered = false
let assetBefore = 0
let assetAfter = 0
if (!usedKnapsack) {
if (!flow.purchase.autoBuyEnabled) {
throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_auto_buy_disabled',
})
}
const [asset, skuList] = await Promise.all([
getCloudtentaclesAsset(cloudContext),
listCloudtentaclesSku(cloudContext),
])
const targetSku = (Array.isArray(skuList.items) ? skuList.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null
if (!targetSku) {
throw createHttpError(`cloudtentacles 未找到 SKU ${flow.binding.skuId}`, {
statusCode: 404,
errorCode: 'admin_task_kuaishou_cloud_sku_not_found',
})
}
assetBefore = Number(asset.asset || 0) || 0
const targetPrice = Number(targetSku.price || 0) || 0
const requiredAsset = targetPrice + flow.purchase.minAssetReserve
if (assetBefore < requiredAsset) {
throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_asset_not_enough',
})
}
await buyCloudtentaclesSku({
...cloudContext,
id: flow.binding.skuId,
count: 1,
})
purchaseTriggered = true
const assetResult = await getCloudtentaclesAsset(cloudContext)
assetAfter = Number(assetResult.asset || 0) || 0
}
const appointed = await appointCloudtentaclesVirtualNumber({
...cloudContext,
key: flow.binding.vnKey,
})
const vnId = Number(appointed.item?.id || 0)
const vnPhone = String(appointed.item?.phone || '').trim()
if (!vnId || !vnPhone) {
throw createHttpError('申请虚拟号成功但返回数据不完整', {
statusCode: 502,
errorCode: 'admin_task_kuaishou_cloud_invalid_vn',
})
}
await generateCloudtentaclesLoginCode({
...cloudContext,
key: flow.binding.vnKey,
id: vnId,
})
const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({
...cloudContext,
key: flow.binding.vnKey,
phone: vnPhone,
})
await verifyCloudtentaclesLoginCode({
...cloudContext,
key: flow.binding.vnKey,
id: vnId,
code: fetchedCode.code,
})
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: flow.binding.vnKey,
id: vnId,
})
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
prepareStatus: 'ready',
vnId,
vnPhone,
bindUrl: bindUrlResult.bindUrl,
bindPreparedAt: now,
},
purchase: {
...flow.purchase,
usedKnapsack,
purchaseTriggered,
assetBefore,
assetAfter,
purchaseAt: purchaseTriggered ? now : flow.purchase.purchaseAt,
},
},
}
const updatedTask = await updateTask(task.id, {
task_status: 'waiting_binding',
inventory_status: 'not_required',
user_action_status: 'pending',
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', {
skuId: flow.binding.skuId,
vnKey: flow.binding.vnKey,
vnId,
vnPhoneMasked: maskPhone(vnPhone),
bindUrl: bindUrlResult.bindUrl,
purchaseTriggered,
usedKnapsack,
}, now)
return {
task: mapTaskActionPayload(updatedTask),
}
}
/** @returns {Promise<AdminTaskActionResponse>} */
/** @param {AdminEntityIdInput} taskId */
/** @param {AdminTaskKuaishouCloudDispatchInput} [payload] */
/** @param {AdminViewerSessionInput | null} [session] */
export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload = {}, session = null) {
const task = await getRequiredTask(taskId)
const now = nowIso()
const viewerContext = createAdminViewerContext(session)
if (!viewerContext.canManageTaskLifecycle) {
throw createHttpError('当前账号没有权限执行发货', {
statusCode: 403,
errorCode: 'admin_task_dispatch_kuaishou_cloud_forbidden',
})
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
}
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const cloudContext = resolvePersistedCloudtentaclesContext()
if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) {
throw createHttpError('当前任务还没有准备好绑定资源,请先准备绑定资源', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_not_prepared',
})
}
const ticketCode = String(payload.ticketCode || '').trim()
const dispatchResult = await useCloudtentaclesSku({
...cloudContext,
id: flow.binding.skuId,
virtualNumberId: flow.binding.vnId,
phone: flow.binding.vnPhone,
})
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
ticket: {
...flow.ticket,
code: ticketCode || flow.ticket.code,
capturedAt: ticketCode ? now : flow.ticket.capturedAt,
capturedBy: ticketCode && session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: flow.ticket.capturedBy,
},
dispatch: {
...flow.dispatch,
status: 'success',
dispatchAt: now,
dispatchBy: session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null,
sendType: Number(dispatchResult.sendType || 0) || 0,
note: String(dispatchResult.note || dispatchResult.responseMessage || '').trim(),
},
},
}
const updatedTask = await updateTask(task.id, {
task_status: 'dispatched_pending_return',
delivery_status: 'delivered',
result_code: 'kuaishou_cloud_dispatched',
result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(),
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', {
ticketCodeMasked: maskCode(ticketCode),
skuId: flow.binding.skuId,
vnId: flow.binding.vnId,
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
sendType: dispatchResult.sendType,
note: dispatchResult.note,
}, now)
return {
task: mapTaskActionPayload(updatedTask),
}
}
/** @returns {Promise<AdminTaskActionResponse>} */
/** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */
export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, session = null) {
const task = await getRequiredTask(taskId)
const now = nowIso()
const viewerContext = createAdminViewerContext(session)
if (!viewerContext.canManageTaskLifecycle) {
throw createHttpError('当前账号没有权限退还号码', {
statusCode: 403,
errorCode: 'admin_task_return_kuaishou_cloud_forbidden',
})
}
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 cloud 履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_kuaishou_cloud',
})
}
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const cloudContext = resolvePersistedCloudtentaclesContext()
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少可退还的虚拟号信息', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_return_context',
})
}
await backCloudtentaclesVirtualNumber({
...cloudContext,
key: flow.binding.vnKey,
id: flow.binding.vnId,
})
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
returnNumber: {
...flow.returnNumber,
status: 'success',
returnedAt: now,
returnedBy: session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null,
},
},
}
const updatedTask = await updateTask(task.id, {
task_status: 'completed',
delivery_status: 'delivered',
result_code: 'kuaishou_cloud_completed',
result_message: 'cloudtentacles 发货并退号完成',
redeemed_at: now,
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(task.id, 'kuaishou_cloud_number_returned', {
vnId: flow.binding.vnId,
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
}, now)
return {
task: mapTaskActionPayload(updatedTask),
}
}
/**
* @param {AdminInventoryImportInput} payload
* @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>}
@@ -725,6 +1089,116 @@ function normalizeInventoryImportRows(payload) {
return dedupeRows(rows)
}
function isKuaishouCloudTask(task) {
return String(task?.executor_key || '').trim() === 'kuaishou_ct_assisted'
}
function resolvePersistedCloudtentaclesContext() {
const source = getCloudtentaclesSourceConfig()
const session = getCloudtentaclesSessionState()
const token = String(session.token || '').trim()
if (!token) {
throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token',
})
}
return {
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
token,
deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-',
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
}
}
function normalizeKuaishouCloudFlow(value) {
const source = value && typeof value === 'object' ? value : {}
const binding = source.binding && typeof source.binding === 'object' ? source.binding : {}
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 : {}
return {
...source,
configId: String(source.configId || '').trim(),
internalSkuCode: String(source.internalSkuCode || '').trim(),
internalSkuName: String(source.internalSkuName || '').trim(),
ticket: {
code: String(ticket.code || '').trim(),
capturedAt: ticket.capturedAt || null,
capturedBy: ticket.capturedBy || null,
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKey: String(binding.cloudSourceKey || 'default').trim() || 'default',
skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || '').trim(),
vnKey: String(binding.vnKey || '').trim(),
vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
},
purchase: {
autoBuyEnabled: purchase.autoBuyEnabled !== false,
minAssetReserve: Number(purchase.minAssetReserve || 0) || 0,
usedKnapsack: purchase.usedKnapsack === true,
purchaseTriggered: purchase.purchaseTriggered === true,
assetBefore: Number(purchase.assetBefore || 0) || 0,
assetAfter: Number(purchase.assetAfter || 0) || 0,
purchaseAt: purchase.purchaseAt || null,
},
dispatch: {
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(),
},
returnNumber: {
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(),
autoConsumeEnabled: consume.autoConsumeEnabled === true,
},
}
}
function maskPhone(value) {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 7) {
return `${text.slice(0, 2)}***${text.slice(-2)}`
}
return `${text.slice(0, 3)}****${text.slice(-4)}`
}
function maskCode(value) {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 8) {
return `${text.slice(0, 2)}***${text.slice(-2)}`
}
return `${text.slice(0, 4)}****${text.slice(-4)}`
}
/**
* @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>} rows
*/
@@ -797,14 +1271,3 @@ function normalizeManualDispatchOutcome(value) {
function getTaskClaimExpiresAt(task) {
return task?.claim_expires_at || task?.primary_claim_expires_at || null
}
function maskCode(value) {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (text.length <= 8) {
return `${text.slice(0, 2)}****${text.slice(-2)}`
}
return `${text.slice(0, 4)}****${text.slice(-4)}`
}