package app import ( "context" cryptorand "crypto/rand" "encoding/binary" "fmt" "log" "math" "sort" "strconv" "strings" "time" "infinite-canvas/backend/internal/model" ) func (s *Service) invalidateRouteCatalog() { s.routeCatalogRefreshMu.Lock() s.routeCatalogMu.Lock() s.routeCatalog = nil s.routeCatalogVersion++ s.routeCatalogRetryAt = time.Time{} s.routeCatalogRefreshError = nil s.routeCatalogMu.Unlock() s.routeCatalogRefreshMu.Unlock() if s.coordinator != nil { ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) defer cancel() if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil { log.Printf("logical model route catalog distributed invalidation failed: %v", err) } } s.initReadCaches() s.routeVersionReadCache.Clear() } func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) { now := time.Now() version := s.currentRouteCatalogVersion() s.routeCatalogMu.RLock() snapshot := s.routeCatalog if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { s.routeCatalogMu.RUnlock() return snapshot, nil } s.routeCatalogMu.RUnlock() s.routeCatalogRefreshMu.Lock() defer s.routeCatalogRefreshMu.Unlock() now = time.Now() version = s.currentRouteCatalogVersion() s.routeCatalogMu.RLock() snapshot = s.routeCatalog if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { s.routeCatalogMu.RUnlock() return snapshot, nil } s.routeCatalogMu.RUnlock() // 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。 if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) { if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { return snapshot, nil } return nil, s.routeCatalogRefreshError } loaded, err := s.loadRouteCatalog() if err != nil { s.routeCatalogRefreshError = err s.routeCatalogRetryAt = time.Now().Add(2 * time.Second) log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err) // 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。 if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { return snapshot, nil } return nil, err } s.routeCatalogRefreshError = nil s.routeCatalogRetryAt = time.Time{} s.routeCatalogMu.Lock() s.routeCatalog = loaded s.routeCatalogMu.Unlock() return loaded, nil } func (s *Service) currentRouteCatalogVersion() int64 { s.routeCatalogMu.RLock() localVersion := s.routeCatalogVersion s.routeCatalogMu.RUnlock() if s.coordinator == nil || !s.coordinator.HasRedis() { return localVersion } s.initReadCaches() ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) defer cancel() version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) { value, err := s.coordinator.RouteCatalogVersion(ctx) if err != nil { log.Printf("logical model route catalog distributed version check failed: %v", err) } return value, 256, err }) if err != nil { return localVersion } if version > localVersion { return version } return localVersion } func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() repo := s.repo.WithContext(ctx) items, err := repo.LogicalModels(false) if err != nil { return nil, err } snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))} graphs, err := repo.LogicalModelGraphs(items, false) if err != nil { return nil, err } systemChannelIDs := make([]string, 0) for _, graph := range graphs { if graph == nil { continue } for _, channelModel := range graph.ChannelModels { systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID) } } systemChannels, err := repo.SystemChannelsByIDs(systemChannelIDs, false) if err != nil { return nil, err } enabledSystemChannels := make(map[string]bool, len(systemChannels)) for _, channel := range systemChannels { enabledSystemChannels[channel.ID] = true } for _, item := range items { graph := graphs[item.ID] if graph == nil || graph.Revision == nil { log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID) continue } productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON) if decodeErr != nil { log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) continue } channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) for _, channelModel := range graph.ChannelModels { channelModelByID[channelModel.ID] = channelModel } cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} for _, route := range graph.Routes { channelModel, ok := channelModelByID[route.ChannelModelID] if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { continue } if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { continue } if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { continue } capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) if specErr != nil { log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) continue } cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) } productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) if defaultsErr != nil { log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) continue } cached.ProductSpec = productSpec cached.Defaults = defaults snapshot.Models[item.ID] = cached snapshot.Ordered = append(snapshot.Ordered, item.ID) } return snapshot, nil } // ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。 // 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段, // 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。 func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) { snapshot, err := s.routeCatalogSnapshot() if err != nil { return nil, err } cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)] if !ok { return nil, BadAuthRequest("所选模型不可用") } intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) } eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") if len(eligible) == 0 { return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") } selected := weightedRoute(eligible) var priceTier *model.ChannelModelPriceTier if cached.Model.PricePolicy == "channel" { priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) if priceTier == nil { return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") } } return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil } func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { eligible := make([]cachedLogicalRoute, 0, len(routes)) maxPriority := math.MinInt for _, route := range routes { if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { continue } if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { continue } if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { continue } if route.Route.Priority > maxPriority { eligible = eligible[:0] maxPriority = route.Route.Priority } if route.Route.Priority == maxPriority { eligible = append(eligible, route) } } return eligible } func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { for _, tier := range channelModel.PriceTiers { if tier.Enabled && tier.PriceConfigured { return true } } return false } // channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 // 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { selector := skuSelectorForIntent(intent) bestScore := -1 var best *model.ChannelModelPriceTier for index := range channelModel.PriceTiers { tier := &channelModel.PriceTiers[index] if !tier.Enabled || !tier.PriceConfigured { continue } matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) if !matched { continue } if score > bestScore { best, bestScore = tier, score } } return best } func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { selector := map[string]string{} if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { selector["operation"] = operation } switch normalizeCapability(intent.Capability) { case "video": // 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend // 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量 // 都归为图生视频。 if intent.Inputs["video"] > 0 { selector["operation"] = "video_to_video" } else if intent.Inputs["image"] > 0 { selector["operation"] = "image_to_video" } if count := intent.Inputs["image"]; count > 0 { selector["imageCount"] = strconv.Itoa(count) } if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { selector["vquality"] = value } if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { selector["videoSeconds"] = strconv.Itoa(seconds) } if audio := normalizedScalar(intent.Options["videoGenerateAudio"]); audio == "true" || audio == "false" { selector["videoGenerateAudio"] = audio } case "image": if intent.Inputs["image"] > 0 { selector["operation"] = "image_to_image" } else { selector["operation"] = "text_to_image" } rawQuality, _ := intent.Options["quality"].(string) rawSize, _ := intent.Options["size"].(string) if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { selector["quality"] = quality } for _, key := range []string{"quality", "size"} { if key == "quality" && selector["quality"] != "" { continue } text, _ := intent.Options[key].(string) if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { selector[key] = value } } } return selector } func normalizeImagePriceQuality(rawQuality string, rawSize string) string { quality := strings.ToLower(strings.TrimSpace(rawQuality)) if quality != "" && quality != "auto" && quality != "any" { return quality } parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") if len(parts) != 2 { return "" } width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { return "" } pixels := width * height switch { case pixels <= 2_000_000: return "1k" case pixels <= 4_300_000: return "2k" case pixels <= 8_294_400: return "4k" default: return "" } } func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { selector := model.DecodeSKUSelector(tier.SelectorJSON) if len(selector) == 0 { if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { selector["vquality"] = resolution } if tier.VideoSeconds > 0 { selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) } } return selector } func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { score := 0 for key, expected := range tier { expected = strings.TrimSpace(expected) if expected == "" || expected == "*" { continue } if requested[key] != expected { return false, 0 } score++ } return true, score } func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { now := time.Now() keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} // 这里会删除过期项,必须使用写锁;不要改成 RLock。 s.routeHealthMu.Lock() for _, key := range keys { until, exists := s.routeHealthBlocked[key] if !exists { continue } if !until.After(now) { delete(s.routeHealthBlocked, key) continue } s.routeHealthMu.Unlock() return true } s.routeHealthMu.Unlock() if s.coordinator == nil || !s.coordinator.HasRedis() { return false } ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() for _, key := range keys { until, err := s.coordinator.RouteBlockedUntil(ctx, key) if err != nil { log.Printf("logical route distributed health check failed key=%s: %v", key, err) return false } if until.After(now) { return true } } return false } func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any { result := make(map[string]any, len(defaults)+len(options)) for key, value := range defaults { result[key] = value } for key, value := range options { result[key] = value } return result } func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { if len(routes) == 1 { return routes[0] } var total int64 for _, route := range routes { total += int64(route.Route.Weight) } if total <= 0 { return routes[0] } var raw [8]byte if _, err := cryptorand.Read(raw[:]); err != nil { return routes[0] } totalUnsigned := uint64(total) // 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。 threshold := -totalUnsigned % totalUnsigned randomValue := binary.LittleEndian.Uint64(raw[:]) for randomValue < threshold { if _, err := cryptorand.Read(raw[:]); err != nil { return routes[0] } randomValue = binary.LittleEndian.Uint64(raw[:]) } pick := int64(randomValue % totalUnsigned) for _, route := range routes { if pick < int64(route.Route.Weight) { return route } pick -= int64(route.Route.Weight) } return routes[len(routes)-1] } func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate { result := make([]RouteSimulationCandidate, 0, len(routes)) poolPriority := math.MinInt for _, route := range routes { match := MatchCapability(route.CapabilitySpec, intent) blocked := s.logicalRouteBlocked(route) if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { poolPriority = route.Route.Priority } result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) } for index := range result { result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority } sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) return result }