340 lines
12 KiB
Go
340 lines
12 KiB
Go
// 渠道模型与价格档的持久化。
|
||
//
|
||
// 修改渠道下的模型前先锁定渠道行(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
|
||
}
|