From dfca6246eb9d445fc57bddb95a7e5c77ecaeabab Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 13:55:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor(grok):=20=E5=B0=86=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=BB=B7=E7=BA=B3=E5=85=A5=20Ent=20=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ent/group.go | 15 ++- backend/ent/group/group.go | 3 + backend/ent/group/where.go | 10 ++ backend/ent/group_create.go | 70 +++++++++++ backend/ent/group_update.go | 36 ++++++ backend/ent/migrate/schema.go | 3 +- backend/ent/mutation.go | 75 +++++++++++- backend/ent/runtime/runtime.go | 36 +++--- backend/ent/schema/group.go | 4 + backend/internal/repository/api_key_repo.go | 1 + ...pi_key_repo_messages_dispatch_unit_test.go | 4 + backend/internal/repository/group_repo.go | 14 +-- .../repository/group_video_model_prices.go | 114 ------------------ .../internal/service/admin_group_duplicate.go | 16 +++ .../service/admin_group_duplicate_test.go | 68 ++++++----- 15 files changed, 292 insertions(+), 177 deletions(-) delete mode 100644 backend/internal/repository/group_video_model_prices.go diff --git a/backend/ent/group.go b/backend/ent/group.go index 81a3e349c0..315541636d 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -85,6 +85,8 @@ type Group struct { VideoPrice720p *float64 `json:"video_price_720p,omitempty"` // VideoPrice1080p holds the value of the "video_price_1080p" field. VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"` + // 按模型族和分辨率覆盖视频每秒价格 + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次) WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` // 是否仅允许 Claude Code 客户端 @@ -235,7 +237,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: + case group.FieldVideoModelPrices, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) @@ -478,6 +480,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.VideoPrice1080p = new(float64) *_m.VideoPrice1080p = value.Float64 } + case group.FieldVideoModelPrices: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field video_model_prices", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.VideoModelPrices); err != nil { + return fmt.Errorf("unmarshal field video_model_prices: %w", err) + } + } case group.FieldWebSearchPricePerCall: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i]) @@ -822,6 +832,9 @@ func (_m *Group) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("video_model_prices=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoModelPrices)) + builder.WriteString(", ") if v := _m.WebSearchPricePerCall; v != nil { builder.WriteString("web_search_price_per_call=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 35d6de1336..44aa596a75 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -82,6 +82,8 @@ const ( FieldVideoPrice720p = "video_price_720p" // FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database. FieldVideoPrice1080p = "video_price_1080p" + // FieldVideoModelPrices holds the string denoting the video_model_prices field in the database. + FieldVideoModelPrices = "video_model_prices" // FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database. FieldWebSearchPricePerCall = "web_search_price_per_call" // FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database. @@ -234,6 +236,7 @@ var Columns = []string{ FieldVideoPrice480p, FieldVideoPrice720p, FieldVideoPrice1080p, + FieldVideoModelPrices, FieldWebSearchPricePerCall, FieldClaudeCodeOnly, FieldFallbackGroupID, diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 64e52ba80a..91df1b27ba 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -1765,6 +1765,16 @@ func VideoPrice1080pNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p)) } +// VideoModelPricesIsNil applies the IsNil predicate on the "video_model_prices" field. +func VideoModelPricesIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldVideoModelPrices)) +} + +// VideoModelPricesNotNil applies the NotNil predicate on the "video_model_prices" field. +func VideoModelPricesNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldVideoModelPrices)) +} + // WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field. func WebSearchPricePerCallEQ(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 54e34a32f6..6478c14a68 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -483,6 +483,12 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate { return _c } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_c *GroupCreate) SetVideoModelPrices(v map[string]map[string]float64) *GroupCreate { + _c.mutation.SetVideoModelPrices(v) + return _c +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate { _c.mutation.SetWebSearchPricePerCall(v) @@ -1378,6 +1384,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) _node.VideoPrice1080p = &value } + if value, ok := _c.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + _node.VideoModelPrices = value + } if value, ok := _c.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) _node.WebSearchPricePerCall = &value @@ -2156,6 +2166,24 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert { return u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsert) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsert { + u.Set(group.FieldVideoModelPrices, v) + return u +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoModelPrices() *GroupUpsert { + u.SetExcluded(group.FieldVideoModelPrices) + return u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsert) ClearVideoModelPrices() *GroupUpsert { + u.SetNull(group.FieldVideoModelPrices) + return u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert { u.Set(group.FieldWebSearchPricePerCall, v) @@ -3157,6 +3185,27 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne { }) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsertOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoModelPrices(v) + }) +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoModelPrices() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoModelPrices() + }) +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsertOne) ClearVideoModelPrices() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoModelPrices() + }) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4379,6 +4428,27 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk { }) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsertBulk) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoModelPrices(v) + }) +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoModelPrices() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoModelPrices() + }) +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsertBulk) ClearVideoModelPrices() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoModelPrices() + }) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index c2fdea3f51..aa308102ff 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -640,6 +640,18 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate { return _u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_u *GroupUpdate) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdate { + _u.mutation.SetVideoModelPrices(v) + return _u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (_u *GroupUpdate) ClearVideoModelPrices() *GroupUpdate { + _u.mutation.ClearVideoModelPrices() + return _u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate { _u.mutation.ResetWebSearchPricePerCall() @@ -1506,6 +1518,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + } + if _u.mutation.VideoModelPricesCleared() { + _spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON) + } if value, ok := _u.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) } @@ -2533,6 +2551,18 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne { return _u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_u *GroupUpdateOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdateOne { + _u.mutation.SetVideoModelPrices(v) + return _u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (_u *GroupUpdateOne) ClearVideoModelPrices() *GroupUpdateOne { + _u.mutation.ClearVideoModelPrices() + return _u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne { _u.mutation.ResetWebSearchPricePerCall() @@ -3429,6 +3459,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + } + if _u.mutation.VideoModelPricesCleared() { + _spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON) + } if value, ok := _u.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 0475bca3bf..74b37383ca 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -928,6 +928,7 @@ var ( {Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, @@ -985,7 +986,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[42]}, + Columns: []*schema.Column{GroupsColumns[43]}, }, { Name: "idx_groups_duplicate_operation_id_active", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 61ff4f9e6d..3dc565846b 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -21896,6 +21896,7 @@ type GroupMutation struct { addvideo_price_720p *float64 video_price_1080p *float64 addvideo_price_1080p *float64 + video_model_prices *map[string]map[string]float64 web_search_price_per_call *float64 addweb_search_price_per_call *float64 claude_code_only *bool @@ -23722,6 +23723,55 @@ func (m *GroupMutation) ResetVideoPrice1080p() { delete(m.clearedFields, group.FieldVideoPrice1080p) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (m *GroupMutation) SetVideoModelPrices(value map[string]map[string]float64) { + m.video_model_prices = &value +} + +// VideoModelPrices returns the value of the "video_model_prices" field in the mutation. +func (m *GroupMutation) VideoModelPrices() (r map[string]map[string]float64, exists bool) { + v := m.video_model_prices + if v == nil { + return + } + return *v, true +} + +// OldVideoModelPrices returns the old "video_model_prices" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldVideoModelPrices(ctx context.Context) (v map[string]map[string]float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoModelPrices is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoModelPrices requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoModelPrices: %w", err) + } + return oldValue.VideoModelPrices, nil +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (m *GroupMutation) ClearVideoModelPrices() { + m.video_model_prices = nil + m.clearedFields[group.FieldVideoModelPrices] = struct{}{} +} + +// VideoModelPricesCleared returns if the "video_model_prices" field was cleared in this mutation. +func (m *GroupMutation) VideoModelPricesCleared() bool { + _, ok := m.clearedFields[group.FieldVideoModelPrices] + return ok +} + +// ResetVideoModelPrices resets all changes to the "video_model_prices" field. +func (m *GroupMutation) ResetVideoModelPrices() { + m.video_model_prices = nil + delete(m.clearedFields, group.FieldVideoModelPrices) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (m *GroupMutation) SetWebSearchPricePerCall(f float64) { m.web_search_price_per_call = &f @@ -25097,7 +25147,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 55) + fields := make([]string, 0, 56) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25197,6 +25247,9 @@ func (m *GroupMutation) Fields() []string { if m.video_price_1080p != nil { fields = append(fields, group.FieldVideoPrice1080p) } + if m.video_model_prices != nil { + fields = append(fields, group.FieldVideoModelPrices) + } if m.web_search_price_per_call != nil { fields = append(fields, group.FieldWebSearchPricePerCall) } @@ -25337,6 +25390,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.VideoPrice720p() case group.FieldVideoPrice1080p: return m.VideoPrice1080p() + case group.FieldVideoModelPrices: + return m.VideoModelPrices() case group.FieldWebSearchPricePerCall: return m.WebSearchPricePerCall() case group.FieldClaudeCodeOnly: @@ -25456,6 +25511,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldVideoPrice720p(ctx) case group.FieldVideoPrice1080p: return m.OldVideoPrice1080p(ctx) + case group.FieldVideoModelPrices: + return m.OldVideoModelPrices(ctx) case group.FieldWebSearchPricePerCall: return m.OldWebSearchPricePerCall(ctx) case group.FieldClaudeCodeOnly: @@ -25740,6 +25797,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetVideoPrice1080p(v) return nil + case group.FieldVideoModelPrices: + v, ok := value.(map[string]map[string]float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoModelPrices(v) + return nil case group.FieldWebSearchPricePerCall: v, ok := value.(float64) if !ok { @@ -26239,6 +26303,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldVideoPrice1080p) { fields = append(fields, group.FieldVideoPrice1080p) } + if m.FieldCleared(group.FieldVideoModelPrices) { + fields = append(fields, group.FieldVideoModelPrices) + } if m.FieldCleared(group.FieldWebSearchPricePerCall) { fields = append(fields, group.FieldWebSearchPricePerCall) } @@ -26301,6 +26368,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldVideoPrice1080p: m.ClearVideoPrice1080p() return nil + case group.FieldVideoModelPrices: + m.ClearVideoModelPrices() + return nil case group.FieldWebSearchPricePerCall: m.ClearWebSearchPricePerCall() return nil @@ -26420,6 +26490,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldVideoPrice1080p: m.ResetVideoPrice1080p() return nil + case group.FieldVideoModelPrices: + m.ResetVideoModelPrices() + return nil case group.FieldWebSearchPricePerCall: m.ResetWebSearchPricePerCall() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e85660dcc1..f57032757d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1118,79 +1118,79 @@ func init() { // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[31].Descriptor() + groupDescClaudeCodeOnly := groupFields[32].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[35].Descriptor() + groupDescModelRoutingEnabled := groupFields[36].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[36].Descriptor() + groupDescMcpXMLInject := groupFields[37].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[37].Descriptor() + groupDescSupportedModelScopes := groupFields[38].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[38].Descriptor() + groupDescSortOrder := groupFields[39].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[39].Descriptor() + groupDescAllowMessagesDispatch := groupFields[40].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[40].Descriptor() + groupDescAllowLive := groupFields[41].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[41].Descriptor() + groupDescRequireOauthOnly := groupFields[42].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[42].Descriptor() + groupDescRequirePrivacySet := groupFields[43].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[43].Descriptor() + groupDescDefaultMappedModel := groupFields[44].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[44].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[45].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[45].Descriptor() + groupDescModelsListConfig := groupFields[46].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[46].Descriptor() + groupDescRpmLimit := groupFields[47].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[47].Descriptor() + groupDescMaxReasoningEffort := groupFields[48].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[48].Descriptor() + groupDescReasoningEffortMappings := groupFields[49].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[49].Descriptor() + groupDescProfitControlEnabled := groupFields[50].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[50].Descriptor() + groupDescProfitMinMargin := groupFields[51].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[51].Descriptor() + groupDescProfitSafetyBuffer := groupFields[52].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 8a9fc12ded..1055a52eb0 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -148,6 +148,10 @@ func (Group) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.JSON("video_model_prices", map[string]map[string]float64{}). + Optional(). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("按模型族和分辨率覆盖视频每秒价格"), field.Float("web_search_price_per_call"). Optional(). Nillable(). diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index c2773996f4..06ef485b70 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -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, diff --git a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go index 4a462ab154..0e079d30d4 100644 --- a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go +++ b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go @@ -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) { diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index be008f4912..7dff754fab 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -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) } diff --git a/backend/internal/repository/group_video_model_prices.go b/backend/internal/repository/group_video_model_prices.go deleted file mode 100644 index af6b05ff86..0000000000 --- a/backend/internal/repository/group_video_model_prices.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 841af5e5eb..d1fcdd1475 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -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), diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 44db1afdc7..f4a7bef2c5 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -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])