Files
ttstd-infinite-canvas/backend/internal/repository/finance_channel_models.go

340 lines
12 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.
// 渠道模型与价格档的持久化。
//
// 修改渠道下的模型前先锁定渠道行(lockSystemChannelForModelMutation),避免并发编辑
// 产生重复价格档或遗漏名称同步。
package repository
import (
"encoding/json"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"yingce/backend/internal/model"
)
type AdminRedeemCodeRow struct {
model.RedeemCode
RedeemedUsername string `json:"redeemedUsername" gorm:"column:redeemed_username"`
RedeemedDisplayName string `json:"redeemedDisplayName" gorm:"column:redeemed_display_name"`
}
func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) {
var items []model.ChannelModel
query := r.db.Where("channel_id = ?", channelID).Order("sort_order asc, created_at asc, id asc")
if !includeDisabled {
query = query.Where("enabled = ?", true)
}
if err := query.Find(&items).Error; err != nil {
return nil, err
}
pointers := make([]*model.ChannelModel, len(items))
for index := range items {
pointers[index] = &items[index]
}
return items, r.attachChannelModelPriceTiers(pointers)
}
func (r *Repository) ChannelModelReferences(channelIDs []string) ([]model.ChannelModel, error) {
if len(channelIDs) == 0 {
return []model.ChannelModel{}, nil
}
var items []model.ChannelModel
if err := r.db.Select("id", "channel_id", "model_key", "display_name", "sort_order", "enabled").
Where("channel_id IN ?", channelIDs).
Order("channel_id asc, sort_order asc, created_at asc, id asc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(channel).Error; err != nil {
return err
}
if len(channelModels) > 0 {
if err := tx.Create(&channelModels).Error; err != nil {
return err
}
}
if len(priceTiers) > 0 {
if err := tx.Create(&priceTiers).Error; err != nil {
return err
}
}
return nil
})
}
func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "channel_id = ? AND model_key = ? AND enabled = ?", channelID, modelKey, true).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelKey string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil {
return nil, err
}
if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
return r.db.Save(item).Error
}
// SaveChannelModelWithPriceTiers 原子保存系统模型与其活动价格档。移除价格档采用软删除,
// 让已结算订单的 PriceTierID 仍能回溯到原始配置版本。
func (r *Repository) SaveChannelModelWithPriceTiers(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var existing []model.ChannelModelPriceTier
if err := tx.Where("channel_model_id = ?", item.ID).Find(&existing).Error; err != nil {
return err
}
existingByKey := make(map[string]model.ChannelModelPriceTier, len(existing))
for _, tier := range existing {
existingByKey[channelModelPriceTierKey(tier)] = tier
}
selected := make(map[string]bool, len(tiers))
for index := range tiers {
tier := &tiers[index]
tier.ChannelModelID = item.ID
key := channelModelPriceTierKey(*tier)
if existingTier, exists := existingByKey[key]; exists {
tier.ID = existingTier.ID
tier.PriceVersion = existingTier.PriceVersion + 1
if err := tx.Save(tier).Error; err != nil {
return err
}
} else if err := tx.Create(tier).Error; err != nil {
return err
}
selected[tier.ID] = true
}
for _, tier := range existing {
if selected[tier.ID] {
continue
}
if err := tx.Delete(&tier).Error; err != nil {
return err
}
}
if err := tx.Save(item).Error; err != nil {
return err
}
return nil
})
}
func channelModelPriceTierKey(tier model.ChannelModelPriceTier) string {
if strings.TrimSpace(tier.SelectorKey) != "" {
return tier.SelectorKey
}
_, key, err := model.CanonicalSKUSelector(map[string]string{
"vquality": strings.TrimSpace(tier.Resolution),
"videoSeconds": strconv.Itoa(tier.VideoSeconds),
})
if err != nil {
return "{}"
}
return key
}
func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) error {
if len(items) == 0 {
return nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
var tiers []model.ChannelModelPriceTier
if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) {
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
return err
}
}
for index := range tiers {
tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON)
}
tiersByModelID := make(map[string][]model.ChannelModelPriceTier, len(items))
for _, tier := range tiers {
tiersByModelID[tier.ChannelModelID] = append(tiersByModelID[tier.ChannelModelID], tier)
}
for _, item := range items {
item.PriceTiers = tiersByModelID[item.ID]
// 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。
if len(item.PriceTiers) == 0 && item.PriceConfigured {
item.PriceTiers = []model.ChannelModelPriceTier{{
ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*",
ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode,
UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits,
PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion,
}}
}
}
return nil
}
// PopulateChannelModelPriceTiers 将价格档附着到已经查询出的渠道模型,供路由关系图批量加载使用。
func (r *Repository) PopulateChannelModelPriceTiers(items []model.ChannelModel) error {
pointers := make([]*model.ChannelModel, len(items))
for index := range items {
pointers[index] = &items[index]
}
return r.attachChannelModelPriceTiers(pointers)
}
func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) error {
if item == nil {
return nil
}
return r.attachChannelModelPriceTiers([]*model.ChannelModel{item})
}
func (r *Repository) DeleteChannelModel(channelID string, id string, now time.Time) error {
deleted, err := r.DeleteChannelModels(channelID, []string{id}, now)
if err != nil {
return err
}
if deleted != 1 {
return gorm.ErrRecordNotFound
}
return nil
}
// DeleteChannelModels atomically removes a selection and refreshes the channel's
// compatibility model list. Any active route or task reference aborts the whole
// transaction so a bulk action cannot leave the administrator with a partial result.
func (r *Repository) DeleteChannelModels(channelID string, ids []string, now time.Time) (int64, error) {
ids = uniqueStrings(ids)
if len(ids) == 0 {
return 0, gorm.ErrRecordNotFound
}
var deleted int64
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil {
return err
}
var existing int64
if err := tx.Model(&model.ChannelModel{}).
Where("channel_id = ? AND id IN ?", channelID, ids).
Count(&existing).Error; err != nil {
return err
}
if existing != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
var activeReferences int64
if err := tx.Table("logical_model_routes AS route").
Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id").
Where("route.channel_model_id IN ?", ids).
Count(&activeReferences).Error; err != nil {
return err
}
if activeReferences > 0 {
return ErrChannelModelInUse
}
if err := tx.Model(&model.Task{}).
Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).
Count(&activeReferences).Error; err != nil {
return err
}
if activeReferences > 0 {
return ErrChannelModelInUse
}
result := tx.Model(&model.ChannelModel{}).
Where("id IN ? AND channel_id = ?", ids, channelID).
Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil {
return err
}
if err := refreshChannelModelNames(tx, channelID, now); err != nil {
return err
}
deleted = result.RowsAffected
return nil
})
return deleted, err
}
// SyncChannelModelNames serializes compatibility-list refreshes for a system
// channel and derives the list from the committed channel_models rows. Callers
// must not pass a previously-read snapshot because concurrent administrator
// writes could otherwise overwrite a newer catalog.
func (r *Repository) SyncChannelModelNames(channelID string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil {
return err
}
return refreshChannelModelNames(tx, channelID, now)
})
}
func (r *Repository) lockSystemChannelForModelMutation(tx *gorm.DB, channelID string) error {
query := tx.Select("id").Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem)
if r.Dialect() == "postgres" {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
var channel model.ModelChannel
return query.First(&channel).Error
}
func refreshChannelModelNames(tx *gorm.DB, channelID string, now time.Time) error {
var names []string
if err := tx.Model(&model.ChannelModel{}).
Where("channel_id = ? AND enabled = ?", channelID, true).
Order("sort_order asc, created_at asc, id asc").
Pluck("model_key", &names).Error; err != nil {
return err
}
encoded, err := json.Marshal(names)
if err != nil {
return err
}
return tx.Model(&model.ModelChannel{}).
Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem).
Updates(map[string]any{"models_json": string(encoded), "updated_at": now}).Error
}
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
if len(items) == 0 {
return 0, nil
}
// 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items)
return result.RowsAffected, result.Error
}