refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试
This commit is contained in:
1 parent
e0a2697c60
commit
7f5d87ef1a
186 files changed
+33727
-29905
No files matched your search
@@ -4,16 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logicalModelCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,79}$`)
|
||||
@@ -41,6 +38,7 @@ type LogicalModelRequest struct {
|
||||
// SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。
|
||||
SourceChannelModelID string `json:"-"`
|
||||
}
|
||||
|
||||
type LogicalRouteRequest struct {
|
||||
ChannelModelID string `json:"channelModelId"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -130,236 +128,6 @@ type RouteSimulationResult struct {
|
||||
Candidates []RouteSimulationCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogicalModel, error) {
|
||||
snapshot, err := s.routeCatalogSnapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]PublicLogicalModel, 0, len(snapshot.Ordered))
|
||||
for _, id := range snapshot.Ordered {
|
||||
cached := snapshot.Models[id]
|
||||
structuralSpecs := availableCachedRouteSpecs(cached.Routes)
|
||||
coverageValid := logicalModelCapabilityCovered(cached.ProductSpec, structuralSpecs)
|
||||
available := coverageValid && hasHealthyCachedRoute(s, cached.Routes)
|
||||
if intent != nil {
|
||||
resolvedIntent := *intent
|
||||
resolvedIntent.Options = mergeIntentDefaults(intent.Options, cached.Defaults)
|
||||
productMatch := MatchCapability(cached.ProductSpec, resolvedIntent)
|
||||
if !productMatch.Matched {
|
||||
continue
|
||||
}
|
||||
available = false
|
||||
if coverageValid {
|
||||
for _, route := range cached.Routes {
|
||||
if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) {
|
||||
available = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, publicLogicalModel(cached, available))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel {
|
||||
item := cached.Model
|
||||
productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, enabledLogicalRouteSpecs(cached.Routes))
|
||||
profiles := make([]CapabilitySpec, 0, len(cached.Routes))
|
||||
seen := make(map[string]bool, len(cached.Routes))
|
||||
for _, route := range cached.Routes {
|
||||
if !route.Route.Enabled || route.Route.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
key := capabilityFingerprint(route.CapabilitySpec)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
profiles = append(profiles, route.CapabilitySpec)
|
||||
}
|
||||
}
|
||||
|
||||
priceTiers := publicLogicalModelPriceTiers(cached)
|
||||
pricingMode, displayPrice, priceLabel := computeModelPriceDisplay(item, priceTiers)
|
||||
|
||||
return PublicLogicalModel{
|
||||
ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon,
|
||||
Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder,
|
||||
PricePolicy: item.PricePolicy, PricingMode: pricingMode, DisplayPrice: displayPrice,
|
||||
PriceLabel: priceLabel, BillingMode: item.BillingMode,
|
||||
UnitPriceMicrocredits: item.UnitPriceMicrocredits,
|
||||
InputPriceMicrocredits: item.InputPriceMicrocredits,
|
||||
OutputPriceMicrocredits: item.OutputPriceMicrocredits,
|
||||
CachedPriceMicrocredits: item.CachedPriceMicrocredits,
|
||||
PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON),
|
||||
CapabilitySpec: productSpec, CapabilityProfiles: profiles,
|
||||
DefaultOptions: cached.Defaults, Available: available,
|
||||
}
|
||||
}
|
||||
|
||||
func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier {
|
||||
if cached.Model.PricePolicy != "channel" {
|
||||
return []PublicLogicalModelPriceTier{}
|
||||
}
|
||||
result := make([]PublicLogicalModelPriceTier, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, route := range cached.Routes {
|
||||
if !route.Route.Enabled || route.Route.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, tier := range route.ChannelModel.PriceTiers {
|
||||
if !tier.Enabled || !tier.PriceConfigured {
|
||||
continue
|
||||
}
|
||||
selector := skuSelectorForTier(tier)
|
||||
_, selectorKey, selectorErr := model.CanonicalSKUSelector(selector)
|
||||
if selectorErr != nil {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s:%s:%d:%d:%d:%d", selectorKey, tier.BillingMode, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, PublicLogicalModelPriceTier{Selector: selector, Resolution: normalizeChannelModelTierResolution(tier.Resolution), VideoSeconds: tier.VideoSeconds, BillingMode: tier.BillingMode, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeLegacyModelIDs(raw string) []string {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return normalizeLegacyModelIDs(values)
|
||||
}
|
||||
|
||||
func normalizeLegacyModelIDs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, raw := range values {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func enabledLogicalRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec {
|
||||
specs := make([]CapabilitySpec, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
if !route.Route.Enabled || route.Route.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
specs = append(specs, route.CapabilitySpec)
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored
|
||||
// only `*` for a custom size. The wildcard remains for matching custom values,
|
||||
// while route presets are restored for admin and creator-side selectors.
|
||||
// Callers must pass only currently enabled routes; disabled or zero-weight
|
||||
// routes would otherwise advertise size tiers the public catalog cannot route.
|
||||
func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec {
|
||||
result := spec
|
||||
result.Options = make(map[string]OptionConstraint, len(spec.Options))
|
||||
for name, constraint := range spec.Options {
|
||||
if !isWildcardOptionConstraint(constraint) {
|
||||
result.Options[name] = constraint
|
||||
continue
|
||||
}
|
||||
values := append([]any(nil), constraint.Values...)
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
seen[normalizedScalar(value)] = true
|
||||
}
|
||||
for _, route := range routes {
|
||||
for _, value := range route.Options[name].Values {
|
||||
key := normalizedScalar(value)
|
||||
if key != "" && !seen[key] {
|
||||
seen[key] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Options[name] = OptionConstraint{Values: values}
|
||||
}
|
||||
// 前台规格里的预设可能只来自单条线路的快照(后续新增的供应线路还没同步进来),
|
||||
// 因此不管自身是否已有预设都要与各线路取并集,否则多档会被压成单档。
|
||||
if merged := mergeCapabilityImageSize(append([]CapabilitySpec{spec}, routes...)); merged != nil {
|
||||
if result.ImageSize == nil {
|
||||
result.ImageSize = merged
|
||||
} else {
|
||||
restored := *result.ImageSize
|
||||
if restored.Parameter == "" {
|
||||
restored.Parameter = merged.Parameter
|
||||
}
|
||||
if !restored.AllowCustom {
|
||||
restored.AllowCustom = merged.AllowCustom
|
||||
}
|
||||
restored.Presets = merged.Presets
|
||||
result.ImageSize = &restored
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeCapabilityImageSize(specs []CapabilitySpec) *CapabilityImageSize {
|
||||
var result *CapabilityImageSize
|
||||
seen := map[string]bool{}
|
||||
for _, spec := range specs {
|
||||
part := spec.ImageSize
|
||||
if part == nil {
|
||||
continue
|
||||
}
|
||||
if result == nil {
|
||||
result = &CapabilityImageSize{Parameter: part.Parameter, AllowCustom: part.AllowCustom}
|
||||
} else {
|
||||
if result.Parameter != "aspect_ratio" && part.Parameter == "aspect_ratio" {
|
||||
result.Parameter = "aspect_ratio"
|
||||
} else if result.Parameter == "" {
|
||||
result.Parameter = part.Parameter
|
||||
}
|
||||
result.AllowCustom = result.AllowCustom || part.AllowCustom
|
||||
}
|
||||
for _, preset := range part.Presets {
|
||||
key := preset.Tier + ":" + preset.Ratio + ":" + preset.Size
|
||||
if key == "::" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result.Presets = append(result.Presets, preset)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// capabilityFingerprint 用规范化后的结构去重能力画像;不能直接依赖原始 JSON,
|
||||
// 因为同一组枚举能力的数组顺序不应造成重复展示。
|
||||
func capabilityFingerprint(spec CapabilitySpec) string {
|
||||
copySpec := spec
|
||||
copySpec.Operations = append([]string(nil), spec.Operations...)
|
||||
sort.Strings(copySpec.Operations)
|
||||
copySpec.Inputs = make(map[string]InputConstraint, len(spec.Inputs))
|
||||
for name, constraint := range spec.Inputs {
|
||||
copySpec.Inputs[name] = constraint
|
||||
}
|
||||
copySpec.Options = make(map[string]OptionConstraint, len(spec.Options))
|
||||
for name, constraint := range spec.Options {
|
||||
values := append([]any(nil), constraint.Values...)
|
||||
sort.SliceStable(values, func(i, j int) bool { return normalizedScalar(values[i]) < normalizedScalar(values[j]) })
|
||||
constraint.Values = values
|
||||
copySpec.Options[name] = constraint
|
||||
}
|
||||
encoded, _ := json.Marshal(copySpec)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
@@ -966,198 +734,6 @@ func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent
|
||||
|
||||
func logicalModelNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
|
||||
|
||||
// 前台模型只声明供应线路真实提供的总目录;组合是否可承接仍由匿名 capabilityProfiles 按 OR 语义判断。
|
||||
func validateProductSpecWithinRoutes(product CapabilitySpec, routeSpecs []CapabilitySpec) error {
|
||||
for _, routeSpec := range routeSpecs {
|
||||
if normalizeCapability(routeSpec.Capability) != normalizeCapability(product.Capability) {
|
||||
return BadAuthRequest("供应线路能力类型与前台模型不一致")
|
||||
}
|
||||
}
|
||||
if len(product.Operations) == 0 {
|
||||
unrestricted := false
|
||||
for _, routeSpec := range routeSpecs {
|
||||
if len(routeSpec.Operations) == 0 {
|
||||
unrestricted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !unrestricted {
|
||||
return BadAuthRequest("创作端生成方式必须从供应线路支持的选项中选择")
|
||||
}
|
||||
} else {
|
||||
for _, operation := range product.Operations {
|
||||
supported := false
|
||||
for _, routeSpec := range routeSpecs {
|
||||
if len(routeSpec.Operations) == 0 || containsCapabilityString(routeSpec.Operations, operation) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
return BadAuthRequest("创作端生成方式不受任何供应线路支持:" + operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, constraint := range product.Inputs {
|
||||
if !inputConstraintCovered(constraint, name, routeSpecs) {
|
||||
return BadAuthRequest("创作端输入范围超出供应线路能力:" + name)
|
||||
}
|
||||
}
|
||||
for name, constraint := range product.Options {
|
||||
if !optionConstraintCovered(constraint, name, routeSpecs) {
|
||||
return BadAuthRequest("创作端参数超出供应线路能力:" + name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inputConstraintCovered(candidate InputConstraint, name string, routeSpecs []CapabilitySpec) bool {
|
||||
next := candidate.Min
|
||||
for next <= candidate.Max {
|
||||
coveredUntil := next - 1
|
||||
for _, routeSpec := range routeSpecs {
|
||||
constraint, exists := routeSpec.Inputs[name]
|
||||
if !exists {
|
||||
constraint = InputConstraint{Min: 0, Max: 0}
|
||||
}
|
||||
if constraint.Min <= next && constraint.Max >= next && constraint.Max > coveredUntil {
|
||||
coveredUntil = constraint.Max
|
||||
}
|
||||
}
|
||||
if coveredUntil < next {
|
||||
return false
|
||||
}
|
||||
next = coveredUntil + 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs []CapabilitySpec) bool {
|
||||
routeConstraints := make([]OptionConstraint, 0, len(routeSpecs))
|
||||
for _, routeSpec := range routeSpecs {
|
||||
if constraint, exists := routeSpec.Options[name]; exists {
|
||||
routeConstraints = append(routeConstraints, constraint)
|
||||
}
|
||||
}
|
||||
if len(routeConstraints) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, routeConstraint := range routeConstraints {
|
||||
if isWildcardOptionConstraint(routeConstraint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(candidate.Values) > 0 {
|
||||
for _, value := range candidate.Values {
|
||||
if !optionValueSupported(name, value, routeConstraints) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if candidate.Min == nil || candidate.Max == nil {
|
||||
return false
|
||||
}
|
||||
if math.Abs(*candidate.Max-*candidate.Min) < 1e-9 {
|
||||
return optionValueSupported(name, *candidate.Min, routeConstraints)
|
||||
}
|
||||
if candidate.Step == nil {
|
||||
return continuousOptionRangeCovered(*candidate.Min, *candidate.Max, routeConstraints)
|
||||
}
|
||||
step := *candidate.Step
|
||||
count := int(math.Floor((*candidate.Max-*candidate.Min)/step+1e-9)) + 1
|
||||
if count <= 10000 {
|
||||
for index := 0; index < count; index++ {
|
||||
value := *candidate.Min + float64(index)*step
|
||||
if !optionValueSupported(name, value, routeConstraints) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 超大离散范围不逐点展开;只有单条连续范围或步长完全兼容的线路才能作为可靠来源。
|
||||
for _, routeConstraint := range routeConstraints {
|
||||
if routeConstraint.Min == nil || routeConstraint.Max == nil || *routeConstraint.Min > *candidate.Min || *routeConstraint.Max < *candidate.Max {
|
||||
continue
|
||||
}
|
||||
if routeConstraint.Step == nil {
|
||||
return true
|
||||
}
|
||||
startSteps := (*candidate.Min - *routeConstraint.Min) / *routeConstraint.Step
|
||||
stepRatio := step / *routeConstraint.Step
|
||||
if math.Abs(startSteps-math.Round(startSteps)) < 1e-9 && math.Abs(stepRatio-math.Round(stepRatio)) < 1e-9 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func optionValueSupported(name string, value any, constraints []OptionConstraint) bool {
|
||||
for _, constraint := range constraints {
|
||||
if isWildcardOptionConstraint(constraint) {
|
||||
return true
|
||||
}
|
||||
if matchOptionConstraint(name, constraint, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWildcardOptionConstraint(constraint OptionConstraint) bool {
|
||||
for _, value := range constraint.Values {
|
||||
if normalizedScalar(value) == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func continuousOptionRangeCovered(minimum float64, maximum float64, constraints []OptionConstraint) bool {
|
||||
next := minimum
|
||||
for next <= maximum+1e-9 {
|
||||
coveredUntil := next
|
||||
advanced := false
|
||||
for _, constraint := range constraints {
|
||||
if constraint.Min == nil || constraint.Max == nil || constraint.Step != nil {
|
||||
continue
|
||||
}
|
||||
if *constraint.Min <= next+1e-9 && *constraint.Max >= next-1e-9 && *constraint.Max > coveredUntil {
|
||||
coveredUntil = *constraint.Max
|
||||
advanced = true
|
||||
}
|
||||
}
|
||||
if coveredUntil >= maximum-1e-9 {
|
||||
return true
|
||||
}
|
||||
if !advanced {
|
||||
return false
|
||||
}
|
||||
next = coveredUntil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anyValues(values []string) OptionConstraint {
|
||||
result := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
return OptionConstraint{Values: result}
|
||||
}
|
||||
|
||||
func boolValues(supportsTrue bool) OptionConstraint {
|
||||
values := []any{false}
|
||||
if supportsTrue {
|
||||
values = append(values, true)
|
||||
}
|
||||
return OptionConstraint{Values: values}
|
||||
}
|
||||
|
||||
func numericRange(minimum float64, maximum float64, step float64) OptionConstraint {
|
||||
return OptionConstraint{Min: &minimum, Max: &maximum, Step: &step}
|
||||
}
|
||||
|
||||
// computeModelPriceDisplay 计算模型的价格展示信息
|
||||
// 返回:pricingMode, displayPrice, priceLabel
|
||||
func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) {
|
||||
|
||||
Reference in new issue
Block a user