790 lines
33 KiB
Go
790 lines
33 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
"infinite-canvas/backend/internal/model"
|
||
"infinite-canvas/backend/internal/repository"
|
||
)
|
||
|
||
var logicalModelCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,79}$`)
|
||
|
||
type LogicalModelRequest struct {
|
||
Code string `json:"code"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
Description string `json:"description"`
|
||
Capability string `json:"capability"`
|
||
Enabled bool `json:"enabled"`
|
||
SortOrder int `json:"sortOrder"`
|
||
PricePolicy string `json:"pricePolicy"`
|
||
BillingMode string `json:"billingMode"`
|
||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
|
||
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
|
||
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
|
||
// LegacyModelIDs 只用于将用户本地保存的旧目录选择迁移到当前模型家族,
|
||
// 不能用它重写任务、账单或路由尝试中的不可变快照。
|
||
LegacyModelIDs []string `json:"legacyModelIds"`
|
||
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
|
||
DefaultOptions map[string]any `json:"defaultOptions"`
|
||
Routes []LogicalRouteRequest `json:"routes"`
|
||
// SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。
|
||
SourceChannelModelID string `json:"-"`
|
||
}
|
||
|
||
type LogicalRouteRequest struct {
|
||
ChannelModelID string `json:"channelModelId"`
|
||
Enabled bool `json:"enabled"`
|
||
Priority int `json:"priority"`
|
||
Weight int `json:"weight"`
|
||
}
|
||
|
||
type PublicLogicalModel struct {
|
||
ID string `json:"id"`
|
||
Code string `json:"code"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
Description string `json:"description"`
|
||
Capability string `json:"capability"`
|
||
SortOrder int `json:"sortOrder"`
|
||
PricePolicy string `json:"pricePolicy"`
|
||
PricingMode string `json:"pricingMode"`
|
||
DisplayPrice *int64 `json:"displayPrice,omitempty"`
|
||
PriceLabel string `json:"priceLabel"`
|
||
BillingMode string `json:"billingMode"`
|
||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||
InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"`
|
||
OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"`
|
||
CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"`
|
||
PriceTiers []PublicLogicalModelPriceTier `json:"priceTiers"`
|
||
LegacyModelIDs []string `json:"legacyModelIds"`
|
||
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
|
||
// CapabilityProfiles 是创作端可见的匿名能力组合,不暴露其背后的供应线路关系。
|
||
CapabilityProfiles []CapabilitySpec `json:"capabilityProfiles"`
|
||
DefaultOptions map[string]any `json:"defaultOptions"`
|
||
Available bool `json:"available"`
|
||
}
|
||
|
||
// PublicLogicalModelPriceTier 是创作端用于约束规格选择和展示当前报价的安全投影,
|
||
// 不暴露供应渠道、上游模型 ID 或内部路由信息。
|
||
type PublicLogicalModelPriceTier struct {
|
||
Selector map[string]string `json:"selector"`
|
||
Resolution string `json:"resolution"`
|
||
VideoSeconds int `json:"videoSeconds"`
|
||
BillingMode string `json:"billingMode"`
|
||
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
|
||
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
|
||
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
|
||
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
|
||
}
|
||
|
||
type AdminLogicalRoute struct {
|
||
ID string `json:"id"`
|
||
ChannelModelID string `json:"channelModelId"`
|
||
ChannelID string `json:"channelId"`
|
||
ChannelModelKey string `json:"channelModelKey"`
|
||
ChannelModelName string `json:"channelModelName"`
|
||
Enabled bool `json:"enabled"`
|
||
Priority int `json:"priority"`
|
||
Weight int `json:"weight"`
|
||
Available bool `json:"available"`
|
||
structurallyAvailable bool
|
||
CapabilitySpec CapabilitySpec `json:"capabilitySpec"`
|
||
}
|
||
|
||
type AdminLogicalModel struct {
|
||
PublicLogicalModel
|
||
Enabled bool `json:"enabled"`
|
||
ActiveRevisionID string `json:"activeRevisionId"`
|
||
RevisionVersion int `json:"revisionVersion"`
|
||
ConfigurationError string `json:"configurationError,omitempty"`
|
||
AvailabilityError string `json:"availabilityError,omitempty"`
|
||
Routes []AdminLogicalRoute `json:"routes"`
|
||
}
|
||
|
||
type RouteSimulationCandidate struct {
|
||
RouteID string `json:"routeId"`
|
||
ChannelModelID string `json:"channelModelId"`
|
||
ChannelModelKey string `json:"channelModelKey"`
|
||
ChannelModelName string `json:"channelModelName"`
|
||
Priority int `json:"priority"`
|
||
Weight int `json:"weight"`
|
||
Enabled bool `json:"enabled"`
|
||
Matched bool `json:"matched"`
|
||
Blocked bool `json:"blocked"`
|
||
InPool bool `json:"inPool"`
|
||
Reasons []string `json:"reasons,omitempty"`
|
||
}
|
||
|
||
type RouteSimulationResult struct {
|
||
ProductMatch CapabilityMatch `json:"productMatch"`
|
||
Candidates []RouteSimulationCandidate `json:"candidates"`
|
||
}
|
||
|
||
func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
items, err := s.repo.LogicalModels(true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
graphs, err := s.repo.LogicalModelGraphs(items, true)
|
||
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 := s.repo.SystemChannelsByIDs(systemChannelIDs, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
systemChannelByID := make(map[string]model.ModelChannel, len(systemChannels))
|
||
for _, channel := range systemChannels {
|
||
systemChannelByID[channel.ID] = channel
|
||
}
|
||
result := make([]AdminLogicalModel, 0, len(items))
|
||
for _, item := range items {
|
||
graph := graphs[item.ID]
|
||
if graph == nil || graph.Revision == nil {
|
||
continue
|
||
}
|
||
admin, buildErr := s.buildAdminLogicalModel(item, graph, systemChannelByID)
|
||
if buildErr != nil {
|
||
return nil, buildErr
|
||
}
|
||
result = append(result, *admin)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *repository.LogicalModelGraph, systemChannelByID map[string]model.ModelChannel) (*AdminLogicalModel, error) {
|
||
productSpec, err := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels))
|
||
for _, channelModel := range graph.ChannelModels {
|
||
channelModelByID[channelModel.ID] = channelModel
|
||
}
|
||
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
|
||
for _, route := range graph.Routes {
|
||
channelModel, channelOK := channelModelByID[route.ChannelModelID]
|
||
if !channelOK {
|
||
return nil, errors.New("供应线路引用的渠道模型不存在")
|
||
}
|
||
capabilitySpec, specErr := channelModelCapabilitySpec(channelModel)
|
||
if specErr != nil {
|
||
return nil, specErr
|
||
}
|
||
_, channelOK = systemChannelByID[channelModel.ChannelID]
|
||
structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK
|
||
billingAvailable := item.PricePolicy != "unified" || item.BillingMode != "token" || supportsTokenBilling(item.Capability, channelModel.Protocol)
|
||
available := structurallyAvailable && billingAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured)
|
||
admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ChannelModelKey: channelModel.ModelKey, ChannelModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: capabilitySpec})
|
||
}
|
||
routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes))
|
||
for _, route := range admin.Routes {
|
||
routeSpecs = append(routeSpecs, route.CapabilitySpec)
|
||
}
|
||
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
|
||
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false)
|
||
// publicLogicalModel above has no route list; admin routes are already attached
|
||
// and the enriched product spec is the source used by the editor.
|
||
admin.CapabilitySpec = productSpec
|
||
admin.DefaultOptions = defaults
|
||
structuralRouteSpecs := structuralAdminRouteSpecs(admin.Routes)
|
||
settlementRouteSpecs := settlementReadyAdminRouteSpecs(admin.Routes)
|
||
admin.ConfigurationError = logicalModelConfigurationError(productSpec, structuralRouteSpecs)
|
||
admin.AvailabilityError = logicalModelAvailabilityError(item.PricePolicy, productSpec, structuralRouteSpecs, settlementRouteSpecs)
|
||
admin.Available = len(settlementRouteSpecs) > 0 && admin.ConfigurationError == "" && admin.AvailabilityError == ""
|
||
return &admin, nil
|
||
}
|
||
|
||
func countAvailableAdminRoutes(routes []AdminLogicalRoute) int {
|
||
count := 0
|
||
for _, route := range routes {
|
||
if route.Available {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func structuralAdminRouteSpecs(routes []AdminLogicalRoute) []CapabilitySpec {
|
||
result := make([]CapabilitySpec, 0, len(routes))
|
||
for _, route := range routes {
|
||
if route.structurallyAvailable {
|
||
result = append(result, route.CapabilitySpec)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func settlementReadyAdminRouteSpecs(routes []AdminLogicalRoute) []CapabilitySpec {
|
||
result := make([]CapabilitySpec, 0, countAvailableAdminRoutes(routes))
|
||
for _, route := range routes {
|
||
if route.Available {
|
||
result = append(result, route.CapabilitySpec)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func availableCachedRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec {
|
||
result := make([]CapabilitySpec, 0, len(routes))
|
||
for _, route := range routes {
|
||
if route.Route.Enabled && route.Route.Weight > 0 {
|
||
result = append(result, route.CapabilitySpec)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func hasHealthyCachedRoute(s *Service, routes []cachedLogicalRoute) bool {
|
||
for _, route := range routes {
|
||
if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func logicalModelCapabilityCovered(product CapabilitySpec, routeSpecs []CapabilitySpec) bool {
|
||
return len(routeSpecs) > 0 && validateProductSpecWithinRoutes(product, routeSpecs) == nil
|
||
}
|
||
|
||
func logicalModelConfigurationError(product CapabilitySpec, routeSpecs []CapabilitySpec) string {
|
||
if len(routeSpecs) == 0 || logicalModelCapabilityCovered(product, routeSpecs) {
|
||
return ""
|
||
}
|
||
return "供应线路已无法完整覆盖创作端能力,请调整线路或能力范围"
|
||
}
|
||
|
||
func logicalModelAvailabilityError(pricePolicy string, product CapabilitySpec, structuralRouteSpecs, settlementRouteSpecs []CapabilitySpec) string {
|
||
if pricePolicy != "channel" || len(structuralRouteSpecs) == 0 {
|
||
return ""
|
||
}
|
||
if len(settlementRouteSpecs) == 0 {
|
||
return "供应线路尚未配置可结算价格,请先完善渠道模型价格"
|
||
}
|
||
if !logicalModelCapabilityCovered(product, settlementRouteSpecs) {
|
||
return "部分创作端能力只能由未配置价格的渠道模型承接,请完善对应价格"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *Service) SaveAdminLogicalModel(actor *model.User, id string, req LogicalModelRequest) (*AdminLogicalModel, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
item, revision, routes, creating, err := s.logicalModelBundle(actor, id, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.repo.SaveLogicalModelBundle(item, revision, routes, creating); err != nil {
|
||
return nil, err
|
||
}
|
||
s.invalidateRouteCatalog()
|
||
// 模型、版本和路由属于后台关键配置,保存成功却缺失审计记录不能静默返回成功。
|
||
if err := s.appendAdminAudit(actor, map[bool]string{true: "logical_model.create", false: "logical_model.update"}[creating], "logical_model", item.ID, "保存前台模型及供应线路", map[string]any{"revisionId": revision.ID, "routeCount": len(routes)}); err != nil {
|
||
return nil, err
|
||
}
|
||
graph, err := s.repo.LogicalModelGraph(item.ID, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
channelIDs := make([]string, 0, len(graph.ChannelModels))
|
||
for _, channelModel := range graph.ChannelModels {
|
||
channelIDs = append(channelIDs, channelModel.ChannelID)
|
||
}
|
||
systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
systemChannelByID := make(map[string]model.ModelChannel, len(systemChannels))
|
||
for _, channel := range systemChannels {
|
||
systemChannelByID[channel.ID] = channel
|
||
}
|
||
return s.buildAdminLogicalModel(*item, graph, systemChannelByID)
|
||
}
|
||
|
||
func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return err
|
||
}
|
||
item, err := s.repo.LogicalModel(strings.TrimSpace(id))
|
||
if logicalModelNotFound(err) {
|
||
return BadAuthRequest("前台模型不存在或已删除")
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if item.ArchivedAt != nil {
|
||
return BadAuthRequest("前台模型不存在或已删除")
|
||
}
|
||
if item.SourceChannelModelID != "" {
|
||
return BadAuthRequest("该前台模型由系统渠道自动同步,请在系统渠道模型中停用")
|
||
}
|
||
audit, err := newAdminAuditEvent(actor, "logical_model.archive", "logical_model", item.ID, "归档前台模型", map[string]any{"code": item.Code, "name": item.Name})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.repo.ArchiveLogicalModel(item.ID, audit, time.Now()); err != nil {
|
||
if errors.Is(err, repository.ErrLogicalModelInUse) {
|
||
return BadAuthRequest("前台模型仍被排队中或进行中任务使用,请等待任务结束后再归档")
|
||
}
|
||
if logicalModelNotFound(err) {
|
||
return BadAuthRequest("前台模型不存在或已删除")
|
||
}
|
||
return err
|
||
}
|
||
s.invalidateRouteCatalog()
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalModelRequest) (*model.LogicalModel, *model.LogicalModelRevision, []model.LogicalModelRoute, bool, error) {
|
||
code := strings.ToLower(strings.TrimSpace(req.Code))
|
||
name := strings.TrimSpace(req.Name)
|
||
capability := normalizeCapability(req.Capability)
|
||
if !logicalModelCodePattern.MatchString(code) {
|
||
return nil, nil, nil, false, BadAuthRequest("模型 code 需为 2-80 位小写字母、数字、点、下划线或连字符")
|
||
}
|
||
if name == "" || len([]rune(name)) > 120 {
|
||
return nil, nil, nil, false, BadAuthRequest("请填写 1-120 个字符的模型名称")
|
||
}
|
||
sourceChannelModelID := strings.TrimSpace(req.SourceChannelModelID)
|
||
if sourceChannelModelID != "" {
|
||
source, sourceErr := s.repo.ChannelModel(sourceChannelModelID)
|
||
if sourceErr != nil {
|
||
return nil, nil, nil, false, BadAuthRequest("系统渠道模型不存在")
|
||
}
|
||
if _, channelErr := s.repo.AdminSystemChannel(source.ChannelID); channelErr != nil {
|
||
return nil, nil, nil, false, BadAuthRequest("前台模型只能同步系统渠道模型")
|
||
}
|
||
capability = normalizeCapability(source.Capability)
|
||
derivedSpec, specErr := channelModelCapabilitySpec(*source)
|
||
if specErr != nil {
|
||
return nil, nil, nil, false, specErr
|
||
}
|
||
derivedDefaults, defaultsErr := channelModelDefaultOptions(*source, derivedSpec)
|
||
if defaultsErr != nil {
|
||
return nil, nil, nil, false, defaultsErr
|
||
}
|
||
if len(req.Routes) == 0 {
|
||
req.CapabilitySpec = derivedSpec
|
||
req.DefaultOptions = derivedDefaults
|
||
req.Routes = []LogicalRouteRequest{{ChannelModelID: source.ID, Enabled: true, Priority: 100, Weight: 100}}
|
||
}
|
||
req.PricePolicy = "channel"
|
||
req.BillingMode = "fixed_request"
|
||
req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits = 0, 0, 0, 0
|
||
// 未完成定价的系统模型仅在后台目录保留同步记录,不能暴露到创作端。
|
||
if strings.TrimSpace(id) == "" {
|
||
req.Enabled = source.Enabled && channelModelHasActivePriceTier(*source)
|
||
}
|
||
}
|
||
normalizedSpec, err := NormalizeCapabilitySpec(req.CapabilitySpec)
|
||
if err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
req.CapabilitySpec = normalizedSpec
|
||
if normalizeCapability(req.CapabilitySpec.Capability) != capability {
|
||
return nil, nil, nil, false, BadAuthRequest("前台模型类型与能力配置不一致")
|
||
}
|
||
normalizedDefaults, err := normalizeLogicalDefaults(req.CapabilitySpec, req.DefaultOptions)
|
||
if err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
req.DefaultOptions = normalizedDefaults
|
||
if req.UnitPriceMicrocredits < 0 || req.InputPriceMicrocredits < 0 || req.OutputPriceMicrocredits < 0 || req.CachedPriceMicrocredits < 0 {
|
||
return nil, nil, nil, false, BadAuthRequest("用户价格不能为负数")
|
||
}
|
||
pricePolicy := strings.TrimSpace(req.PricePolicy)
|
||
if pricePolicy != "channel" && pricePolicy != "unified" {
|
||
return nil, nil, nil, false, BadAuthRequest("请选择跟随供应价格或统一定价")
|
||
}
|
||
billingMode := strings.TrimSpace(req.BillingMode)
|
||
if pricePolicy == "channel" {
|
||
billingMode = "fixed_request"
|
||
req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits = 0, 0, 0, 0
|
||
} else if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" {
|
||
return nil, nil, nil, false, BadAuthRequest("前台模型计费方式仅支持按次、按秒或 Token")
|
||
}
|
||
if pricePolicy == "unified" && billingMode == "per_second" && capability != "video" {
|
||
return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费")
|
||
}
|
||
if pricePolicy == "unified" && billingMode == "token" {
|
||
if err := validateTokenPrices(capability, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits); err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
}
|
||
creating := strings.TrimSpace(id) == ""
|
||
var item *model.LogicalModel
|
||
if creating {
|
||
id, err = s.repo.NextPrefixedID("LMODEL")
|
||
if err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
item = &model.LogicalModel{ID: id, CreatedAt: time.Now()}
|
||
} else {
|
||
item, err = s.repo.LogicalModel(id)
|
||
if err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
if item.ArchivedAt != nil {
|
||
return nil, nil, nil, false, BadAuthRequest("前台模型不存在或已删除")
|
||
}
|
||
}
|
||
item.Code, item.Name, item.Icon, item.Description, item.Capability = code, name, strings.TrimSpace(req.Icon), strings.TrimSpace(req.Description), capability
|
||
if sourceChannelModelID != "" {
|
||
item.SourceChannelModelID = sourceChannelModelID
|
||
}
|
||
item.Enabled, item.SortOrder, item.PricePolicy, item.BillingMode = req.Enabled, req.SortOrder, pricePolicy, billingMode
|
||
item.UnitPriceMicrocredits, item.InputPriceMicrocredits, item.OutputPriceMicrocredits, item.CachedPriceMicrocredits = req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits
|
||
if req.LegacyModelIDs != nil {
|
||
legacyJSON, marshalErr := json.Marshal(normalizeLegacyModelIDs(req.LegacyModelIDs))
|
||
if marshalErr != nil {
|
||
return nil, nil, nil, false, marshalErr
|
||
}
|
||
item.LegacyModelIDsJSON = string(legacyJSON)
|
||
}
|
||
item.UpdatedAt = time.Now()
|
||
revisionID, err := s.repo.NextPrefixedID("REVISION")
|
||
if err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
specJSON, err := json.Marshal(req.CapabilitySpec)
|
||
if err != nil {
|
||
return nil, nil, nil, false, fmt.Errorf("序列化前台模型能力合同失败:%w", err)
|
||
}
|
||
defaultsJSON, err := json.Marshal(defaultMap(req.DefaultOptions))
|
||
if err != nil {
|
||
return nil, nil, nil, false, fmt.Errorf("序列化前台模型默认参数失败:%w", err)
|
||
}
|
||
revision := &model.LogicalModelRevision{ID: revisionID, LogicalModelID: item.ID, CapabilitySpecJSON: string(specJSON), DefaultOptionsJSON: string(defaultsJSON), CreatedBy: actor.ID, CreatedAt: time.Now()}
|
||
routes := make([]model.LogicalModelRoute, 0, len(req.Routes))
|
||
seenChannelModels := make(map[string]bool, len(req.Routes))
|
||
structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
|
||
settlementRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
|
||
enabledRouteProtocols := make([]model.ChannelInterfaceType, 0, len(req.Routes))
|
||
for _, input := range req.Routes {
|
||
channelModelID := strings.TrimSpace(input.ChannelModelID)
|
||
if channelModelID == "" || seenChannelModels[channelModelID] {
|
||
return nil, nil, nil, false, BadAuthRequest("供应线路必须选择不重复的渠道模型")
|
||
}
|
||
seenChannelModels[channelModelID] = true
|
||
channelModel, modelErr := s.repo.ChannelModel(channelModelID)
|
||
if modelErr != nil {
|
||
return nil, nil, nil, false, BadAuthRequest("供应线路引用的渠道模型不存在")
|
||
}
|
||
capabilitySpec, specErr := channelModelCapabilitySpec(*channelModel)
|
||
if specErr != nil {
|
||
return nil, nil, nil, false, specErr
|
||
}
|
||
if normalizeCapability(capabilitySpec.Capability) != capability {
|
||
return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致")
|
||
}
|
||
if input.Enabled {
|
||
enabledRouteProtocols = append(enabledRouteProtocols, channelModel.Protocol)
|
||
}
|
||
if req.Enabled && input.Enabled && input.Weight <= 0 {
|
||
return nil, nil, nil, false, BadAuthRequest("启用供应线路的同级权重必须大于 0")
|
||
}
|
||
if input.Weight < 0 {
|
||
return nil, nil, nil, false, BadAuthRequest("供应线路的同级权重不能为负数")
|
||
}
|
||
if _, channelErr := s.repo.SystemChannel(channelModel.ChannelID); channelErr != nil {
|
||
return nil, nil, nil, false, BadAuthRequest("供应线路只能选择系统渠道模型")
|
||
}
|
||
if req.Enabled && input.Enabled && channelModel.Enabled {
|
||
structuralRouteSpecs = append(structuralRouteSpecs, capabilitySpec)
|
||
if pricePolicy != "channel" || channelModel.PriceConfigured {
|
||
settlementRouteSpecs = append(settlementRouteSpecs, capabilitySpec)
|
||
}
|
||
}
|
||
routeID, idErr := s.repo.NextPrefixedID("ROUTE")
|
||
if idErr != nil {
|
||
return nil, nil, nil, false, idErr
|
||
}
|
||
routes = append(routes, model.LogicalModelRoute{ID: routeID, ChannelModelID: channelModel.ID, Enabled: input.Enabled, Priority: input.Priority, Weight: input.Weight, CreatedAt: time.Now(), UpdatedAt: time.Now()})
|
||
}
|
||
if pricePolicy == "unified" && billingMode == "token" {
|
||
if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) {
|
||
return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本和视频前台模型")
|
||
}
|
||
}
|
||
// 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。
|
||
if req.Enabled {
|
||
if len(structuralRouteSpecs) == 0 {
|
||
return nil, nil, nil, false, BadAuthRequest("启用前台模型前至少需要一条已启用的供应线路")
|
||
}
|
||
if err := validateProductSpecWithinRoutes(req.CapabilitySpec, structuralRouteSpecs); err != nil {
|
||
return nil, nil, nil, false, err
|
||
}
|
||
if availabilityError := logicalModelAvailabilityError(pricePolicy, req.CapabilitySpec, structuralRouteSpecs, settlementRouteSpecs); availabilityError != "" {
|
||
return nil, nil, nil, false, BadAuthRequest(availabilityError)
|
||
}
|
||
}
|
||
return item, revision, routes, creating, nil
|
||
}
|
||
|
||
func supportsLogicalModelTokenBilling(capability string, enabledRouteProtocols []model.ChannelInterfaceType) bool {
|
||
if capability == "text" {
|
||
return true
|
||
}
|
||
if capability != "video" || len(enabledRouteProtocols) == 0 {
|
||
return false
|
||
}
|
||
for _, protocol := range enabledRouteProtocols {
|
||
if !supportsTokenBilling(capability, protocol) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map[string]any, error) {
|
||
result := make(map[string]any, len(defaults))
|
||
for rawName, value := range defaults {
|
||
name := canonicalCapabilityOptionName(rawName)
|
||
if _, exists := result[name]; exists {
|
||
return nil, BadAuthRequest("默认参数存在重复别名:" + name)
|
||
}
|
||
constraint, ok := spec.Options[name]
|
||
if !ok || !matchOptionConstraint(name, constraint, value) {
|
||
return nil, BadAuthRequest("默认参数 " + name + " 不在前台模型能力范围内")
|
||
}
|
||
// `*` 只表示允许任意自定义值,不能作为创作端默认参数发送。
|
||
if normalizedScalar(value) == "*" {
|
||
for _, candidate := range constraint.Values {
|
||
if normalizedScalar(candidate) != "*" {
|
||
value = candidate
|
||
break
|
||
}
|
||
}
|
||
}
|
||
result[name] = value
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func defaultMap(value map[string]any) map[string]any {
|
||
if value == nil {
|
||
return map[string]any{}
|
||
}
|
||
return value
|
||
}
|
||
|
||
func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec, error) {
|
||
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
|
||
if err != nil {
|
||
return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
|
||
}
|
||
if config != nil {
|
||
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
|
||
if err != nil {
|
||
return CapabilitySpec{}, err
|
||
}
|
||
}
|
||
spec, err := CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability))
|
||
if err != nil {
|
||
return CapabilitySpec{}, err
|
||
}
|
||
return capabilitySpecWithPriceTiers(spec, channelModel), nil
|
||
}
|
||
|
||
// capabilitySpecWithPriceTiers 只让创作端选择已经启用且可结算的规格。规格组合最终仍由
|
||
// 路由时的精确价格档匹配保证,避免独立枚举无法表达“分辨率 × 时长”非笛卡尔组合的问题。
|
||
func capabilitySpecWithPriceTiers(spec CapabilitySpec, channelModel model.ChannelModel) CapabilitySpec {
|
||
if normalizeCapability(spec.Capability) != "video" || len(channelModel.PriceTiers) == 0 {
|
||
return spec
|
||
}
|
||
tiers := make([]model.ChannelModelPriceTier, 0, len(channelModel.PriceTiers))
|
||
for _, tier := range channelModel.PriceTiers {
|
||
if tier.Enabled && tier.PriceConfigured {
|
||
tiers = append(tiers, tier)
|
||
}
|
||
}
|
||
if len(tiers) == 0 {
|
||
return spec
|
||
}
|
||
result := spec
|
||
result.Options = make(map[string]OptionConstraint, len(spec.Options))
|
||
for name, option := range spec.Options {
|
||
result.Options[name] = option
|
||
}
|
||
hasResolutionWildcard, hasDurationWildcard := false, false
|
||
resolutions := make([]any, 0, len(tiers))
|
||
durations := make([]any, 0, len(tiers))
|
||
seenResolutions := make(map[string]bool, len(tiers))
|
||
seenDurations := make(map[int]bool, len(tiers))
|
||
for _, tier := range tiers {
|
||
if normalizeChannelModelTierResolution(tier.Resolution) == "*" {
|
||
hasResolutionWildcard = true
|
||
} else if value := normalizeChannelModelTierResolution(tier.Resolution); !seenResolutions[value] {
|
||
seenResolutions[value] = true
|
||
resolutions = append(resolutions, value)
|
||
}
|
||
if tier.VideoSeconds == 0 {
|
||
hasDurationWildcard = true
|
||
} else if !seenDurations[tier.VideoSeconds] {
|
||
seenDurations[tier.VideoSeconds] = true
|
||
durations = append(durations, tier.VideoSeconds)
|
||
}
|
||
}
|
||
if !hasResolutionWildcard && len(resolutions) > 0 {
|
||
result.Options["vquality"] = OptionConstraint{Values: resolutions}
|
||
}
|
||
if !hasDurationWildcard && len(durations) > 0 {
|
||
result.Options["videoSeconds"] = OptionConstraint{Values: durations}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func channelModelDefaultOptions(channelModel model.ChannelModel, spec CapabilitySpec) (map[string]any, error) {
|
||
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
|
||
if err != nil {
|
||
return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
|
||
}
|
||
if config != nil {
|
||
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
defaults := make(map[string]any)
|
||
if config != nil {
|
||
switch normalizeCapability(channelModel.Capability) {
|
||
case "image":
|
||
if config.Image != nil {
|
||
defaults["size"] = config.Image.Size.Default
|
||
if config.Image.Quality.Supported {
|
||
defaults["quality"] = config.Image.Quality.Default
|
||
}
|
||
if config.Image.TransparentBackground.Supported {
|
||
defaults["transparentBackground"] = config.Image.TransparentBackground.Default
|
||
}
|
||
}
|
||
case "video":
|
||
if config.Video != nil {
|
||
defaults["videoSeconds"] = config.Video.Duration.Default
|
||
defaults["vquality"] = normalizeChannelModelTierResolution(config.Video.DefaultResolution)
|
||
defaults["size"] = config.Video.DefaultRatio
|
||
if config.Video.GenerateAudio.Supported {
|
||
defaults["videoGenerateAudio"] = config.Video.GenerateAudio.Default
|
||
}
|
||
if config.Video.Watermark.Supported {
|
||
defaults["videoWatermark"] = config.Video.Watermark.Default
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 当渠道默认规格没有价格档时,优先选择第一个可结算档,确保初始选择可直接生成。
|
||
if normalizeCapability(channelModel.Capability) == "video" && channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "video", Options: defaults}) == nil {
|
||
for _, tier := range channelModel.PriceTiers {
|
||
if !tier.Enabled || !tier.PriceConfigured {
|
||
continue
|
||
}
|
||
if tier.Resolution != "*" {
|
||
defaults["vquality"] = normalizeChannelModelTierResolution(tier.Resolution)
|
||
}
|
||
if tier.VideoSeconds > 0 {
|
||
defaults["videoSeconds"] = tier.VideoSeconds
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return normalizeLogicalDefaults(spec, defaults)
|
||
}
|
||
|
||
func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent ModelRequestIntent) (*RouteSimulationResult, error) {
|
||
if err := s.RequireAdmin(actor); err != nil {
|
||
return nil, err
|
||
}
|
||
snapshot, err := s.routeCatalogSnapshot()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cached, ok := snapshot.Models[id]
|
||
if !ok {
|
||
return nil, BadAuthRequest("前台模型未启用或尚未发布")
|
||
}
|
||
intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults)
|
||
return &RouteSimulationResult{ProductMatch: MatchCapability(cached.ProductSpec, intent), Candidates: s.sortedRouteDiagnostics(cached.Routes, intent)}, nil
|
||
}
|
||
|
||
func logicalModelNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }
|
||
|
||
// computeModelPriceDisplay 计算模型的价格展示信息
|
||
// 返回:pricingMode, displayPrice, priceLabel
|
||
func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) {
|
||
if model.PricePolicy == "channel" {
|
||
// 跟随渠道价格
|
||
if len(priceTiers) == 0 {
|
||
return "provider", nil, "未配置"
|
||
}
|
||
// 检查是否所有价格档都相同
|
||
if len(priceTiers) == 1 {
|
||
tier := priceTiers[0]
|
||
price := getTierDisplayPrice(tier)
|
||
if price > 0 {
|
||
return "provider", &price, ""
|
||
}
|
||
}
|
||
// 多个价格档或价格为0,显示"按渠道规格计费"
|
||
return "provider", nil, "按渠道规格计费"
|
||
}
|
||
|
||
// 统一定价模式
|
||
if model.BillingMode == "fixed_request" && model.UnitPriceMicrocredits > 0 {
|
||
price := model.UnitPriceMicrocredits
|
||
return "unified", &price, ""
|
||
}
|
||
if model.BillingMode == "per_second" && model.UnitPriceMicrocredits > 0 {
|
||
price := model.UnitPriceMicrocredits
|
||
return "unified", &price, "按秒"
|
||
}
|
||
if model.BillingMode == "token" {
|
||
// Token 计费显示输入/输出价格
|
||
if model.InputPriceMicrocredits > 0 || model.OutputPriceMicrocredits > 0 {
|
||
return "unified", nil, "按 Token"
|
||
}
|
||
}
|
||
|
||
return "unified", nil, "未配置"
|
||
}
|
||
|
||
// getTierDisplayPrice 获取价格档的展示价格
|
||
func getTierDisplayPrice(tier PublicLogicalModelPriceTier) int64 {
|
||
if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" {
|
||
return tier.UnitPriceMicrocredits
|
||
}
|
||
// Token 计费返回输出价格(如果有)
|
||
if tier.OutputTokenPriceMicrocredits > 0 {
|
||
return tier.OutputTokenPriceMicrocredits
|
||
}
|
||
if tier.InputTokenPriceMicrocredits > 0 {
|
||
return tier.InputTokenPriceMicrocredits
|
||
}
|
||
return 0
|
||
}
|