偿还 lewan 工程债:模块拆分、默认角色降级与短链收口
将 kuaishou-cloud 大入口拆为 prepare/rebind/probe/refresh 等模块;默认角色仅作诊断;补充兑换状态单测;短链明确只读兼容历史链接。
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 历史短链只读跳转入口(/s/:code)。
|
||||
* 新 claim 交付请使用本站 /#/claim/{token},勿再为 feifei 生成 shortLink。
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
|
||||
import { resolveShortLinkTarget } from '../services/short-links/short-link-service.js'
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
TASK_STATUS,
|
||||
canRedeemKuaishouCloudClaimStatus,
|
||||
canTaskTransition,
|
||||
normalizeTaskStatus,
|
||||
} from '../../domain/task-status.js'
|
||||
import {
|
||||
assertBoundUidMatchesExpected,
|
||||
assertClaimExpectedUidReady,
|
||||
isClaimUidMatched,
|
||||
} from './claim-identity.js'
|
||||
|
||||
/**
|
||||
* 一键兑换前置:waiting_binding + UID 匹配 → 可升到 role_confirmed → redeeming
|
||||
*/
|
||||
function canPromoteWaitingBindingToRedeem(options: {
|
||||
status: unknown
|
||||
expectedUid: string
|
||||
boundUid: string
|
||||
}) {
|
||||
const status = normalizeTaskStatus(options.status)
|
||||
if (!canRedeemKuaishouCloudClaimStatus(status)) {
|
||||
return { ok: false, reason: 'status_not_redeemable' as const }
|
||||
}
|
||||
if (!options.expectedUid) {
|
||||
return { ok: false, reason: 'uid_missing' as const }
|
||||
}
|
||||
if (!isClaimUidMatched(options.expectedUid, options.boundUid)) {
|
||||
return { ok: false, reason: 'uid_mismatch' as const }
|
||||
}
|
||||
if (status === TASK_STATUS.WAITING_BINDING) {
|
||||
if (!canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED)) {
|
||||
return { ok: false, reason: 'cannot_promote' as const }
|
||||
}
|
||||
}
|
||||
if (!canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING)) {
|
||||
return { ok: false, reason: 'cannot_redeem' as const }
|
||||
}
|
||||
return { ok: true as const, reason: 'ok' as const }
|
||||
}
|
||||
|
||||
test('waiting_binding + uid 匹配可一键兑换', () => {
|
||||
const result = canPromoteWaitingBindingToRedeem({
|
||||
status: TASK_STATUS.WAITING_BINDING,
|
||||
expectedUid: '10001',
|
||||
boundUid: '10001',
|
||||
})
|
||||
assert.deepEqual(result, { ok: true, reason: 'ok' })
|
||||
})
|
||||
|
||||
test('waiting_binding + uid 不一致不可兑换', () => {
|
||||
const result = canPromoteWaitingBindingToRedeem({
|
||||
status: TASK_STATUS.WAITING_BINDING,
|
||||
expectedUid: '10001',
|
||||
boundUid: '99999',
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.reason, 'uid_mismatch')
|
||||
})
|
||||
|
||||
test('link_generated 状态不可直接 redeem', () => {
|
||||
const result = canPromoteWaitingBindingToRedeem({
|
||||
status: TASK_STATUS.LINK_GENERATED,
|
||||
expectedUid: '10001',
|
||||
boundUid: '10001',
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.reason, 'status_not_redeemable')
|
||||
})
|
||||
|
||||
test('assertBoundUidMatchesExpected 在不匹配时抛错', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertBoundUidMatchesExpected(
|
||||
{
|
||||
context_json: JSON.stringify({
|
||||
claimIdentity: { expectedUid: '10001' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
binding: { roleId: '999', roleName: 'x', vnPhone: '1' },
|
||||
role: { rid: '999', name: 'x' },
|
||||
},
|
||||
),
|
||||
/不一致/,
|
||||
)
|
||||
})
|
||||
|
||||
test('assertClaimExpectedUidReady 要求 claimIdentity', () => {
|
||||
assert.throws(
|
||||
() => assertClaimExpectedUidReady({ context_json: '{}' }),
|
||||
/填写游戏 UID/,
|
||||
)
|
||||
})
|
||||
@@ -36,7 +36,8 @@ export async function syncKuaishouCloudRoleInfo(task: TaskRow) {
|
||||
source: 'claim_page_polling',
|
||||
actor: { source: 'system' },
|
||||
recordEvent: false,
|
||||
forceProbe: flow.role.isDefaultRole === true,
|
||||
// 轮询阶段始终允许探测绑链,默认角色仅作诊断
|
||||
forceProbe: true,
|
||||
})
|
||||
return result.task || task
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { JsonObject } from './domain.js'
|
||||
|
||||
export type CloudDeliveryPlanItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
skuItem: JsonObject | null
|
||||
knapsackItem: JsonObject | null
|
||||
knapsackCount: number
|
||||
missingCount: number
|
||||
}
|
||||
|
||||
export function resolveKuaishouCloudDeliveryPlan(
|
||||
flow: JsonObject,
|
||||
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {},
|
||||
): { items: CloudDeliveryPlanItem[] } {
|
||||
const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : []
|
||||
const normalizedKnapsackItems = Array.isArray(knapsackItems)
|
||||
? knapsackItems.filter(isCloudSkuLikeItem)
|
||||
: []
|
||||
const deliveryItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : []
|
||||
|
||||
return {
|
||||
items: deliveryItems
|
||||
.map((item: unknown) => {
|
||||
const source = item && typeof item === 'object' ? (item as JsonObject) : {}
|
||||
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
|
||||
const quantity = Number(source.quantity || 1) || 1
|
||||
if (!cloudSkuId || !Number.isInteger(quantity) || quantity <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const skuItem = normalizedSkuItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null
|
||||
const knapsackItem =
|
||||
normalizedKnapsackItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null
|
||||
const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0)
|
||||
const cloudSkuName = String(
|
||||
source.cloudSkuName || source.skuName || skuItem?.name || knapsackItem?.name || '',
|
||||
).trim()
|
||||
|
||||
return {
|
||||
cloudSkuId,
|
||||
cloudSkuName,
|
||||
quantity,
|
||||
skuItem,
|
||||
knapsackItem,
|
||||
knapsackCount,
|
||||
missingCount: Math.max(0, quantity - knapsackCount),
|
||||
}
|
||||
})
|
||||
.filter((item): item is CloudDeliveryPlanItem => Boolean(item)),
|
||||
}
|
||||
}
|
||||
|
||||
function isCloudSkuLikeItem(item: unknown): item is JsonObject {
|
||||
const current = item && typeof item === 'object' ? (item as JsonObject) : {}
|
||||
return Number(current.id || 0) > 0
|
||||
}
|
||||
@@ -1,46 +1,69 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
hasKuaishouCloudCustomerRole,
|
||||
normalizeKuaishouCloudFlow,
|
||||
hasKuaishouCloudDefaultRoleSnapshot,
|
||||
isSameKuaishouCloudRoleIdentity,
|
||||
} from './domain.js'
|
||||
import { buildRoleStateFromCurrentInfo } from './role-state.js'
|
||||
import { normalizeKuaishouCloudFlow } from './domain.js'
|
||||
|
||||
test('hasKuaishouCloudCustomerRole 要求当前角色不同于虚拟机默认角色', () => {
|
||||
const defaultRoleFlow = normalizeKuaishouCloudFlow({
|
||||
test('hasKuaishouCloudCustomerRole 只要求已有绑定角色信息(UID 主闸后默认角色不再作门槛)', () => {
|
||||
const defaultRoleFlow = {
|
||||
binding: {
|
||||
vnPhone: '13800000000',
|
||||
roleName: '默认角色',
|
||||
roleId: '10001',
|
||||
roleName: '默认机位角色',
|
||||
roleId: 'DEFAULT-000',
|
||||
},
|
||||
role: {
|
||||
name: '默认角色',
|
||||
rid: '10001',
|
||||
defaultName: '默认角色',
|
||||
defaultRid: '10001',
|
||||
defaultCapturedAt: '2026-05-31T00:00:00.000Z',
|
||||
isDefaultRole: false,
|
||||
defaultName: '默认机位角色',
|
||||
defaultRid: 'DEFAULT-000',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
assert.equal(defaultRoleFlow.role.isDefaultRole, true)
|
||||
assert.equal(hasKuaishouCloudCustomerRole(defaultRoleFlow), false)
|
||||
assert.equal(hasKuaishouCloudCustomerRole(defaultRoleFlow), true)
|
||||
assert.equal(hasKuaishouCloudDefaultRoleSnapshot(defaultRoleFlow), true)
|
||||
|
||||
const customerRoleFlow = normalizeKuaishouCloudFlow({
|
||||
...defaultRoleFlow,
|
||||
const emptyFlow = {
|
||||
binding: {
|
||||
...defaultRoleFlow.binding,
|
||||
roleName: '用户角色',
|
||||
roleId: '20002',
|
||||
vnPhone: '13800000000',
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
},
|
||||
}
|
||||
assert.equal(hasKuaishouCloudCustomerRole(emptyFlow), false)
|
||||
})
|
||||
|
||||
test('buildRoleStateFromCurrentInfo 即使接近默认角色也 ready 并保留 rid', () => {
|
||||
const flow = normalizeKuaishouCloudFlow({
|
||||
role: {
|
||||
...defaultRoleFlow.role,
|
||||
name: '用户角色',
|
||||
rid: '20002',
|
||||
isDefaultRole: true,
|
||||
defaultName: '默认机位角色',
|
||||
defaultRid: 'DEFAULT-000',
|
||||
},
|
||||
binding: {},
|
||||
})
|
||||
const state = buildRoleStateFromCurrentInfo({
|
||||
flow,
|
||||
roleInfo: { name: '默认机位角色', rid: 'DEFAULT-000' },
|
||||
now: '2026-07-10T00:00:00.000Z',
|
||||
emptyMessage: 'empty',
|
||||
})
|
||||
|
||||
assert.equal(customerRoleFlow.role.isDefaultRole, false)
|
||||
assert.equal(hasKuaishouCloudCustomerRole(customerRoleFlow), true)
|
||||
assert.equal(state.hasRoleInfo, true)
|
||||
assert.equal(state.isDefaultRole, true)
|
||||
assert.equal(state.role.status, 'ready')
|
||||
assert.equal(state.bindingRoleId, 'DEFAULT-000')
|
||||
assert.match(state.role.errorMessage, /UID/)
|
||||
})
|
||||
|
||||
test('isSameKuaishouCloudRoleIdentity prefers rid', () => {
|
||||
assert.equal(
|
||||
isSameKuaishouCloudRoleIdentity({ name: 'a', rid: '1' }, { name: 'b', rid: '1' }),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isSameKuaishouCloudRoleIdentity({ name: 'a', rid: '1' }, { name: 'a', rid: '2' }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -141,20 +141,19 @@ export function hasKuaishouCloudDefaultRoleSnapshot(flowLike: unknown) {
|
||||
return Boolean(flow.role.defaultName || flow.role.defaultRid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已有可展示的绑定角色信息(诊断用)。
|
||||
*
|
||||
* 注意:履约主闸已改为 claimIdentity.expectedUid 匹配;
|
||||
* 本函数不再要求「当前角色必须不同于虚拟机默认角色」。
|
||||
*/
|
||||
export function hasKuaishouCloudCustomerRole(flowLike: unknown) {
|
||||
const flow = normalizeKuaishouCloudFlow(flowLike)
|
||||
if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) {
|
||||
if (!flow.binding.vnPhone) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hasKuaishouCloudDefaultRoleSnapshot(flow)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !isSameKuaishouCloudRoleIdentity(
|
||||
{ name: flow.binding.roleName, rid: flow.binding.roleId },
|
||||
{ name: flow.role.defaultName, rid: flow.role.defaultRid },
|
||||
)
|
||||
return Boolean(flow.binding.roleName || flow.binding.roleId)
|
||||
}
|
||||
|
||||
export function isSameKuaishouCloudRoleIdentity(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
import { getTaskClaimExpiresAt, isClaimExpired } from './task-context.js'
|
||||
|
||||
export async function ensureTaskClaimLink(task: TaskRow) {
|
||||
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
|
||||
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
|
||||
const expiredAt = getTaskClaimExpiresAt(task)
|
||||
|
||||
if (tokenStatus === 'active' && token && !isClaimExpired(expiredAt)) {
|
||||
return {
|
||||
token,
|
||||
expiredAt,
|
||||
claimUrl: buildClaimUrl(token),
|
||||
}
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
return {
|
||||
token: claimToken.token,
|
||||
expiredAt: claimToken.expired_at,
|
||||
claimUrl: claimToken.claimUrl,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { probeCloudtentaclesBindUrl } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js'
|
||||
import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import {
|
||||
isKuaishouCloudBindUrlFresh,
|
||||
isKuaishouCloudTask,
|
||||
normalizeKuaishouCloudFlow,
|
||||
normalizeKuaishouCloudRoleInfo,
|
||||
type JsonObject,
|
||||
} from './domain.js'
|
||||
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
|
||||
import { normalizeActor, parseTaskContext } from './task-context.js'
|
||||
import { buildRoleStateFromCurrentInfo } from './role-state.js'
|
||||
import { refreshKuaishouCloudTaskBindUrl } from './prepare-fulfillment.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_cloud_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
|
||||
if (!flow.binding.bindUrl) {
|
||||
return {
|
||||
task,
|
||||
flow,
|
||||
probe: null as null,
|
||||
}
|
||||
}
|
||||
|
||||
const probeIntervalMs =
|
||||
Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) * 1000
|
||||
if (
|
||||
!options.force &&
|
||||
flow.binding.bindProbeAt &&
|
||||
Date.now() - Date.parse(flow.binding.bindProbeAt) < probeIntervalMs
|
||||
) {
|
||||
return {
|
||||
task,
|
||||
flow,
|
||||
probe: null as null,
|
||||
}
|
||||
}
|
||||
|
||||
const probe = await probeCloudtentaclesBindUrl({
|
||||
bindUrl: flow.binding.bindUrl,
|
||||
})
|
||||
|
||||
if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) {
|
||||
const refreshed = await refreshKuaishouCloudTaskBindUrl(task, {
|
||||
source: options.source || 'claim_page_bind_url_expired',
|
||||
actor: options.actor || { source: 'system' },
|
||||
})
|
||||
return {
|
||||
...refreshed,
|
||||
probe,
|
||||
}
|
||||
}
|
||||
|
||||
const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo)
|
||||
const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid)
|
||||
const roleState = hasRoleInfo
|
||||
? buildRoleStateFromCurrentInfo({
|
||||
flow,
|
||||
roleInfo: probeRoleInfo,
|
||||
now,
|
||||
emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试',
|
||||
})
|
||||
: null
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
binding: {
|
||||
...flow.binding,
|
||||
bindProbeAt: now,
|
||||
bindProbeStatus: probe.valid ? 'valid' : 'invalid',
|
||||
bindProbeMessage: String(probe.message || probe.reason || '').trim(),
|
||||
roleName: roleState ? roleState.bindingRoleName : flow.binding.roleName,
|
||||
roleId: roleState ? roleState.bindingRoleId : flow.binding.roleId,
|
||||
},
|
||||
role: roleState ? roleState.role : flow.role,
|
||||
},
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
role_id: roleState && !roleState.isDefaultRole ? probeRoleInfo.rid : task.role_id,
|
||||
role_name: roleState && !roleState.isDefaultRole ? probeRoleInfo.name : task.role_name,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||
probe,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} task
|
||||
* @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input]
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { buildClaimUrl } from '../../claim/claim-service.js'
|
||||
import { backCloudtentaclesVirtualNumber } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js'
|
||||
import {
|
||||
isKuaishouCloudTask,
|
||||
maskPhone,
|
||||
normalizeKuaishouCloudFlow,
|
||||
resolveKuaishouCloudBindUrlExpiresAt,
|
||||
type JsonObject,
|
||||
} from './domain.js'
|
||||
import {
|
||||
prepareKuaishouCloudBindResourceWithFallback,
|
||||
resolveKuaishouCloudVnKeyCandidates,
|
||||
} from './binding-resources.js'
|
||||
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
|
||||
import {
|
||||
getTaskClaimExpiresAt,
|
||||
normalizeActor,
|
||||
parseTaskContext,
|
||||
} from './task-context.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 rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) {
|
||||
if (!isKuaishouCloudTask(task)) {
|
||||
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
|
||||
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 defaultRoleSnapshot = 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: nextBindExpiresAt,
|
||||
bindProbeAt: null,
|
||||
bindProbeStatus: 'pending',
|
||||
bindProbeMessage: '',
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
},
|
||||
role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot),
|
||||
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,
|
||||
defaultRoleName: defaultRoleSnapshot.defaultName,
|
||||
defaultRoleId: defaultRoleSnapshot.defaultRid,
|
||||
defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus,
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
token: claimLinkState.token,
|
||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import {
|
||||
isKuaishouCloudTask,
|
||||
normalizeKuaishouCloudFlow,
|
||||
normalizeKuaishouCloudRoleInfo,
|
||||
type JsonObject,
|
||||
} from './domain.js'
|
||||
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
|
||||
import { normalizeActor, parseTaskContext } from './task-context.js'
|
||||
import { buildRoleStateFromCurrentInfo } from './role-state.js'
|
||||
import { probeKuaishouCloudTaskBindUrl } from './probe-bind-url.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
export async function refreshKuaishouCloudTaskRoleInfo(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_info_context',
|
||||
})
|
||||
}
|
||||
|
||||
if (flow.binding.bindUrl) {
|
||||
const probed = await probeKuaishouCloudTaskBindUrl(task, {
|
||||
source: options.source || 'system_role_refresh_bind_url_probe',
|
||||
actor,
|
||||
force: options.forceProbe === true,
|
||||
})
|
||||
const probedFlow = normalizeKuaishouCloudFlow(
|
||||
parseTaskContext(probed.task).kuaishouCloudFulfillment,
|
||||
)
|
||||
if (probed.probe?.expired) {
|
||||
return {
|
||||
task: probed.task,
|
||||
roleInfo: normalizeKuaishouCloudRoleInfo(null),
|
||||
flow: probedFlow,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(probed.probe === null || probed.probe?.valid) &&
|
||||
(probedFlow.binding.roleName || probedFlow.binding.roleId)
|
||||
) {
|
||||
return {
|
||||
task: probed.task,
|
||||
roleInfo: {
|
||||
name: probedFlow.binding.roleName,
|
||||
rid: probedFlow.binding.roleId,
|
||||
rawInfo: probedFlow.role.rawInfo,
|
||||
},
|
||||
flow: probedFlow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([
|
||||
flow.binding.resolvedSourceKey,
|
||||
...flow.binding.cloudSourceKeys,
|
||||
])
|
||||
const bindInfoResult = await getCloudtentaclesBindInfo({
|
||||
...cloudContext,
|
||||
key: flow.binding.vnKey,
|
||||
id: flow.binding.vnId,
|
||||
})
|
||||
|
||||
const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo)
|
||||
const roleState = buildRoleStateFromCurrentInfo({
|
||||
flow,
|
||||
roleInfo: bindInfo,
|
||||
now,
|
||||
emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试',
|
||||
})
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
binding: {
|
||||
...flow.binding,
|
||||
roleName: roleState.bindingRoleName,
|
||||
roleId: roleState.bindingRoleId,
|
||||
},
|
||||
role: roleState.role,
|
||||
},
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
// UID 主闸:有绑定角色即落库,默认角色仅诊断
|
||||
role_id: roleState.hasRoleInfo ? bindInfo.rid : '',
|
||||
role_name: roleState.hasRoleInfo ? bindInfo.name : '',
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (options.recordEvent !== false) {
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_cloud_role_info_refreshed',
|
||||
{
|
||||
source: String(options.source || 'system').trim() || 'system',
|
||||
roleName: bindInfo.name,
|
||||
roleId: bindInfo.rid,
|
||||
vnId: flow.binding.vnId,
|
||||
actor,
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
task: updatedTask,
|
||||
roleInfo: bindInfo,
|
||||
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 角色状态与默认角色快照(诊断用)。
|
||||
* 履约主闸已改为 claimIdentity.expectedUid;默认角色不再作为 ready 硬门槛。
|
||||
*/
|
||||
import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import {
|
||||
hasKuaishouCloudDefaultRoleSnapshot,
|
||||
isSameKuaishouCloudRoleIdentity,
|
||||
normalizeKuaishouCloudFlow,
|
||||
normalizeKuaishouCloudRoleInfo,
|
||||
type JsonObject,
|
||||
} from './domain.js'
|
||||
|
||||
export type KuaishouCloudDefaultRoleSnapshot = {
|
||||
defaultName: string
|
||||
defaultRid: string
|
||||
defaultCapturedAt: string | null
|
||||
defaultCaptureStatus: string
|
||||
defaultErrorMessage: string
|
||||
}
|
||||
|
||||
export async function captureKuaishouCloudDefaultRoleSnapshot({
|
||||
cloudContext,
|
||||
preparedBinding,
|
||||
now,
|
||||
}: {
|
||||
cloudContext: JsonObject
|
||||
preparedBinding: JsonObject
|
||||
now: string
|
||||
}): Promise<KuaishouCloudDefaultRoleSnapshot> {
|
||||
try {
|
||||
const bindInfoResult = await getCloudtentaclesBindInfo({
|
||||
...cloudContext,
|
||||
key: preparedBinding.vnKey,
|
||||
id: preparedBinding.vnId,
|
||||
})
|
||||
const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo)
|
||||
const hasRole = Boolean(roleInfo.name || roleInfo.rid)
|
||||
|
||||
return {
|
||||
defaultName: roleInfo.name,
|
||||
defaultRid: roleInfo.rid,
|
||||
defaultCapturedAt: hasRole ? now : null,
|
||||
defaultCaptureStatus: hasRole ? 'captured' : 'empty',
|
||||
defaultErrorMessage: hasRole ? '' : '未获取到虚拟机默认角色信息,请稍后刷新',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
defaultName: '',
|
||||
defaultRid: '',
|
||||
defaultCapturedAt: null,
|
||||
defaultCaptureStatus: 'failed',
|
||||
defaultErrorMessage:
|
||||
error instanceof Error ? error.message : '获取虚拟机默认角色信息失败,请稍后刷新',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPendingRoleWithDefaultSnapshot(snapshot: KuaishouCloudDefaultRoleSnapshot) {
|
||||
return {
|
||||
status: 'pending',
|
||||
name: '',
|
||||
rid: '',
|
||||
refreshedAt: null as null,
|
||||
errorMessage: snapshot.defaultErrorMessage || '',
|
||||
rawInfo: null as null,
|
||||
defaultName: snapshot.defaultName,
|
||||
defaultRid: snapshot.defaultRid,
|
||||
defaultCapturedAt: snapshot.defaultCapturedAt,
|
||||
defaultCaptureStatus: snapshot.defaultCaptureStatus,
|
||||
defaultErrorMessage: snapshot.defaultErrorMessage,
|
||||
isDefaultRole: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRoleStateFromCurrentInfo({
|
||||
flow,
|
||||
roleInfo,
|
||||
now,
|
||||
emptyMessage,
|
||||
}: {
|
||||
flow: ReturnType<typeof normalizeKuaishouCloudFlow>
|
||||
roleInfo: { name: string; rid: string; rawInfo?: unknown }
|
||||
now: string
|
||||
emptyMessage: string
|
||||
}) {
|
||||
const hasRoleInfo = Boolean(roleInfo.name || roleInfo.rid)
|
||||
const hasDefaultRole = hasKuaishouCloudDefaultRoleSnapshot(flow)
|
||||
const isDefaultRole =
|
||||
hasRoleInfo &&
|
||||
hasDefaultRole &&
|
||||
isSameKuaishouCloudRoleIdentity(
|
||||
{ name: roleInfo.name, rid: roleInfo.rid },
|
||||
{ name: flow.role.defaultName, rid: flow.role.defaultRid },
|
||||
)
|
||||
|
||||
// UID 主闸:只要有绑定角色信息就视为 ready 并落库 rid;
|
||||
// isDefaultRole 仅作诊断文案,不再清空 roleId。
|
||||
return {
|
||||
hasRoleInfo,
|
||||
isDefaultRole,
|
||||
bindingRoleName: hasRoleInfo ? roleInfo.name : '',
|
||||
bindingRoleId: hasRoleInfo ? roleInfo.rid : '',
|
||||
role: {
|
||||
...flow.role,
|
||||
status: hasRoleInfo ? 'ready' : 'pending',
|
||||
name: hasRoleInfo ? roleInfo.name : '',
|
||||
rid: hasRoleInfo ? roleInfo.rid : '',
|
||||
refreshedAt: now,
|
||||
errorMessage: hasRoleInfo
|
||||
? isDefaultRole
|
||||
? '当前绑定接近虚拟机默认角色,请确认已绑定自己的角色,并与填写 UID 一致'
|
||||
: ''
|
||||
: emptyMessage,
|
||||
rawInfo: roleInfo.rawInfo || null,
|
||||
isDefaultRole,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export async function ensureShortLinkForTarget(
|
||||
): Promise<ShortLinkPayload> {
|
||||
const normalizedTargetUrl = normalizeTargetUrl(targetUrl)
|
||||
const source = String(options.source || '').trim()
|
||||
if (source === SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM) {
|
||||
console.warn(
|
||||
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /#/claim/{token} 并在 H5 拼 uid',
|
||||
)
|
||||
}
|
||||
const targetUrlHash = hashTargetUrl(normalizedTargetUrl)
|
||||
const now = nowIso()
|
||||
const expiresAt = options.expiresAt !== undefined
|
||||
|
||||
Reference in New Issue
Block a user