feat(models): 统一模型规格价格与路由
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
@@ -20,6 +23,7 @@ func Models() []any {
|
||||
&model.EmailVerificationCode{},
|
||||
&model.ModelChannel{},
|
||||
&model.ChannelModel{},
|
||||
&model.ChannelModelPriceTier{},
|
||||
&model.IDSequence{},
|
||||
&model.LogicalModel{},
|
||||
&model.LogicalModelRevision{},
|
||||
@@ -90,6 +94,15 @@ func MigrateSchema(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(Models()...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateChannelModelPriceTierSelectors(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillChannelModelPriceTiers(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateChannelModelPriceTierSelectors(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dropLegacyPhysicalVariants(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,9 +120,113 @@ func MigrateSchema(db *gorm.DB) error {
|
||||
if err := db.Exec("DROP INDEX IF EXISTS idx_route_attempt_task_number").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DROP INDEX IF EXISTS idx_logical_model_source_active").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_model_source_active ON logical_models(source_channel_model_id) WHERE source_channel_model_id <> '' AND archived_at IS NULL").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error
|
||||
}
|
||||
|
||||
// migrateChannelModelPriceTierSelectors upgrades the old video-only unique key to
|
||||
// a canonical SKU selector key. It never changes task, route-attempt, or billing
|
||||
// foreign keys, so completed work remains auditable after a product SKU merge.
|
||||
func migrateChannelModelPriceTierSelectors(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.ChannelModelPriceTier{}) {
|
||||
return nil
|
||||
}
|
||||
var tiers []model.ChannelModelPriceTier
|
||||
if err := db.Unscoped().Find(&tiers).Error; err != nil {
|
||||
return fmt.Errorf("读取规格价格档:%w", err)
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
if len(selector) == 0 {
|
||||
selector = map[string]string{}
|
||||
if resolution := strings.TrimSpace(tier.Resolution); resolution != "" && resolution != "*" {
|
||||
selector["vquality"] = strings.ToLower(resolution)
|
||||
}
|
||||
if tier.VideoSeconds > 0 {
|
||||
selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds)
|
||||
}
|
||||
}
|
||||
_, key, err := model.CanonicalSKUSelector(selector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("规范化规格价格档 %s:%w", tier.ID, err)
|
||||
}
|
||||
if tier.SelectorKey == key && tier.SelectorJSON == key {
|
||||
continue
|
||||
}
|
||||
if err := db.Unscoped().Model(&model.ChannelModelPriceTier{}).Where("id = ?", tier.ID).Updates(map[string]any{"selector_key": key, "selector_json": key}).Error; err != nil {
|
||||
return fmt.Errorf("更新规格价格档 %s:%w", tier.ID, err)
|
||||
}
|
||||
}
|
||||
var duplicate struct {
|
||||
ChannelModelID string
|
||||
SelectorKey string
|
||||
Count int64
|
||||
}
|
||||
err := db.Table("channel_model_price_tiers").
|
||||
Select("channel_model_id, selector_key, COUNT(*) AS count").
|
||||
Where("deleted_at IS NULL").
|
||||
Group("channel_model_id, selector_key").
|
||||
Having("COUNT(*) > 1").
|
||||
First(&duplicate).Error
|
||||
if err == nil {
|
||||
return fmt.Errorf("渠道模型 %s 存在重复 SKU 选择器 %s,拒绝建立唯一索引", duplicate.ChannelModelID, duplicate.SelectorKey)
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("检查重复 SKU 选择器:%w", err)
|
||||
}
|
||||
if err := db.Exec("DROP INDEX IF EXISTS idx_channel_model_price_tier_active").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_model_price_tier_active ON channel_model_price_tiers(channel_model_id, selector_key) WHERE deleted_at IS NULL").Error
|
||||
}
|
||||
|
||||
// backfillChannelModelPriceTiers 将旧的单价模型无损映射为默认价格档。历史订单保存的是
|
||||
// 金额快照,不能也不需要回写;这里仅保证升级后现有渠道模型仍能按原价格继续结算。
|
||||
func backfillChannelModelPriceTiers(db *gorm.DB) error {
|
||||
var channelModels []model.ChannelModel
|
||||
if err := db.Find(&channelModels).Error; err != nil {
|
||||
return fmt.Errorf("读取渠道模型价格档回填数据:%w", err)
|
||||
}
|
||||
for _, channelModel := range channelModels {
|
||||
var count int64
|
||||
if err := db.Model(&model.ChannelModelPriceTier{}).Where("channel_model_id = ?", channelModel.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("检查渠道模型 %s 价格档:%w", channelModel.ID, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
priceVersion := channelModel.PriceVersion
|
||||
if priceVersion < 1 {
|
||||
priceVersion = 1
|
||||
}
|
||||
digest := sha256.Sum256([]byte(channelModel.ID))
|
||||
tier := model.ChannelModelPriceTier{
|
||||
ID: fmt.Sprintf("PTIER-%x", digest[:15]),
|
||||
ChannelModelID: channelModel.ID,
|
||||
Resolution: "*",
|
||||
VideoSeconds: 0,
|
||||
ProviderModelKey: channelModel.ProviderModelKey,
|
||||
BillingMode: channelModel.BillingMode,
|
||||
UnitPriceMicrocredits: channelModel.UnitPriceMicrocredits,
|
||||
InputTokenPriceMicrocredits: channelModel.InputTokenPriceMicrocredits,
|
||||
OutputTokenPriceMicrocredits: channelModel.OutputTokenPriceMicrocredits,
|
||||
CachedTokenPriceMicrocredits: channelModel.CachedTokenPriceMicrocredits,
|
||||
PriceConfigured: channelModel.PriceConfigured,
|
||||
Enabled: channelModel.Enabled,
|
||||
PriceVersion: priceVersion,
|
||||
}
|
||||
if err := db.Create(&tier).Error; err != nil {
|
||||
return fmt.Errorf("回填渠道模型 %s 默认价格档:%w", channelModel.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateLogicalRoutesToChannelModels 在模型结构切换前把历史 variant 外键转换为渠道模型外键。
|
||||
// 写路径不能接受无法映射的历史引用,否则任务恢复和计费会失去明确执行目标。
|
||||
func migrateLogicalRoutesToChannelModels(db *gorm.DB) error {
|
||||
|
||||
Reference in New Issue
Block a user