feat(models): 统一模型规格价格与路由

This commit is contained in:
kinoko
2026-08-22 17:00:08 +08:00
parent d268a938e5
commit 236b850177
36 changed files with 3404 additions and 421 deletions

View File

@@ -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 {