From d6b0e45c7b034df458a2168f5a349cd55c40c11d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 10 Jul 2026 14:01:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=81=BF=E8=BF=98=20lewan=20=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=80=BA=EF=BC=9A=E6=A8=A1=E5=9D=97=E6=8B=86=E5=88=86=E3=80=81?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=A7=92=E8=89=B2=E9=99=8D=E7=BA=A7=E4=B8=8E?= =?UTF-8?q?=E7=9F=AD=E9=93=BE=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 kuaishou-cloud 大入口拆为 prepare/rebind/probe/refresh 等模块;默认角色仅作诊断;补充兑换状态单测;短链明确只读兼容历史链接。 --- apps/backend/src/routes/short-links.ts | 4 + .../claim/claim-redeem-transition.test.ts | 97 ++ .../claim/kuaishou-cloud-sync-service.ts | 3 +- .../kuaishou-cloud/delivery-plan.ts | 58 + .../fulfillment/kuaishou-cloud/domain.test.ts | 75 +- .../fulfillment/kuaishou-cloud/domain.ts | 17 +- .../kuaishou-cloud/ensure-claim-link.ts | 24 + .../fulfillment/kuaishou-cloud/index.ts | 1166 +---------------- .../kuaishou-cloud/prepare-fulfillment.ts | 480 +++++++ .../kuaishou-cloud/probe-bind-url.ts | 177 +++ .../fulfillment/kuaishou-cloud/rebind-role.ts | 312 +++++ .../kuaishou-cloud/refresh-role-info.ts | 129 ++ .../fulfillment/kuaishou-cloud/role-state.ts | 119 ++ .../short-links/short-link-service.ts | 5 + docs/履约配置/工程债-lewan拆分与短链.md | 32 + 15 files changed, 1508 insertions(+), 1190 deletions(-) create mode 100644 apps/backend/src/services/claim/claim-redeem-transition.test.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts create mode 100644 docs/履约配置/工程债-lewan拆分与短链.md diff --git a/apps/backend/src/routes/short-links.ts b/apps/backend/src/routes/short-links.ts index ec44cf48..831f47c8 100644 --- a/apps/backend/src/routes/short-links.ts +++ b/apps/backend/src/routes/short-links.ts @@ -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' diff --git a/apps/backend/src/services/claim/claim-redeem-transition.test.ts b/apps/backend/src/services/claim/claim-redeem-transition.test.ts new file mode 100644 index 00000000..60962ef1 --- /dev/null +++ b/apps/backend/src/services/claim/claim-redeem-transition.test.ts @@ -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/, + ) +}) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index b947afa9..3db29112 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -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 { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts new file mode 100644 index 00000000..4bbd9a67 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts @@ -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 +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts index 7e6bb267..8524787c 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts @@ -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, + ) }) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index d8318367..9bbfeffe 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -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( diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts new file mode 100644 index 00000000..9c25bfd8 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts @@ -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, + } +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts index 84ff1986..bb1343f4 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -1,67 +1,7 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' -import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js' -import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' -import { - backCloudtentaclesVirtualNumber, - getCloudtentaclesBindInfo, - 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, normalizeTaskStatus } from '../../../domain/task-status.js' -import { - KUAISHOU_CLOUD_FIXED_VN_KEY, - hasKuaishouCloudCustomerRole, - hasKuaishouCloudDefaultRoleSnapshot, - isKuaishouCloudBindUrlFresh, - isKuaishouCloudTask, - isSameKuaishouCloudRoleIdentity, - maskPhone, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - resolveKuaishouCloudBindUrlExpiresAt, - type JsonObject, -} from './domain.js' -import { - prepareKuaishouCloudBindResourceWithFallback, - resolveKuaishouCloudBindingResources, - resolveKuaishouCloudVnKeyCandidates, -} from './binding-resources.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { - buildCloudtentaclesSourceSelectionEventDetail, - selectCloudtentaclesSourceForFulfillment, -} from './account-selector.js' -import { - getTaskClaimExpiresAt, - isClaimExpired, - normalizeActor, - parseTaskContext, -} from './task-context.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -type CloudDeliveryPlanItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number - skuItem: JsonObject | null - knapsackItem: JsonObject | null - knapsackCount: number - missingCount: number -} - -type KuaishouCloudDefaultRoleSnapshot = { - defaultName: string - defaultRid: string - defaultCapturedAt: string | null - defaultCaptureStatus: string - defaultErrorMessage: string -} - +/** + * kuaishou-lewan(cloud)履约入口。 + * 实现已按职责拆分到 prepare / rebind / probe / refresh-role 等模块。 + */ export { isKuaishouCloudBindUrlFresh, isKuaishouCloudTask, @@ -75,1096 +15,14 @@ export { resolveKuaishouCloudBindUrlExpiresAt, } from './domain.js' export { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' - -async function captureKuaishouCloudDefaultRoleSnapshot({ - cloudContext, - preparedBinding, - now, -}: { - cloudContext: JsonObject - preparedBinding: JsonObject - now: string -}): Promise { - 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 : '获取虚拟机默认角色信息失败,请稍后刷新', - } - } -} - -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, - } -} - -function buildRoleStateFromCurrentInfo({ - flow, - roleInfo, - now, - emptyMessage, -}: { - flow: ReturnType - 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 }, - ) - - return { - hasRoleInfo, - isDefaultRole, - bindingRoleName: hasRoleInfo ? roleInfo.name : '', - bindingRoleId: hasRoleInfo ? roleInfo.rid : '', - role: { - ...flow.role, - status: hasRoleInfo && !isDefaultRole ? 'ready' : 'pending', - name: hasRoleInfo ? roleInfo.name : '', - rid: hasRoleInfo ? roleInfo.rid : '', - refreshedAt: now, - errorMessage: hasRoleInfo - ? isDefaultRole - ? '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' - : '' - : emptyMessage, - rawInfo: roleInfo.rawInfo || null, - isDefaultRole, - }, - } -} - -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, - } -} - -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), - } -} - -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), - } -} - -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 -} - -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, { - role_id: roleState.hasRoleInfo && !roleState.isDefaultRole ? bindInfo.rid : '', - role_name: roleState.hasRoleInfo && !roleState.isDefaultRole ? 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), - } -} - -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 -} - +export { ensureTaskClaimLink } from './ensure-claim-link.js' +export { + prepareKuaishouCloudFulfillmentTask, + refreshKuaishouCloudTaskBindUrl, +} from './prepare-fulfillment.js' +export { rebindKuaishouCloudTaskRole } from './rebind-role.js' +export { probeKuaishouCloudTaskBindUrl } from './probe-bind-url.js' +export { refreshKuaishouCloudTaskRoleInfo } from './refresh-role-info.js' export { dispatchKuaishouCloudFulfillmentTask, returnKuaishouCloudFulfillmentTask, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts new file mode 100644 index 00000000..849fbe88 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts @@ -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 +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts new file mode 100644 index 00000000..cf2d67e1 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts @@ -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 +} + diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts new file mode 100644 index 00000000..e6248f83 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts @@ -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), + } +} + diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts new file mode 100644 index 00000000..4c5411d2 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts @@ -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), + } +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts new file mode 100644 index 00000000..05bbf9e2 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts @@ -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 { + 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 + 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, + }, + } +} diff --git a/apps/backend/src/services/short-links/short-link-service.ts b/apps/backend/src/services/short-links/short-link-service.ts index f66d3e3d..9e84252f 100644 --- a/apps/backend/src/services/short-links/short-link-service.ts +++ b/apps/backend/src/services/short-links/short-link-service.ts @@ -44,6 +44,11 @@ export async function ensureShortLinkForTarget( ): Promise { 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 diff --git a/docs/履约配置/工程债-lewan拆分与短链.md b/docs/履约配置/工程债-lewan拆分与短链.md new file mode 100644 index 00000000..5d9313d0 --- /dev/null +++ b/docs/履约配置/工程债-lewan拆分与短链.md @@ -0,0 +1,32 @@ +# 工程债:lewan 拆分、默认角色、短链 + +## lewan 模块拆分(已做) + +```text +kuaishou-cloud/ + index.ts # barrel 导出 + prepare-fulfillment.ts # prepare + 刷新 bindUrl + rebind-role.ts + probe-bind-url.ts + refresh-role-info.ts + role-state.ts # 默认角色快照 + ready 状态(UID 主闸) + delivery-plan.ts + ensure-claim-link.ts + dispatch-role-sync.ts + task-finalization.ts # dispatch / return(仍厚,后续可再拆) + domain.ts +``` + +对外 import 路径仍为 `.../kuaishou-cloud/index.js`。 + +## 默认角色 + +- 主闸:`claimIdentity.expectedUid` 与绑定 `rid` 一致。 +- `isDefaultRole` 仅诊断文案;有角色信息即 `ready` 并落库 rid。 +- `hasKuaishouCloudCustomerRole` 改为「是否已有绑定角色」,不再要求不同于默认角色。 + +## 短链 + +- `/s/:code` **只读解析**历史链接。 +- feifei claim **不再**依赖 shortLink 交付;若仍调用 `ensureShortLinkForTarget` 且 source 为 feifei claim,会打 warn。 +- 新业务:本站 claimUrl + H5 拼 `uid`。