303 lines
9.1 KiB
TypeScript
303 lines
9.1 KiB
TypeScript
import type { JsonObject } from '../../types/json.js'
|
|
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js'
|
|
import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js'
|
|
import {
|
|
normalizeCloudtentaclesDeviceId,
|
|
normalizeCloudtentaclesDeviceType,
|
|
} from '../platforms/cloudtentacles/defaults.js'
|
|
import { listCloudtentaclesSku } from '../platforms/cloudtentacles/catalog-service.js'
|
|
import {
|
|
resolveCloudtentaclesOverrideRule,
|
|
type CloudtentaclesOverrideRule,
|
|
} from './cloudtentacles-override-rule-service.js'
|
|
import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
|
|
|
|
type CloudtentaclesSource = {
|
|
key?: string
|
|
label?: string
|
|
enabled?: boolean
|
|
baseUrl?: string
|
|
deviceId?: string
|
|
deviceType?: number
|
|
}
|
|
|
|
type CloudtentaclesSession = {
|
|
token?: string
|
|
baseUrl?: string
|
|
deviceId?: string
|
|
deviceType?: number
|
|
}
|
|
|
|
type CloudtentaclesSku = {
|
|
id?: number
|
|
name?: string
|
|
inventory?: number
|
|
price?: number
|
|
[key: string]: unknown
|
|
}
|
|
|
|
type CloudtentaclesNameMatchDeps = {
|
|
listCloudtentaclesSources?: typeof listCloudtentaclesSources
|
|
getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey
|
|
listCloudtentaclesSku?: typeof listCloudtentaclesSku
|
|
resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule
|
|
}
|
|
|
|
export type CloudtentaclesResolvedDeliveryItem = {
|
|
cloudSkuId: number
|
|
cloudSkuName: string
|
|
quantity: number
|
|
}
|
|
|
|
export type CloudtentaclesNameMatchResult = {
|
|
matchMode: 'cloudtentacles_name' | 'cloudtentacles_override'
|
|
productName: string
|
|
normalizedProductName: string
|
|
cloudSkuId: number
|
|
cloudSkuName: string
|
|
cloudSkuPrice: number
|
|
cloudSkuInventory: number
|
|
cloudSourceKeys: string[]
|
|
resolvedSourceKey: string
|
|
deliveryItems: CloudtentaclesResolvedDeliveryItem[]
|
|
skuSnapshot: JsonObject
|
|
}
|
|
|
|
export async function resolveCloudtentaclesSkuByProductName(
|
|
productName: unknown,
|
|
deps: CloudtentaclesNameMatchDeps = {},
|
|
): Promise<CloudtentaclesNameMatchResult | null> {
|
|
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
|
if (!normalizedProductName) {
|
|
return null
|
|
}
|
|
|
|
const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources
|
|
const getSessionByKey =
|
|
deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
|
|
const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku
|
|
const resolveOverrideRule =
|
|
deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule
|
|
const sourcesConfig = listSources()
|
|
|
|
if (sourcesConfig.enabled === false) {
|
|
return null
|
|
}
|
|
|
|
const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : [])
|
|
.map((source: CloudtentaclesSource) =>
|
|
buildCloudtentaclesSourceContext(source, getSessionByKey),
|
|
)
|
|
.filter(
|
|
(context): context is NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>> =>
|
|
Boolean(context),
|
|
)
|
|
|
|
if (sourceContexts.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const overrideRule = resolveOverrideRule(productName)
|
|
const overrideMatch = overrideRule
|
|
? await resolveOverrideMatch(
|
|
productName,
|
|
normalizedProductName,
|
|
overrideRule,
|
|
sourceContexts,
|
|
listSku,
|
|
)
|
|
: null
|
|
if (overrideMatch) {
|
|
return overrideMatch
|
|
}
|
|
|
|
const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey)
|
|
|
|
for (const context of sourceContexts) {
|
|
try {
|
|
const skuList = await listSku(context)
|
|
const matchedSku = findCloudtentaclesSkuByName(
|
|
Array.isArray(skuList.items) ? skuList.items : [],
|
|
productName,
|
|
)
|
|
|
|
if (!matchedSku) {
|
|
continue
|
|
}
|
|
|
|
const cloudSkuId = Number(matchedSku.id || 0)
|
|
const cloudSkuName = String(matchedSku.name || '').trim()
|
|
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
|
continue
|
|
}
|
|
|
|
return {
|
|
matchMode: 'cloudtentacles_name',
|
|
productName: String(productName || '').trim(),
|
|
normalizedProductName,
|
|
cloudSkuId,
|
|
cloudSkuName,
|
|
cloudSkuPrice: Number(matchedSku.price || 0) || 0,
|
|
cloudSkuInventory: Number(matchedSku.inventory || 0) || 0,
|
|
cloudSourceKeys,
|
|
// Matching only discovers compatible accounts. The fulfillment selector
|
|
// chooses the least-loaded account immediately before taking a number.
|
|
resolvedSourceKey: '',
|
|
deliveryItems: [
|
|
{
|
|
cloudSkuId,
|
|
cloudSkuName,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
skuSnapshot: { ...matchedSku },
|
|
}
|
|
} catch {
|
|
// 当前账号不可用时继续尝试下一个已登录账号。
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function buildCloudtentaclesSourceContext(
|
|
source: CloudtentaclesSource,
|
|
getSessionByKey: typeof getCloudtentaclesSessionStateByKey,
|
|
) {
|
|
const sourceKey = String(source.key || '').trim()
|
|
if (!sourceKey || source.enabled === false) {
|
|
return null
|
|
}
|
|
|
|
const session = getSessionByKey(sourceKey) as CloudtentaclesSession | null
|
|
const token = String(session?.token || '').trim()
|
|
if (!token) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
sourceKey,
|
|
accountLabel: String(source.label || sourceKey).trim() || sourceKey,
|
|
baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(),
|
|
token,
|
|
deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId),
|
|
deviceType: normalizeCloudtentaclesDeviceType(session?.deviceType ?? source.deviceType),
|
|
}
|
|
}
|
|
|
|
function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: unknown) {
|
|
const rawName = String(productName || '').trim()
|
|
const normalizedName = normalizeCloudtentaclesMatchName(rawName)
|
|
|
|
return (
|
|
items.find((item) => String(item.name || '').trim() === rawName) ||
|
|
items.find((item) => normalizeCloudtentaclesMatchName(item.name) === normalizedName) ||
|
|
null
|
|
)
|
|
}
|
|
|
|
async function resolveOverrideMatch(
|
|
productName: unknown,
|
|
normalizedProductName: string,
|
|
rule: CloudtentaclesOverrideRule,
|
|
sourceContexts: Array<NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>>>,
|
|
listSku: typeof listCloudtentaclesSku,
|
|
): Promise<CloudtentaclesNameMatchResult | null> {
|
|
const candidates = rule.sourceKey
|
|
? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey)
|
|
: sourceContexts
|
|
|
|
if (candidates.length === 0) {
|
|
return null
|
|
}
|
|
|
|
for (const context of candidates) {
|
|
try {
|
|
const skuList = await listSku(context)
|
|
const items = Array.isArray(skuList.items) ? skuList.items : []
|
|
const deliveryItems = resolveOverrideDeliveryItems(rule, items)
|
|
|
|
if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) {
|
|
continue
|
|
}
|
|
|
|
const firstDeliveryItem = deliveryItems[0]
|
|
const firstSku =
|
|
items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {}
|
|
const cloudSourceKeys = rule.sourceKey
|
|
? [context.sourceKey]
|
|
: sourceContexts.map((sourceContext) => sourceContext.sourceKey)
|
|
|
|
return {
|
|
matchMode: 'cloudtentacles_override',
|
|
productName: String(productName || '').trim(),
|
|
normalizedProductName,
|
|
cloudSkuId: firstDeliveryItem.cloudSkuId,
|
|
cloudSkuName: firstDeliveryItem.cloudSkuName,
|
|
cloudSkuPrice: Number(firstSku.price || 0) || 0,
|
|
cloudSkuInventory: Number(firstSku.inventory || 0) || 0,
|
|
cloudSourceKeys,
|
|
resolvedSourceKey: rule.sourceKey ? context.sourceKey : '',
|
|
deliveryItems,
|
|
skuSnapshot: {
|
|
overrideRuleId: rule.id,
|
|
overrideRuleProductName: rule.productName,
|
|
deliveryItems,
|
|
firstSku,
|
|
},
|
|
}
|
|
} catch {
|
|
// 当前账号不可用时继续尝试下一个已登录账号。
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function resolveOverrideDeliveryItems(
|
|
rule: CloudtentaclesOverrideRule,
|
|
skuItems: CloudtentaclesSku[],
|
|
) {
|
|
const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = []
|
|
|
|
for (const ruleItem of rule.deliveryItems) {
|
|
const matchedSku =
|
|
ruleItem.cloudSkuId > 0
|
|
? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId)
|
|
: findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName)
|
|
const cloudSkuId = Number(matchedSku?.id || 0)
|
|
const cloudSkuName = String(matchedSku?.name || '').trim()
|
|
|
|
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) {
|
|
return []
|
|
}
|
|
|
|
resolvedItems.push({
|
|
cloudSkuId,
|
|
cloudSkuName,
|
|
quantity: Math.max(1, Number(ruleItem.quantity || 1)),
|
|
})
|
|
}
|
|
|
|
return mergeResolvedDeliveryItems(resolvedItems)
|
|
}
|
|
|
|
function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) {
|
|
const merged = new Map<number, CloudtentaclesResolvedDeliveryItem>()
|
|
|
|
for (const item of items) {
|
|
const existing = merged.get(item.cloudSkuId)
|
|
if (existing) {
|
|
existing.quantity += item.quantity
|
|
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
|
continue
|
|
}
|
|
|
|
merged.set(item.cloudSkuId, { ...item })
|
|
}
|
|
|
|
return Array.from(merged.values())
|
|
}
|
|
|
|
export { normalizeCloudtentaclesMatchName }
|