偿还 lewan 工程债:模块拆分、默认角色降级与短链收口
将 kuaishou-cloud 大入口拆为 prepare/rebind/probe/refresh 等模块;默认角色仅作诊断;补充兑换状态单测;短链明确只读兼容历史链接。
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { buildClaimUrl } from '../../claim/claim-service.js'
|
||||
import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js'
|
||||
import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js'
|
||||
import { backCloudtentaclesVirtualNumber } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js'
|
||||
import {
|
||||
KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||
isKuaishouCloudBindUrlFresh,
|
||||
isKuaishouCloudTask,
|
||||
maskPhone,
|
||||
normalizeKuaishouCloudFlow,
|
||||
resolveKuaishouCloudBindUrlExpiresAt,
|
||||
type JsonObject,
|
||||
} from './domain.js'
|
||||
import {
|
||||
prepareKuaishouCloudBindResourceWithFallback,
|
||||
resolveKuaishouCloudBindingResources,
|
||||
resolveKuaishouCloudVnKeyCandidates,
|
||||
} from './binding-resources.js'
|
||||
import {
|
||||
buildCloudtentaclesSourceSelectionEventDetail,
|
||||
selectCloudtentaclesSourceForFulfillment,
|
||||
} from './account-selector.js'
|
||||
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
|
||||
import {
|
||||
getTaskClaimExpiresAt,
|
||||
normalizeActor,
|
||||
parseTaskContext,
|
||||
} from './task-context.js'
|
||||
import { resolveKuaishouCloudDeliveryPlan } from './delivery-plan.js'
|
||||
import { ensureTaskClaimLink } from './ensure-claim-link.js'
|
||||
import {
|
||||
buildPendingRoleWithDefaultSnapshot,
|
||||
captureKuaishouCloudDefaultRoleSnapshot,
|
||||
} from './role-state.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const actor = normalizeActor(options.actor)
|
||||
const force = options.force === true
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
const claimLinkState = await ensureTaskClaimLink(task)
|
||||
|
||||
if (
|
||||
!force &&
|
||||
flow.binding.prepareStatus === 'ready' &&
|
||||
flow.binding.vnId > 0 &&
|
||||
flow.binding.vnPhone &&
|
||||
flow.binding.bindUrl
|
||||
) {
|
||||
if (!isKuaishouCloudBindUrlFresh(flow)) {
|
||||
return refreshKuaishouCloudTaskBindUrl(task, {
|
||||
source: options.source || 'system_refresh_expired_bind_url',
|
||||
actor,
|
||||
claimLinkState,
|
||||
})
|
||||
}
|
||||
|
||||
let readyTask = task
|
||||
|
||||
if (normalizeTaskStatus(task.task_status) !== TASK_STATUS.WAITING_BINDING) {
|
||||
readyTask =
|
||||
(await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.WAITING_BINDING,
|
||||
claim_token: claimLinkState.token || task.claim_token || '',
|
||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||
updated_at: now,
|
||||
})) || task
|
||||
}
|
||||
|
||||
return {
|
||||
task: readyTask,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
token: claimLinkState.token,
|
||||
flow,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow)
|
||||
try {
|
||||
const cloudContext = selectedCloud.context
|
||||
const [knapsack, skuList] = await Promise.all([
|
||||
getCloudtentaclesKnapsack(cloudContext),
|
||||
listCloudtentaclesSku(cloudContext),
|
||||
])
|
||||
const resolvedBinding = resolveKuaishouCloudBindingResources(flow, {
|
||||
skuItems: Array.isArray(skuList.items) ? skuList.items : [],
|
||||
knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [],
|
||||
})
|
||||
const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({
|
||||
flow,
|
||||
binding: resolvedBinding,
|
||||
})
|
||||
|
||||
if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) {
|
||||
throw createHttpError('当前任务缺少可用 cloud 资源,无法自动准备绑定', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_missing_binding_config',
|
||||
})
|
||||
}
|
||||
|
||||
const flowWithResolvedBinding = {
|
||||
...flow,
|
||||
binding: {
|
||||
...flow.binding,
|
||||
skuId: resolvedBinding.skuId,
|
||||
skuName: resolvedBinding.skuName,
|
||||
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, {
|
||||
skuItems: Array.isArray(skuList.items) ? skuList.items : [],
|
||||
knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [],
|
||||
})
|
||||
if (deliveryPlan.items.length === 0) {
|
||||
throw createHttpError('当前任务缺少 cloud 发货物品配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_missing_delivery_items',
|
||||
})
|
||||
}
|
||||
|
||||
const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0)
|
||||
if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) {
|
||||
throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_auto_buy_disabled',
|
||||
})
|
||||
}
|
||||
|
||||
const missingSku = deliveryPlan.items.find((item) => item.missingCount > 0 && !item.skuItem)
|
||||
if (missingSku) {
|
||||
throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, {
|
||||
statusCode: 404,
|
||||
errorCode: 'kuaishou_cloud_sku_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const purchaseTriggered = false
|
||||
const assetBefore = 0
|
||||
const assetAfter = 0
|
||||
|
||||
const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
vnKeyCandidates,
|
||||
})
|
||||
const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({
|
||||
cloudContext,
|
||||
preparedBinding,
|
||||
now,
|
||||
})
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flowWithResolvedBinding,
|
||||
binding: {
|
||||
...flowWithResolvedBinding.binding,
|
||||
resolvedSourceKey: cloudContext.resolvedSourceKey,
|
||||
vnKey: preparedBinding.vnKey,
|
||||
prepareStatus: 'ready',
|
||||
vnId: preparedBinding.vnId,
|
||||
vnPhone: preparedBinding.vnPhone,
|
||||
bindUrl: preparedBinding.bindUrl,
|
||||
bindPreparedAt: now,
|
||||
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
|
||||
bindProbeAt: null as null,
|
||||
bindProbeStatus: 'pending',
|
||||
bindProbeMessage: '',
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
},
|
||||
role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot),
|
||||
purchase: {
|
||||
...flowWithResolvedBinding.purchase,
|
||||
usedKnapsack,
|
||||
purchaseTriggered,
|
||||
assetBefore,
|
||||
assetAfter,
|
||||
items: deliveryPlan.items.map((item) => ({
|
||||
cloudSkuId: item.cloudSkuId,
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
requiredCount: item.quantity,
|
||||
knapsackCount: item.knapsackCount,
|
||||
purchasedCount: 0,
|
||||
})),
|
||||
purchaseAt: flowWithResolvedBinding.purchase.purchaseAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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: '',
|
||||
last_error: '',
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_binding_prepared',
|
||||
{
|
||||
source: String(options.source || 'system').trim() || 'system',
|
||||
skuId: flowWithResolvedBinding.binding.skuId,
|
||||
skuName: flowWithResolvedBinding.binding.skuName,
|
||||
vnKey: preparedBinding.vnKey,
|
||||
vnId: preparedBinding.vnId,
|
||||
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
|
||||
purchaseTriggered,
|
||||
usedKnapsack,
|
||||
deliveryItems: deliveryPlan.items.map((item) => ({
|
||||
cloudSkuId: item.cloudSkuId,
|
||||
cloudSkuName: item.cloudSkuName,
|
||||
quantity: item.quantity,
|
||||
knapsackCount: item.knapsackCount,
|
||||
purchasedCount: 0,
|
||||
})),
|
||||
resolvedByName: resolvedBinding.resolvedByName,
|
||||
accountSelection: buildCloudtentaclesSourceSelectionEventDetail(selectedCloud),
|
||||
defaultRoleName: defaultRoleSnapshot.defaultName,
|
||||
defaultRoleId: defaultRoleSnapshot.defaultRid,
|
||||
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
token: claimLinkState.token,
|
||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||
}
|
||||
} finally {
|
||||
selectedCloud.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const actor = normalizeActor(options.actor)
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
|
||||
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
||||
throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_missing_bind_url_context',
|
||||
})
|
||||
}
|
||||
|
||||
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
|
||||
flow.binding.resolvedSourceKey,
|
||||
...flow.binding.cloudSourceKeys,
|
||||
])
|
||||
const oldVnKey = flow.binding.vnKey
|
||||
const oldVnId = flow.binding.vnId
|
||||
const oldVnPhone = flow.binding.vnPhone
|
||||
|
||||
await backCloudtentaclesVirtualNumber({
|
||||
...cloudContext,
|
||||
key: oldVnKey,
|
||||
id: oldVnId,
|
||||
})
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_expired_bind_number_returned',
|
||||
{
|
||||
source: String(options.source || 'system').trim() || 'system',
|
||||
vnKey: oldVnKey,
|
||||
vnId: oldVnId,
|
||||
vnPhoneMasked: maskPhone(oldVnPhone),
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
let preparedBinding
|
||||
try {
|
||||
preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({
|
||||
cloudContext,
|
||||
vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({
|
||||
flow,
|
||||
binding: flow.binding,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!String(preparedBinding.bindUrl || '').trim()) {
|
||||
throw createHttpError('cloudtentacles 未返回新的绑定链接', {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_cloud_empty_bind_url',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, {
|
||||
taskContext,
|
||||
flow,
|
||||
now,
|
||||
actor,
|
||||
error,
|
||||
})
|
||||
|
||||
return {
|
||||
task: failedTask,
|
||||
claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')),
|
||||
token: String(task.primary_claim_token || task.claim_token || ''),
|
||||
flow: normalizeKuaishouCloudFlow(parseTaskContext(failedTask).kuaishouCloudFulfillment),
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRoleSnapshot =
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
? null
|
||||
: await captureKuaishouCloudDefaultRoleSnapshot({
|
||||
cloudContext,
|
||||
preparedBinding,
|
||||
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: resolveKuaishouCloudBindUrlExpiresAt(now),
|
||||
bindProbeAt: null as null,
|
||||
bindProbeStatus: 'pending',
|
||||
bindProbeMessage: '',
|
||||
roleName:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
? flow.binding.roleName
|
||||
: '',
|
||||
roleId:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
? flow.binding.roleId
|
||||
: '',
|
||||
},
|
||||
role:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
? flow.role
|
||||
: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot!),
|
||||
},
|
||||
}
|
||||
|
||||
const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task))
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
? task.task_status
|
||||
: TASK_STATUS.WAITING_BINDING,
|
||||
claim_token: claimLinkState.token || task.claim_token || '',
|
||||
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
|
||||
role_id:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '',
|
||||
role_name:
|
||||
normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '',
|
||||
last_error: '',
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_bind_url_refreshed',
|
||||
{
|
||||
source: String(options.source || 'system').trim() || 'system',
|
||||
oldVnId,
|
||||
oldVnPhoneMasked: maskPhone(oldVnPhone),
|
||||
vnKey: preparedBinding.vnKey,
|
||||
vnId: preparedBinding.vnId,
|
||||
vnPhoneMasked: maskPhone(preparedBinding.vnPhone),
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
token: claimLinkState.token,
|
||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function markKuaishouCloudBindUrlRefreshFailed(
|
||||
task: TaskRow,
|
||||
{ taskContext, flow, now, actor, error }: JsonObject = {},
|
||||
) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error || '新绑定链接准备失败')
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
binding: {
|
||||
...flow.binding,
|
||||
prepareStatus: 'pending',
|
||||
vnId: 0,
|
||||
vnPhone: '',
|
||||
bindUrl: '',
|
||||
bindPreparedAt: null,
|
||||
bindExpiresAt: null,
|
||||
bindProbeAt: now,
|
||||
bindProbeStatus: 'refresh_failed',
|
||||
bindProbeMessage: errorMessage,
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
},
|
||||
role: {
|
||||
status: 'pending',
|
||||
name: '',
|
||||
rid: '',
|
||||
refreshedAt: now,
|
||||
errorMessage: '绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理',
|
||||
rawInfo: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
role_id: '',
|
||||
role_name: '',
|
||||
last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_bind_url_refresh_failed',
|
||||
{
|
||||
source: 'system_refresh_expired_bind_url',
|
||||
errorMessage,
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
await notifyKuaishouCloudBindUrlRefreshFailed({
|
||||
task: updatedTask,
|
||||
errorMessage,
|
||||
})
|
||||
|
||||
return updatedTask
|
||||
}
|
||||
Reference in New Issue
Block a user