refactor(grok): 将视频模型价纳入 Ent 持久化

This commit is contained in:
IanShaw027
2026-08-07 13:55:58 +08:00
parent e12e0dc1a6
commit dfca6246eb
15 changed files with 292 additions and 177 deletions
@@ -969,6 +969,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
VideoPrice480P: g.VideoPrice480p,
VideoPrice720P: g.VideoPrice720p,
VideoPrice1080P: g.VideoPrice1080p,
VideoModelPrices: service.NormalizeVideoModelPrices(g.VideoModelPrices),
WebSearchPricePerCall: g.WebSearchPricePerCall,
DefaultValidityDays: g.DefaultValidityDays,
ClaudeCodeOnly: g.ClaudeCodeOnly,
@@ -19,6 +19,9 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
RateMultiplier: 1,
AllowMessagesDispatch: true,
DefaultMappedModel: "gpt-5.4",
VideoModelPrices: map[string]map[string]float64{
service.VideoPriceFamilyGrokImagineVideo15: {service.VideoBillingResolution720P: 0.14},
},
MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
OpusMappedModel: "gpt-5.4-nano",
SonnetMappedModel: "gpt-5.3-codex",
@@ -32,6 +35,7 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
got := groupEntityToService(group)
require.NotNil(t, got)
require.Equal(t, group.MessagesDispatchModelConfig, got.MessagesDispatchModelConfig)
require.Equal(t, group.VideoModelPrices, got.VideoModelPrices)
}
func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_SQLite(t *testing.T) {
+3 -11
View File
@@ -46,9 +46,6 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
if err := createGroupRecord(ctx, r.client, groupIn); err != nil {
return err
}
if saveErr := saveGroupVideoModelPrices(ctx, r.sql, groupIn.ID, groupIn.VideoModelPrices); saveErr != nil {
return fmt.Errorf("save group video_model_prices: %w", saveErr)
}
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err)
}
@@ -85,6 +82,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
@@ -228,11 +226,7 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G
if err != nil {
return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil)
}
out := groupEntityToService(m)
if prices, loadErr := loadGroupVideoModelPrices(ctx, r.sql, []int64{id}); loadErr == nil {
applyVideoModelPricesToGroup(out, prices)
}
return out, nil
return groupEntityToService(m), nil
}
func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error {
@@ -261,6 +255,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
@@ -363,9 +358,6 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists)
}
groupIn.UpdatedAt = updated.UpdatedAt
if err := saveGroupVideoModelPrices(ctx, r.sql, groupIn.ID, groupIn.VideoModelPrices); err != nil {
return fmt.Errorf("save group video_model_prices: %w", err)
}
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group update failed: group=%d err=%v", groupIn.ID, err)
}
@@ -1,114 +0,0 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
)
func loadGroupVideoModelPrices(ctx context.Context, sqlq sqlExecutor, groupIDs []int64) (map[int64]map[string]map[string]float64, error) {
out := make(map[int64]map[string]map[string]float64, len(groupIDs))
if sqlq == nil || len(groupIDs) == 0 {
return out, nil
}
rows, err := sqlq.QueryContext(ctx, `
SELECT id, video_model_prices
FROM groups
WHERE id = ANY($1) AND deleted_at IS NULL
`, pq.Array(groupIDs))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
groupID int64
raw []byte
)
if err := rows.Scan(&groupID, &raw); err != nil {
return nil, err
}
prices, err := decodeVideoModelPrices(raw)
if err != nil {
return nil, err
}
if prices != nil {
out[groupID] = prices
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func saveGroupVideoModelPrices(ctx context.Context, sqlq sqlExecutor, groupID int64, prices map[string]map[string]float64) error {
if sqlq == nil || groupID <= 0 {
return nil
}
normalized := service.NormalizeVideoModelPrices(prices)
if len(normalized) == 0 {
_, err := sqlq.ExecContext(ctx, `
UPDATE groups
SET video_model_prices = NULL
WHERE id = $1
`, groupID)
return err
}
payload, err := json.Marshal(normalized)
if err != nil {
return err
}
_, err = sqlq.ExecContext(ctx, `
UPDATE groups
SET video_model_prices = $1::jsonb
WHERE id = $2
`, string(payload), groupID)
return err
}
func applyVideoModelPricesToGroups(groups []service.Group, pricesByID map[int64]map[string]map[string]float64) {
for i := range groups {
if prices, ok := pricesByID[groups[i].ID]; ok {
groups[i].VideoModelPrices = service.NormalizeVideoModelPrices(prices)
continue
}
groups[i].VideoModelPrices = service.NormalizeVideoModelPrices(groups[i].VideoModelPrices)
}
}
func applyVideoModelPricesToGroup(group *service.Group, pricesByID map[int64]map[string]map[string]float64) {
if group == nil {
return
}
if prices, ok := pricesByID[group.ID]; ok {
group.VideoModelPrices = service.NormalizeVideoModelPrices(prices)
return
}
group.VideoModelPrices = service.NormalizeVideoModelPrices(group.VideoModelPrices)
}
func decodeVideoModelPrices(raw []byte) (map[string]map[string]float64, error) {
if len(raw) == 0 {
return nil, nil
}
// Driver may return NULL as nil slice; treat empty JSON as nil.
trimmed := string(raw)
if trimmed == "" || trimmed == "null" {
return nil, nil
}
var parsed map[string]map[string]float64
if err := json.Unmarshal(raw, &parsed); err != nil {
// Some drivers surface NULL via sql.NullString paths; tolerate empty object.
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return service.NormalizeVideoModelPrices(parsed), nil
}
@@ -67,6 +67,21 @@ func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 {
return cloned
}
func cloneGroupVideoModelPrices(value map[string]map[string]float64) map[string]map[string]float64 {
if value == nil {
return nil
}
cloned := make(map[string]map[string]float64, len(value))
for model, prices := range value {
clonedPrices := make(map[string]float64, len(prices))
for resolution, price := range prices {
clonedPrices[resolution] = price
}
cloned[model] = clonedPrices
}
return cloned
}
func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig {
cloned := value
if value.ExactModelMappings != nil {
@@ -113,6 +128,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P),
VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P),
VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P),
VideoModelPrices: cloneGroupVideoModelPrices(source.VideoModelPrices),
WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall),
ClaudeCodeOnly: source.ClaudeCodeOnly,
FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID),
@@ -121,37 +121,40 @@ func groupDuplicateTestPointer[T any](value T) *T { return &value }
func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) {
createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC)
source := &Group{
ID: 41,
Name: "高级订阅",
Description: "configuration",
Platform: PlatformOpenAI,
RateMultiplier: 1.75,
PeakRateEnabled: true,
PeakStart: "09:00",
PeakEnd: "18:00",
PeakRateMultiplier: 1.2,
IsExclusive: true,
Status: StatusActive,
Hydrated: true,
SubscriptionType: SubscriptionTypeSubscription,
DailyLimitUSD: groupDuplicateTestPointer(11.0),
WeeklyLimitUSD: groupDuplicateTestPointer(22.0),
MonthlyLimitUSD: groupDuplicateTestPointer(33.0),
DefaultValidityDays: 91,
AllowImageGeneration: true,
AllowBatchImageGeneration: true,
ImageRateIndependent: true,
ImageRateMultiplier: 1.4,
ImagePrice1K: groupDuplicateTestPointer(0.01),
ImagePrice2K: groupDuplicateTestPointer(0.02),
ImagePrice4K: groupDuplicateTestPointer(0.04),
BatchImageDiscountMultiplier: 0.4,
BatchImageHoldMultiplier: 0.7,
VideoRateIndependent: true,
VideoRateMultiplier: 2.1,
VideoPrice480P: groupDuplicateTestPointer(0.1),
VideoPrice720P: groupDuplicateTestPointer(0.2),
VideoPrice1080P: groupDuplicateTestPointer(0.3),
ID: 41,
Name: "高级订阅",
Description: "configuration",
Platform: PlatformOpenAI,
RateMultiplier: 1.75,
PeakRateEnabled: true,
PeakStart: "09:00",
PeakEnd: "18:00",
PeakRateMultiplier: 1.2,
IsExclusive: true,
Status: StatusActive,
Hydrated: true,
SubscriptionType: SubscriptionTypeSubscription,
DailyLimitUSD: groupDuplicateTestPointer(11.0),
WeeklyLimitUSD: groupDuplicateTestPointer(22.0),
MonthlyLimitUSD: groupDuplicateTestPointer(33.0),
DefaultValidityDays: 91,
AllowImageGeneration: true,
AllowBatchImageGeneration: true,
ImageRateIndependent: true,
ImageRateMultiplier: 1.4,
ImagePrice1K: groupDuplicateTestPointer(0.01),
ImagePrice2K: groupDuplicateTestPointer(0.02),
ImagePrice4K: groupDuplicateTestPointer(0.04),
BatchImageDiscountMultiplier: 0.4,
BatchImageHoldMultiplier: 0.7,
VideoRateIndependent: true,
VideoRateMultiplier: 2.1,
VideoPrice480P: groupDuplicateTestPointer(0.1),
VideoPrice720P: groupDuplicateTestPointer(0.2),
VideoPrice1080P: groupDuplicateTestPointer(0.3),
VideoModelPrices: map[string]map[string]float64{
VideoPriceFamilyGrokImagineVideo15: {VideoBillingResolution720P: 0.14},
},
WebSearchPricePerCall: groupDuplicateTestPointer(0.005),
ClaudeCodeOnly: true,
FallbackGroupID: groupDuplicateTestPointer(int64(7)),
@@ -204,6 +207,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier)
require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays)
require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K)
require.Equal(t, source.VideoModelPrices, duplicate.VideoModelPrices)
require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall)
require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
@@ -222,12 +226,14 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
}, repo.createdBindings[duplicate.ID])
duplicate.ModelRouting["gpt-*"][0] = 999
duplicate.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P] = 999
duplicate.SupportedModelScopes[0] = "changed"
duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed"
duplicate.ModelsListConfig.Models[0] = "changed"
duplicate.ReasoningEffortMappings[0].To = "changed"
*duplicate.DailyLimitUSD = 999
require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0])
require.Equal(t, 0.14, source.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P])
require.Equal(t, "claude", source.SupportedModelScopes[0])
require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"])
require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0])