后端迁移后台管理快手Cloud写侧
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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<string, any>
|
||||
|
||||
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<PreparedKuaishouCloudBindResource> {
|
||||
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('不支持的游戏类型')
|
||||
}
|
||||
Reference in New Issue
Block a user