import { normalizeProductName } from '../../order/product-match-service.js' import { getCloudtentaclesSourceConfig } from '../../platforms/cloudtentacles/source-config-service.js' import { getCloudtentaclesSessionState } from '../../platforms/cloudtentacles/session-state-service.js' import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/shared.js' import { appointCloudtentaclesVirtualNumber, backCloudtentaclesVirtualNumber, fetchCloudtentaclesVirtualNumberCode, generateCloudtentaclesLoginCode, getCloudtentaclesBindUrl, verifyCloudtentaclesLoginCode, } from '../../platforms/cloudtentacles/virtual-number-service.js' import { createHttpError } from '../../../utils/http.js' export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1' export type JsonObject = Record export type CloudtentaclesContext = { baseUrl: string token: string deviceId: string deviceType: number } export type CloudSkuLikeItem = { id?: number | string name?: string price?: number | string count?: number | string [key: string]: unknown } export type KuaishouCloudFlow = JsonObject & { configId: string internalSkuCode: string internalSkuName: string ticket: JsonObject binding: JsonObject role: JsonObject purchase: JsonObject dispatch: JsonObject returnNumber: JsonObject consume: JsonObject } export type KuaishouCloudBindingResources = { skuId: number skuName: string vnKey: string skuItem: CloudSkuLikeItem | null knapsackItem: CloudSkuLikeItem | null resolvedByName: boolean } export type PreparedKuaishouCloudBindResource = { vnKey: string vnId: number vnPhone: string bindUrl: string } export function isKuaishouCloudTask(task: unknown): boolean { const record: JsonObject = task && typeof task === 'object' ? task : {} return String(record.executor_key || '').trim() === 'kuaishou_ct_assisted' } export function resolvePersistedCloudtentaclesContext(): CloudtentaclesContext { const source = getCloudtentaclesSourceConfig() const session = getCloudtentaclesSessionState() const token = String(session.token || '').trim() if (!token) { throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token', }) } return { baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', token, deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', deviceType: Number(session.deviceType ?? source.deviceType ?? 0), } } export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow { const source: JsonObject = value && typeof value === 'object' ? value : {} const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} const role = source.role && typeof source.role === 'object' ? source.role : {} const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} const bindPreparedAt = binding.bindPreparedAt || null return { ...source, configId: String(source.configId || '').trim(), internalSkuCode: String(source.internalSkuCode || '').trim(), internalSkuName: String(source.internalSkuName || '').trim(), ticket: { code: String(ticket.code || '').trim(), status: String(ticket.status || 'pending').trim() || 'pending', capturedAt: ticket.capturedAt || null, capturedBy: ticket.capturedBy || null, verifiedAt: ticket.verifiedAt || null, oid: String(ticket.oid || '').trim(), formToken: String(ticket.formToken || '').trim(), leftCount: Number(ticket.leftCount || 0) || 0, goodsTitle: String(ticket.goodsTitle || '').trim(), }, binding: { prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', cloudSourceKey: String(binding.cloudSourceKey || 'default').trim() || 'default', skuId: Number(binding.skuId || 0) || 0, skuName: String(binding.skuName || '').trim(), vnKey: String(binding.vnKey || '').trim(), vnId: Number(binding.vnId || 0) || 0, vnPhone: String(binding.vnPhone || '').trim(), bindUrl: String(binding.bindUrl || '').trim(), bindPreparedAt, bindExpiresAt: binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt), bindProbeAt: binding.bindProbeAt || null, bindProbeStatus: String(binding.bindProbeStatus || '').trim(), bindProbeMessage: String(binding.bindProbeMessage || '').trim(), roleName: String(binding.roleName || '').trim(), roleId: String(binding.roleId || '').trim(), }, role: { status: String(role.status || 'pending').trim() || 'pending', name: String(role.name || '').trim(), rid: String(role.rid || '').trim(), refreshedAt: role.refreshedAt || null, errorMessage: String(role.errorMessage || '').trim(), rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null, }, purchase: { autoBuyEnabled: purchase.autoBuyEnabled !== false, minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, usedKnapsack: purchase.usedKnapsack === true, purchaseTriggered: purchase.purchaseTriggered === true, assetBefore: Number(purchase.assetBefore || 0) || 0, assetAfter: Number(purchase.assetAfter || 0) || 0, purchaseAt: purchase.purchaseAt || null, }, dispatch: { status: String(dispatch.status || 'pending').trim() || 'pending', dispatchAt: dispatch.dispatchAt || null, dispatchBy: dispatch.dispatchBy || null, sendType: Number(dispatch.sendType || 0) || 0, note: String(dispatch.note || '').trim(), }, returnNumber: { status: String(returnNumber.status || 'pending').trim() || 'pending', returnedAt: returnNumber.returnedAt || null, returnedBy: returnNumber.returnedBy || null, autoReturnEnabled: returnNumber.autoReturnEnabled === true, }, consume: { status: String(consume.status || 'pending').trim() || 'pending', shopId: String(consume.shopId || '').trim(), shopName: String(consume.shopName || '').trim(), autoConsumeEnabled: consume.autoConsumeEnabled === true, consumedAt: consume.consumedAt || null, errorMessage: String(consume.errorMessage || '').trim(), }, } } export function normalizeKuaishouCloudRoleInfo(value: unknown): { name: string, rid: string, rawInfo: JsonObject | null } { const rawInfo: JsonObject | null = value && typeof value === 'object' ? value : null const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null const source = nestedBindInfo || rawInfo return { name: String(source?.name || source?.roleName || source?.nickname || source?.sRoleName || '').trim(), rid: String(source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '').trim(), rawInfo, } } export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown): string | null { const preparedTime = Date.parse(String(preparedAt || '')) if (!Number.isFinite(preparedTime)) { return null } const config = resolveCloudtentaclesConfig() const ttlSeconds = Number(config.bindUrlTtlSeconds || 600) return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString() } export function resolveKuaishouCloudBindingResources( flow: KuaishouCloudFlow, { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {}, ): KuaishouCloudBindingResources { const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : [] const normalizedKnapsackItems = Array.isArray(knapsackItems) ? knapsackItems.filter(isCloudSkuLikeItem) : [] const currentSkuId = Number(flow?.binding?.skuId || 0) || 0 const currentSkuName = String(flow?.binding?.skuName || '').trim() const nameCandidates = collectKuaishouCloudNameCandidates(flow) const skuItemById = currentSkuId > 0 ? normalizedSkuItems.find((item) => Number(item.id || 0) === currentSkuId) || null : null const knapsackItemById = currentSkuId > 0 ? normalizedKnapsackItems.find((item) => Number(item.id || 0) === currentSkuId) || null : null if (skuItemById || knapsackItemById) { const matchedItem = skuItemById || knapsackItemById return { skuId: Number(matchedItem?.id || 0) || 0, skuName: String(currentSkuName || matchedItem?.name || '').trim(), vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, skuItem: skuItemById, knapsackItem: knapsackItemById, resolvedByName: false, } } const matchedSkuItem = findCloudItemByNames(normalizedSkuItems, nameCandidates) const matchedKnapsackItem = findCloudItemByNames( normalizedKnapsackItems, nameCandidates, matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0, ) const matchedItem = matchedSkuItem || matchedKnapsackItem return { skuId: Number(matchedItem?.id || 0) || 0, skuName: String(currentSkuName || matchedItem?.name || '').trim(), vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, skuItem: matchedSkuItem, knapsackItem: matchedKnapsackItem, resolvedByName: Boolean(matchedItem), } } export function resolveKuaishouCloudVnKeyCandidates(input: { flow?: KuaishouCloudFlow, binding?: KuaishouCloudBindingResources } = {}): string[] { return [KUAISHOU_CLOUD_FIXED_VN_KEY] } export async function prepareKuaishouCloudBindResourceWithFallback(input: { cloudContext?: CloudtentaclesContext vnKeyCandidates?: string[] } = {}): Promise { const { cloudContext = {}, vnKeyCandidates = [] } = input const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [] let lastError: unknown = null for (const vnKey of candidates) { let vnId = 0 let vnPhone = '' try { const appointed = await appointCloudtentaclesVirtualNumber({ ...cloudContext, key: vnKey, }) vnId = Number(appointed.item?.id || 0) vnPhone = String(appointed.item?.phone || '').trim() if (!vnId || !vnPhone) { throw createHttpError('申请虚拟号成功但返回数据不完整', { statusCode: 502, errorCode: 'admin_task_kuaishou_cloud_invalid_vn', }) } await generateCloudtentaclesLoginCode({ ...cloudContext, key: vnKey, id: vnId, }) const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({ ...cloudContext, key: vnKey, phone: vnPhone, }) await verifyCloudtentaclesLoginCode({ ...cloudContext, key: vnKey, id: vnId, code: fetchedCode.code, }) const bindUrlResult = await getCloudtentaclesBindUrl({ ...cloudContext, key: vnKey, id: vnId, }) return { vnKey, vnId, vnPhone, bindUrl: String(bindUrlResult.bindUrl || '').trim(), } } catch (error) { lastError = error if (vnId > 0) { try { await backCloudtentaclesVirtualNumber({ ...cloudContext, key: vnKey, id: vnId, }) } catch { // 兜底退号失败时保留原始错误,避免吞掉主链路异常 } } if (!isRecoverableKuaishouCloudVnKeyError(error)) { throw error } } } throw lastError || createHttpError('没有找到可用的 VN Key', { statusCode: 409, errorCode: 'admin_task_kuaishou_cloud_missing_binding_config', }) } function collectKuaishouCloudNameCandidates(flow: KuaishouCloudFlow): string[] { return Array.from(new Set([ String(flow?.binding?.skuName || '').trim(), String(flow?.internalSkuName || '').trim(), String(flow?.internalSkuCode || '').trim(), ].filter(Boolean))) } function findCloudItemByNames(items: CloudSkuLikeItem[], nameCandidates: string[], preferredId = 0): CloudSkuLikeItem | null { const normalizedItems = Array.isArray(items) ? items : [] const normalizedNames = nameCandidates .map((item) => ({ raw: String(item || '').trim(), normalized: normalizeProductName(item), })) .filter((item) => item.raw && item.normalized) if (normalizedNames.length === 0 || normalizedItems.length === 0) { return preferredId > 0 ? normalizedItems.find((item) => Number(item.id || 0) === preferredId) || null : null } if (preferredId > 0) { const preferred = normalizedItems.find((item) => Number(item.id || 0) === preferredId) || null if (preferred) { return preferred } } const exactMatches = normalizedItems.filter((item) => { const itemName = normalizeProductName(item.name) return normalizedNames.some((candidate) => candidate.normalized === itemName) }) if (exactMatches.length > 0) { return exactMatches[0] } const partialMatches = normalizedItems.filter((item) => { const itemName = normalizeProductName(item.name) return normalizedNames.some((candidate) => itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName)) }) if (partialMatches.length > 0) { return partialMatches.sort((left, right) => String(left.name || '').length - String(right.name || '').length)[0] } return null } function isCloudSkuLikeItem(item: unknown): item is CloudSkuLikeItem { const record: JsonObject = item && typeof item === 'object' ? item : {} return Number(record.id || 0) > 0 } function isRecoverableKuaishouCloudVnKeyError(error: any): boolean { const errorCode = String(error?.errorCode || error?.code || '').trim() const errorMessage = String(error?.message || '').trim() return errorCode === 'cloudtentacles_vn_bind_url_failed' && errorMessage.includes('不支持的游戏类型') }