Files
ttstd-infinite-canvas/backend/internal/app/channel_models.go

457 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"yingce/backend/internal/model"
"yingce/backend/internal/protocol"
"yingce/backend/internal/repository"
)
type ChannelModelRequest struct {
ModelKey string `json:"modelKey"`
ProviderModelKey string `json:"providerModelKey"`
DisplayName string `json:"displayName"`
ChannelLabel string `json:"channelLabel"`
Tags []model.ChannelModelTag `json:"tags"`
Description string `json:"description"`
Icon string `json:"icon"`
Capability string `json:"capability"`
Protocol string `json:"protocol"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
PriceConfigured bool `json:"priceConfigured"`
Enabled *bool `json:"enabled"`
CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"`
PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"`
}
const maxAdminChannelModelBatchDeleteCount = 100
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
type ChannelModelPriceTierRequest struct {
CostPricing model.CreditCostPricing `json:"costPricing"`
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio;
// operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。
Selector map[string]string `json:"selector"`
Resolution string `json:"resolution"`
VideoSeconds int `json:"videoSeconds"`
ProviderModelKey string `json:"providerModelKey"`
BillingMode string `json:"billingMode"`
UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"`
InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"`
OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"`
CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"`
PriceConfigured bool `json:"priceConfigured"`
Enabled *bool `json:"enabled"`
}
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
type AdminChannelModelFetchResult struct {
Models []string `json:"models"`
Added int64 `json:"added"`
}
type AdminChannelModelImportRequest struct {
Models []string `json:"models"`
}
type AdminChannelModelTestResult struct {
DurationMs int64 `json:"durationMs"`
}
func (s *Service) EnsureSystemChannelModels() error {
channels, err := s.repo.SystemChannels(true)
if err != nil {
return err
}
for index := range channels {
items, err := s.repo.ChannelModels(channels[index].ID, true)
if err != nil {
return err
}
if len(items) == 0 {
if err := s.syncInitialChannelModels(&channels[index], channelModelNames(channels[index])); err != nil {
return err
}
}
}
return nil
}
func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if _, err := s.adminSystemChannel(channelID); err != nil {
return nil, err
}
items, err := s.ensureChannelModels(channelID, true)
if err != nil {
return nil, err
}
for index := range items {
if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" {
continue
}
config, decodeErr := DecodeModelCapabilityConfig(items[index].CapabilityConfigJSON)
if decodeErr != nil || config == nil {
continue
}
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config)
if normalizeErr != nil || normalized == nil {
continue
}
encoded, encodeErr := json.Marshal(normalized)
var value map[string]any
if encodeErr == nil && json.Unmarshal(encoded, &value) == nil {
items[index].CapabilityConfig = value
}
}
return items, nil
}
func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) {
return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/"))
}
// SystemChannelHasProtocol 用于没有携带 model 字段的轮询请求:先确认渠道确实配置了该协议,
// 再由 handler 按协议限定请求路径,避免用空模型绕过系统渠道授权。
func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.ChannelInterfaceType) (bool, error) {
items, err := s.repo.ChannelModels(channelID, false)
if err != nil {
return false, err
}
for _, item := range items {
if item.Protocol == protocol {
return true, nil
}
}
return false, nil
}
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channelLabel := strings.TrimSpace(req.ChannelLabel)
tags, err := normalizeChannelModelTags(req.Tags)
if err != nil {
return nil, err
}
description := strings.TrimSpace(req.Description)
if len([]rune(description)) > 500 {
return nil, BadAuthRequest("模型描述不能超过 500 字")
}
if len([]rune(channelLabel)) > 80 {
return nil, BadAuthRequest("渠道展示名不能超过 80 字")
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
// 先检查同渠道重复模型,避免无关能力校验或生成无用序列号掩盖真正的冲突。
conflict, conflictErr := s.repo.ChannelModelByKeyIncludingDisabled(channelID, modelKey)
if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
return nil, conflictErr
}
if conflict != nil && conflict.ID != strings.TrimSpace(id) {
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
return nil, err
}
}
tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey)
if err != nil {
return nil, err
}
modelID, err := s.repo.NextPrefixedID("MODEL")
if err != nil {
return nil, err
}
item := &model.ChannelModel{ID: modelID, ChannelID: channelID, Enabled: true, PriceVersion: 1}
previousProviderModelKey := ""
if id != "" {
item, err = s.repo.ChannelModelByID(channelID, id)
if err != nil {
return nil, err
}
previousProviderModelKey = strings.TrimPrefix(strings.TrimSpace(item.ProviderModelKey), "models/")
item.PriceVersion++
}
// 模型级上游键重命名时,与旧值相同的档位键属于“跟随模型默认”的隐式固化,必须级联跟随;
// 否则任务请求会继续把旧上游键发给供应商。管理员显式配置的其他上游 SKU 不受影响。
if previousProviderModelKey != "" && providerModelKey != previousProviderModelKey {
for index := range tiers {
if strings.TrimPrefix(strings.TrimSpace(tiers[index].ProviderModelKey), "models/") == previousProviderModelKey {
tiers[index].ProviderModelKey = providerModelKey
}
}
}
item.ModelKey = modelKey
item.ProviderModelKey = providerModelKey
item.DisplayName = strings.TrimSpace(req.DisplayName)
item.ChannelLabel = channelLabel
item.Tags = tags
item.Description = description
if item.DisplayName == "" {
item.DisplayName = modelKey
}
item.Icon = strings.TrimSpace(req.Icon)
item.Capability = capability
item.Protocol = protocol
s.applyChannelModelPriceTierSummary(item, tiers)
if capability == "text" || capability == "image" || capability == "video" {
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
encoded, encodeErr := json.Marshal(capabilityConfig)
if encodeErr != nil {
return nil, encodeErr
}
if item.CapabilityConfigJSON != string(encoded) {
item.CapabilityVersion++
}
item.CapabilityConfigJSON = string(encoded)
} else {
item.CapabilityConfigJSON = ""
item.CapabilityVersion = 0
}
if req.Enabled != nil {
item.Enabled = *req.Enabled
}
if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability); err != nil {
return nil, err
}
// 渠道模型及所有价格档必须同时落库,不能出现“能力已开放但规格价格尚未更新”的窗口。
if err := s.repo.SaveChannelModelWithPriceTiers(item, tiers); err != nil {
return nil, err
}
item.PriceTiers = tiers
s.invalidateRouteCatalog()
if err := s.syncChannelModelNames(channel); err != nil {
return nil, err
}
if err := s.syncLogicalModelsFromChannelModel(actor, item); err != nil {
return nil, err
}
return item, nil
}
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
return normalizeChannelModelContractWithRegistry(protocol.Builtins(), channel, req)
}
func (s *Service) normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
return normalizeChannelModelContractWithRegistry(s.protocolRegistry(), channel, req)
}
func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) {
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
if modelKey == "" {
return "", "", "", "", BadAuthRequest("请填写模型标识")
}
providerModelKey := strings.TrimPrefix(strings.TrimSpace(req.ProviderModelKey), "models/")
if providerModelKey == "" {
providerModelKey = modelKey
}
capability := normalizeCapability(req.Capability)
if capability == "" {
return "", "", "", "", BadAuthRequest("请选择模型能力")
}
adapter, ok := registry.Resolve(strings.TrimSpace(req.Protocol))
if !ok || !adapter.Metadata().Enabled || adapter.Metadata().UnavailableReason != "" {
return "", "", "", "", BadAuthRequest("请选择有效的模型请求协议")
}
protocol := model.ChannelInterfaceType(adapter.Metadata().ID)
if expected := protocolCapabilityFromMetadata(adapter.Metadata()); expected != "" && expected != capability {
return "", "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return "", "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
return modelKey, providerModelKey, capability, protocol, nil
}
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
_, err := s.DeleteAdminChannelModels(actor, channelID, []string{id})
return err
}
// DeleteAdminChannelModels validates the complete selection before asking the
// repository to remove it atomically. This deliberately rejects partial success:
// administrators can safely correct an in-use model and retry the same selection.
func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) {
if err := s.RequireAdmin(actor); err != nil {
return 0, err
}
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, BadAuthRequest("系统渠道不存在或已删除")
}
return 0, err
}
modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids)
if err != nil {
return 0, err
}
items, err := s.repo.ChannelModels(channelID, true)
if err != nil {
return 0, err
}
selected := make(map[string]bool, len(modelIDs))
for _, id := range modelIDs {
selected[id] = true
}
found := 0
for _, item := range items {
if selected[item.ID] {
found++
}
}
if found != len(modelIDs) {
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
// 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。
deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, time.Now())
if errors.Is(err, repository.ErrChannelModelInUse) {
return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试")
}
if err == nil {
s.invalidateRouteCatalog()
}
return deleted, err
}
func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
if len(result) == 0 {
return nil, BadAuthRequest("请至少选择一个要删除的渠道模型")
}
if len(result) > maxAdminChannelModelBatchDeleteCount {
return nil, BadAuthRequest("单次最多删除 100 个渠道模型")
}
return result, nil
}
func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error {
existing, err := s.repo.ChannelModels(channel.ID, true)
if err != nil {
return err
}
byKey := make(map[string]*model.ChannelModel, len(existing))
for index := range existing {
byKey[existing[index].ModelKey] = &existing[index]
}
desired := make(map[string]bool, len(names))
retired := retiredChannelModelKeys(channel.RetiredModelsJSON)
for _, name := range uniqueNonEmpty(names) {
name = strings.TrimPrefix(name, "models/")
if retired[channelModelCatalogKey(name)] {
continue
}
desired[name] = true
if item := byKey[name]; item != nil {
continue
}
modelID, idErr := s.repo.NextPrefixedID("MODEL")
if idErr != nil {
return idErr
}
item := model.ChannelModel{ID: modelID, ChannelID: channel.ID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, UnitPriceMicrocredits: 0, PriceVersion: 1}
if err := s.repo.SaveChannelModel(&item); err != nil {
return err
}
}
for index := range existing {
if !desired[existing[index].ModelKey] {
changed := existing[index].Enabled
existing[index].Enabled = false
if changed {
existing[index].PriceVersion++
if err := s.repo.SaveChannelModel(&existing[index]); err != nil {
return err
}
}
}
}
return nil
}
func retiredChannelModelKeys(raw string) map[string]bool {
var values []string
_ = json.Unmarshal([]byte(raw), &values)
result := make(map[string]bool, len(values))
for _, value := range values {
if key := channelModelCatalogKey(value); key != "" {
result[key] = true
}
}
return result
}
func channelModelCatalogKey(value string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/"))
}
func (s *Service) ensureChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) {
items, err := s.repo.ChannelModels(channelID, includeDisabled)
if err != nil || len(items) > 0 {
return items, err
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
if err := s.syncInitialChannelModels(channel, channelModelNames(*channel)); err != nil {
return nil, err
}
return s.repo.ChannelModels(channelID, includeDisabled)
}
func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
return s.repo.SyncChannelModelNames(channel.ID, time.Now())
}
func (s *Service) capabilityForProtocol(protocol model.ChannelInterfaceType) string {
metadata, ok := s.channelProtocolMetadata(string(protocol))
if !ok {
return ""
}
return protocolCapabilityFromMetadata(metadata)
}
func protocolCapabilityFromMetadata(metadata protocol.Metadata) string {
if len(metadata.Categories) == 0 {
return ""
}
return string(metadata.Categories[0])
}