diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fae8048f..73ca35d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,19 +57,24 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile working-directory: frontend - name: Build frontend - run: npm run build + run: pnpm run build working-directory: frontend - name: Upload frontend artifact diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 9f23c993..768254f9 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -105,7 +105,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.NewConcurrencyService(concurrencyCache) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) - accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) + accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) diff --git a/backend/ent/group.go b/backend/ent/group.go index e8687224..dca64cec 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -45,6 +45,12 @@ type Group struct { MonthlyLimitUsd *float64 `json:"monthly_limit_usd,omitempty"` // DefaultValidityDays holds the value of the "default_validity_days" field. DefaultValidityDays int `json:"default_validity_days,omitempty"` + // ImagePrice1k holds the value of the "image_price_1k" field. + ImagePrice1k *float64 `json:"image_price_1k,omitempty"` + // ImagePrice2k holds the value of the "image_price_2k" field. + ImagePrice2k *float64 `json:"image_price_2k,omitempty"` + // ImagePrice4k holds the value of the "image_price_4k" field. + ImagePrice4k *float64 `json:"image_price_4k,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -153,7 +159,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldIsExclusive: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd: + case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays: values[i] = new(sql.NullInt64) @@ -271,6 +277,27 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.DefaultValidityDays = int(value.Int64) } + case group.FieldImagePrice1k: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field image_price_1k", values[i]) + } else if value.Valid { + _m.ImagePrice1k = new(float64) + *_m.ImagePrice1k = value.Float64 + } + case group.FieldImagePrice2k: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field image_price_2k", values[i]) + } else if value.Valid { + _m.ImagePrice2k = new(float64) + *_m.ImagePrice2k = value.Float64 + } + case group.FieldImagePrice4k: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field image_price_4k", values[i]) + } else if value.Valid { + _m.ImagePrice4k = new(float64) + *_m.ImagePrice4k = value.Float64 + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -398,6 +425,21 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("default_validity_days=") builder.WriteString(fmt.Sprintf("%v", _m.DefaultValidityDays)) + builder.WriteString(", ") + if v := _m.ImagePrice1k; v != nil { + builder.WriteString("image_price_1k=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.ImagePrice2k; v != nil { + builder.WriteString("image_price_2k=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.ImagePrice4k; v != nil { + builder.WriteString("image_price_4k=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 1934b17b..1c5ed343 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -43,6 +43,12 @@ const ( FieldMonthlyLimitUsd = "monthly_limit_usd" // FieldDefaultValidityDays holds the string denoting the default_validity_days field in the database. FieldDefaultValidityDays = "default_validity_days" + // FieldImagePrice1k holds the string denoting the image_price_1k field in the database. + FieldImagePrice1k = "image_price_1k" + // FieldImagePrice2k holds the string denoting the image_price_2k field in the database. + FieldImagePrice2k = "image_price_2k" + // FieldImagePrice4k holds the string denoting the image_price_4k field in the database. + FieldImagePrice4k = "image_price_4k" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -132,6 +138,9 @@ var Columns = []string{ FieldWeeklyLimitUsd, FieldMonthlyLimitUsd, FieldDefaultValidityDays, + FieldImagePrice1k, + FieldImagePrice2k, + FieldImagePrice4k, } var ( @@ -267,6 +276,21 @@ func ByDefaultValidityDays(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldDefaultValidityDays, opts...).ToFunc() } +// ByImagePrice1k orders the results by the image_price_1k field. +func ByImagePrice1k(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImagePrice1k, opts...).ToFunc() +} + +// ByImagePrice2k orders the results by the image_price_2k field. +func ByImagePrice2k(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImagePrice2k, opts...).ToFunc() +} + +// ByImagePrice4k orders the results by the image_price_4k field. +func ByImagePrice4k(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImagePrice4k, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index cb553242..7bce1fe6 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -125,6 +125,21 @@ func DefaultValidityDays(v int) predicate.Group { return predicate.Group(sql.FieldEQ(FieldDefaultValidityDays, v)) } +// ImagePrice1k applies equality check predicate on the "image_price_1k" field. It's identical to ImagePrice1kEQ. +func ImagePrice1k(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v)) +} + +// ImagePrice2k applies equality check predicate on the "image_price_2k" field. It's identical to ImagePrice2kEQ. +func ImagePrice2k(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice2k, v)) +} + +// ImagePrice4k applies equality check predicate on the "image_price_4k" field. It's identical to ImagePrice4kEQ. +func ImagePrice4k(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice4k, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -830,6 +845,156 @@ func DefaultValidityDaysLTE(v int) predicate.Group { return predicate.Group(sql.FieldLTE(FieldDefaultValidityDays, v)) } +// ImagePrice1kEQ applies the EQ predicate on the "image_price_1k" field. +func ImagePrice1kEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v)) +} + +// ImagePrice1kNEQ applies the NEQ predicate on the "image_price_1k" field. +func ImagePrice1kNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldImagePrice1k, v)) +} + +// ImagePrice1kIn applies the In predicate on the "image_price_1k" field. +func ImagePrice1kIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldImagePrice1k, vs...)) +} + +// ImagePrice1kNotIn applies the NotIn predicate on the "image_price_1k" field. +func ImagePrice1kNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldImagePrice1k, vs...)) +} + +// ImagePrice1kGT applies the GT predicate on the "image_price_1k" field. +func ImagePrice1kGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldImagePrice1k, v)) +} + +// ImagePrice1kGTE applies the GTE predicate on the "image_price_1k" field. +func ImagePrice1kGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldImagePrice1k, v)) +} + +// ImagePrice1kLT applies the LT predicate on the "image_price_1k" field. +func ImagePrice1kLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldImagePrice1k, v)) +} + +// ImagePrice1kLTE applies the LTE predicate on the "image_price_1k" field. +func ImagePrice1kLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldImagePrice1k, v)) +} + +// ImagePrice1kIsNil applies the IsNil predicate on the "image_price_1k" field. +func ImagePrice1kIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldImagePrice1k)) +} + +// ImagePrice1kNotNil applies the NotNil predicate on the "image_price_1k" field. +func ImagePrice1kNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldImagePrice1k)) +} + +// ImagePrice2kEQ applies the EQ predicate on the "image_price_2k" field. +func ImagePrice2kEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice2k, v)) +} + +// ImagePrice2kNEQ applies the NEQ predicate on the "image_price_2k" field. +func ImagePrice2kNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldImagePrice2k, v)) +} + +// ImagePrice2kIn applies the In predicate on the "image_price_2k" field. +func ImagePrice2kIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldImagePrice2k, vs...)) +} + +// ImagePrice2kNotIn applies the NotIn predicate on the "image_price_2k" field. +func ImagePrice2kNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldImagePrice2k, vs...)) +} + +// ImagePrice2kGT applies the GT predicate on the "image_price_2k" field. +func ImagePrice2kGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldImagePrice2k, v)) +} + +// ImagePrice2kGTE applies the GTE predicate on the "image_price_2k" field. +func ImagePrice2kGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldImagePrice2k, v)) +} + +// ImagePrice2kLT applies the LT predicate on the "image_price_2k" field. +func ImagePrice2kLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldImagePrice2k, v)) +} + +// ImagePrice2kLTE applies the LTE predicate on the "image_price_2k" field. +func ImagePrice2kLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldImagePrice2k, v)) +} + +// ImagePrice2kIsNil applies the IsNil predicate on the "image_price_2k" field. +func ImagePrice2kIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldImagePrice2k)) +} + +// ImagePrice2kNotNil applies the NotNil predicate on the "image_price_2k" field. +func ImagePrice2kNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldImagePrice2k)) +} + +// ImagePrice4kEQ applies the EQ predicate on the "image_price_4k" field. +func ImagePrice4kEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImagePrice4k, v)) +} + +// ImagePrice4kNEQ applies the NEQ predicate on the "image_price_4k" field. +func ImagePrice4kNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldImagePrice4k, v)) +} + +// ImagePrice4kIn applies the In predicate on the "image_price_4k" field. +func ImagePrice4kIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldImagePrice4k, vs...)) +} + +// ImagePrice4kNotIn applies the NotIn predicate on the "image_price_4k" field. +func ImagePrice4kNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldImagePrice4k, vs...)) +} + +// ImagePrice4kGT applies the GT predicate on the "image_price_4k" field. +func ImagePrice4kGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldImagePrice4k, v)) +} + +// ImagePrice4kGTE applies the GTE predicate on the "image_price_4k" field. +func ImagePrice4kGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldImagePrice4k, v)) +} + +// ImagePrice4kLT applies the LT predicate on the "image_price_4k" field. +func ImagePrice4kLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldImagePrice4k, v)) +} + +// ImagePrice4kLTE applies the LTE predicate on the "image_price_4k" field. +func ImagePrice4kLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldImagePrice4k, v)) +} + +// ImagePrice4kIsNil applies the IsNil predicate on the "image_price_4k" field. +func ImagePrice4kIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldImagePrice4k)) +} + +// ImagePrice4kNotNil applies the NotNil predicate on the "image_price_4k" field. +func ImagePrice4kNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldImagePrice4k)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 0613c78e..6a928af6 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -216,6 +216,48 @@ func (_c *GroupCreate) SetNillableDefaultValidityDays(v *int) *GroupCreate { return _c } +// SetImagePrice1k sets the "image_price_1k" field. +func (_c *GroupCreate) SetImagePrice1k(v float64) *GroupCreate { + _c.mutation.SetImagePrice1k(v) + return _c +} + +// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil. +func (_c *GroupCreate) SetNillableImagePrice1k(v *float64) *GroupCreate { + if v != nil { + _c.SetImagePrice1k(*v) + } + return _c +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (_c *GroupCreate) SetImagePrice2k(v float64) *GroupCreate { + _c.mutation.SetImagePrice2k(v) + return _c +} + +// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil. +func (_c *GroupCreate) SetNillableImagePrice2k(v *float64) *GroupCreate { + if v != nil { + _c.SetImagePrice2k(*v) + } + return _c +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (_c *GroupCreate) SetImagePrice4k(v float64) *GroupCreate { + _c.mutation.SetImagePrice4k(v) + return _c +} + +// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil. +func (_c *GroupCreate) SetNillableImagePrice4k(v *float64) *GroupCreate { + if v != nil { + _c.SetImagePrice4k(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -516,6 +558,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldDefaultValidityDays, field.TypeInt, value) _node.DefaultValidityDays = value } + if value, ok := _c.mutation.ImagePrice1k(); ok { + _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) + _node.ImagePrice1k = &value + } + if value, ok := _c.mutation.ImagePrice2k(); ok { + _spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value) + _node.ImagePrice2k = &value + } + if value, ok := _c.mutation.ImagePrice4k(); ok { + _spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value) + _node.ImagePrice4k = &value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -888,6 +942,78 @@ func (u *GroupUpsert) AddDefaultValidityDays(v int) *GroupUpsert { return u } +// SetImagePrice1k sets the "image_price_1k" field. +func (u *GroupUpsert) SetImagePrice1k(v float64) *GroupUpsert { + u.Set(group.FieldImagePrice1k, v) + return u +} + +// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create. +func (u *GroupUpsert) UpdateImagePrice1k() *GroupUpsert { + u.SetExcluded(group.FieldImagePrice1k) + return u +} + +// AddImagePrice1k adds v to the "image_price_1k" field. +func (u *GroupUpsert) AddImagePrice1k(v float64) *GroupUpsert { + u.Add(group.FieldImagePrice1k, v) + return u +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (u *GroupUpsert) ClearImagePrice1k() *GroupUpsert { + u.SetNull(group.FieldImagePrice1k) + return u +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (u *GroupUpsert) SetImagePrice2k(v float64) *GroupUpsert { + u.Set(group.FieldImagePrice2k, v) + return u +} + +// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create. +func (u *GroupUpsert) UpdateImagePrice2k() *GroupUpsert { + u.SetExcluded(group.FieldImagePrice2k) + return u +} + +// AddImagePrice2k adds v to the "image_price_2k" field. +func (u *GroupUpsert) AddImagePrice2k(v float64) *GroupUpsert { + u.Add(group.FieldImagePrice2k, v) + return u +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (u *GroupUpsert) ClearImagePrice2k() *GroupUpsert { + u.SetNull(group.FieldImagePrice2k) + return u +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (u *GroupUpsert) SetImagePrice4k(v float64) *GroupUpsert { + u.Set(group.FieldImagePrice4k, v) + return u +} + +// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create. +func (u *GroupUpsert) UpdateImagePrice4k() *GroupUpsert { + u.SetExcluded(group.FieldImagePrice4k) + return u +} + +// AddImagePrice4k adds v to the "image_price_4k" field. +func (u *GroupUpsert) AddImagePrice4k(v float64) *GroupUpsert { + u.Add(group.FieldImagePrice4k, v) + return u +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (u *GroupUpsert) ClearImagePrice4k() *GroupUpsert { + u.SetNull(group.FieldImagePrice4k) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -1185,6 +1311,90 @@ func (u *GroupUpsertOne) UpdateDefaultValidityDays() *GroupUpsertOne { }) } +// SetImagePrice1k sets the "image_price_1k" field. +func (u *GroupUpsertOne) SetImagePrice1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice1k(v) + }) +} + +// AddImagePrice1k adds v to the "image_price_1k" field. +func (u *GroupUpsertOne) AddImagePrice1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice1k(v) + }) +} + +// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateImagePrice1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice1k() + }) +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (u *GroupUpsertOne) ClearImagePrice1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice1k() + }) +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (u *GroupUpsertOne) SetImagePrice2k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice2k(v) + }) +} + +// AddImagePrice2k adds v to the "image_price_2k" field. +func (u *GroupUpsertOne) AddImagePrice2k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice2k(v) + }) +} + +// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateImagePrice2k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice2k() + }) +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (u *GroupUpsertOne) ClearImagePrice2k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice2k() + }) +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (u *GroupUpsertOne) SetImagePrice4k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice4k(v) + }) +} + +// AddImagePrice4k adds v to the "image_price_4k" field. +func (u *GroupUpsertOne) AddImagePrice4k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice4k(v) + }) +} + +// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateImagePrice4k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice4k() + }) +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (u *GroupUpsertOne) ClearImagePrice4k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice4k() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1648,6 +1858,90 @@ func (u *GroupUpsertBulk) UpdateDefaultValidityDays() *GroupUpsertBulk { }) } +// SetImagePrice1k sets the "image_price_1k" field. +func (u *GroupUpsertBulk) SetImagePrice1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice1k(v) + }) +} + +// AddImagePrice1k adds v to the "image_price_1k" field. +func (u *GroupUpsertBulk) AddImagePrice1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice1k(v) + }) +} + +// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateImagePrice1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice1k() + }) +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (u *GroupUpsertBulk) ClearImagePrice1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice1k() + }) +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (u *GroupUpsertBulk) SetImagePrice2k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice2k(v) + }) +} + +// AddImagePrice2k adds v to the "image_price_2k" field. +func (u *GroupUpsertBulk) AddImagePrice2k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice2k(v) + }) +} + +// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateImagePrice2k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice2k() + }) +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (u *GroupUpsertBulk) ClearImagePrice2k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice2k() + }) +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (u *GroupUpsertBulk) SetImagePrice4k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetImagePrice4k(v) + }) +} + +// AddImagePrice4k adds v to the "image_price_4k" field. +func (u *GroupUpsertBulk) AddImagePrice4k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddImagePrice4k(v) + }) +} + +// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateImagePrice4k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateImagePrice4k() + }) +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (u *GroupUpsertBulk) ClearImagePrice4k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearImagePrice4k() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 43dcf319..43555ce2 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -273,6 +273,87 @@ func (_u *GroupUpdate) AddDefaultValidityDays(v int) *GroupUpdate { return _u } +// SetImagePrice1k sets the "image_price_1k" field. +func (_u *GroupUpdate) SetImagePrice1k(v float64) *GroupUpdate { + _u.mutation.ResetImagePrice1k() + _u.mutation.SetImagePrice1k(v) + return _u +} + +// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableImagePrice1k(v *float64) *GroupUpdate { + if v != nil { + _u.SetImagePrice1k(*v) + } + return _u +} + +// AddImagePrice1k adds value to the "image_price_1k" field. +func (_u *GroupUpdate) AddImagePrice1k(v float64) *GroupUpdate { + _u.mutation.AddImagePrice1k(v) + return _u +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (_u *GroupUpdate) ClearImagePrice1k() *GroupUpdate { + _u.mutation.ClearImagePrice1k() + return _u +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (_u *GroupUpdate) SetImagePrice2k(v float64) *GroupUpdate { + _u.mutation.ResetImagePrice2k() + _u.mutation.SetImagePrice2k(v) + return _u +} + +// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableImagePrice2k(v *float64) *GroupUpdate { + if v != nil { + _u.SetImagePrice2k(*v) + } + return _u +} + +// AddImagePrice2k adds value to the "image_price_2k" field. +func (_u *GroupUpdate) AddImagePrice2k(v float64) *GroupUpdate { + _u.mutation.AddImagePrice2k(v) + return _u +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (_u *GroupUpdate) ClearImagePrice2k() *GroupUpdate { + _u.mutation.ClearImagePrice2k() + return _u +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (_u *GroupUpdate) SetImagePrice4k(v float64) *GroupUpdate { + _u.mutation.ResetImagePrice4k() + _u.mutation.SetImagePrice4k(v) + return _u +} + +// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableImagePrice4k(v *float64) *GroupUpdate { + if v != nil { + _u.SetImagePrice4k(*v) + } + return _u +} + +// AddImagePrice4k adds value to the "image_price_4k" field. +func (_u *GroupUpdate) AddImagePrice4k(v float64) *GroupUpdate { + _u.mutation.AddImagePrice4k(v) + return _u +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (_u *GroupUpdate) ClearImagePrice4k() *GroupUpdate { + _u.mutation.ClearImagePrice4k() + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -642,6 +723,33 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedDefaultValidityDays(); ok { _spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value) } + if value, ok := _u.mutation.ImagePrice1k(); ok { + _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice1k(); ok { + _spec.AddField(group.FieldImagePrice1k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice1kCleared() { + _spec.ClearField(group.FieldImagePrice1k, field.TypeFloat64) + } + if value, ok := _u.mutation.ImagePrice2k(); ok { + _spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice2k(); ok { + _spec.AddField(group.FieldImagePrice2k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice2kCleared() { + _spec.ClearField(group.FieldImagePrice2k, field.TypeFloat64) + } + if value, ok := _u.mutation.ImagePrice4k(); ok { + _spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice4k(); ok { + _spec.AddField(group.FieldImagePrice4k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice4kCleared() { + _spec.ClearField(group.FieldImagePrice4k, field.TypeFloat64) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1195,6 +1303,87 @@ func (_u *GroupUpdateOne) AddDefaultValidityDays(v int) *GroupUpdateOne { return _u } +// SetImagePrice1k sets the "image_price_1k" field. +func (_u *GroupUpdateOne) SetImagePrice1k(v float64) *GroupUpdateOne { + _u.mutation.ResetImagePrice1k() + _u.mutation.SetImagePrice1k(v) + return _u +} + +// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableImagePrice1k(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetImagePrice1k(*v) + } + return _u +} + +// AddImagePrice1k adds value to the "image_price_1k" field. +func (_u *GroupUpdateOne) AddImagePrice1k(v float64) *GroupUpdateOne { + _u.mutation.AddImagePrice1k(v) + return _u +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (_u *GroupUpdateOne) ClearImagePrice1k() *GroupUpdateOne { + _u.mutation.ClearImagePrice1k() + return _u +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (_u *GroupUpdateOne) SetImagePrice2k(v float64) *GroupUpdateOne { + _u.mutation.ResetImagePrice2k() + _u.mutation.SetImagePrice2k(v) + return _u +} + +// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableImagePrice2k(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetImagePrice2k(*v) + } + return _u +} + +// AddImagePrice2k adds value to the "image_price_2k" field. +func (_u *GroupUpdateOne) AddImagePrice2k(v float64) *GroupUpdateOne { + _u.mutation.AddImagePrice2k(v) + return _u +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (_u *GroupUpdateOne) ClearImagePrice2k() *GroupUpdateOne { + _u.mutation.ClearImagePrice2k() + return _u +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (_u *GroupUpdateOne) SetImagePrice4k(v float64) *GroupUpdateOne { + _u.mutation.ResetImagePrice4k() + _u.mutation.SetImagePrice4k(v) + return _u +} + +// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableImagePrice4k(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetImagePrice4k(*v) + } + return _u +} + +// AddImagePrice4k adds value to the "image_price_4k" field. +func (_u *GroupUpdateOne) AddImagePrice4k(v float64) *GroupUpdateOne { + _u.mutation.AddImagePrice4k(v) + return _u +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (_u *GroupUpdateOne) ClearImagePrice4k() *GroupUpdateOne { + _u.mutation.ClearImagePrice4k() + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -1594,6 +1783,33 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedDefaultValidityDays(); ok { _spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value) } + if value, ok := _u.mutation.ImagePrice1k(); ok { + _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice1k(); ok { + _spec.AddField(group.FieldImagePrice1k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice1kCleared() { + _spec.ClearField(group.FieldImagePrice1k, field.TypeFloat64) + } + if value, ok := _u.mutation.ImagePrice2k(); ok { + _spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice2k(); ok { + _spec.AddField(group.FieldImagePrice2k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice2kCleared() { + _spec.ClearField(group.FieldImagePrice2k, field.TypeFloat64) + } + if value, ok := _u.mutation.ImagePrice4k(); ok { + _spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImagePrice4k(); ok { + _spec.AddField(group.FieldImagePrice4k, field.TypeFloat64, value) + } + if _u.mutation.ImagePrice4kCleared() { + _spec.ClearField(group.FieldImagePrice4k, field.TypeFloat64) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 9a909545..d0e43bf3 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -216,6 +216,9 @@ var ( {Name: "weekly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "monthly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "default_validity_days", Type: field.TypeInt, Default: 30}, + {Name: "image_price_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "image_price_2k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ @@ -368,6 +371,8 @@ var ( {Name: "stream", Type: field.TypeBool, Default: false}, {Name: "duration_ms", Type: field.TypeInt, Nullable: true}, {Name: "first_token_ms", Type: field.TypeInt, Nullable: true}, + {Name: "image_count", Type: field.TypeInt, Default: 0}, + {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "api_key_id", Type: field.TypeInt64}, {Name: "account_id", Type: field.TypeInt64}, @@ -383,31 +388,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[21]}, + Columns: []*schema.Column{UsageLogsColumns[23]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[24]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -416,32 +421,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[21]}, + Columns: []*schema.Column{UsageLogsColumns[23]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[24]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[20]}, + Columns: []*schema.Column{UsageLogsColumns[22]}, }, { Name: "usagelog_model", @@ -456,12 +461,12 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[20]}, + Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[21], UsageLogsColumns[20]}, + Columns: []*schema.Column{UsageLogsColumns[23], UsageLogsColumns[22]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 7d9cf66b..91883413 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -3457,6 +3457,12 @@ type GroupMutation struct { addmonthly_limit_usd *float64 default_validity_days *int adddefault_validity_days *int + image_price_1k *float64 + addimage_price_1k *float64 + image_price_2k *float64 + addimage_price_2k *float64 + image_price_4k *float64 + addimage_price_4k *float64 clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -4251,6 +4257,216 @@ func (m *GroupMutation) ResetDefaultValidityDays() { m.adddefault_validity_days = nil } +// SetImagePrice1k sets the "image_price_1k" field. +func (m *GroupMutation) SetImagePrice1k(f float64) { + m.image_price_1k = &f + m.addimage_price_1k = nil +} + +// ImagePrice1k returns the value of the "image_price_1k" field in the mutation. +func (m *GroupMutation) ImagePrice1k() (r float64, exists bool) { + v := m.image_price_1k + if v == nil { + return + } + return *v, true +} + +// OldImagePrice1k returns the old "image_price_1k" 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) OldImagePrice1k(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImagePrice1k is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImagePrice1k requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImagePrice1k: %w", err) + } + return oldValue.ImagePrice1k, nil +} + +// AddImagePrice1k adds f to the "image_price_1k" field. +func (m *GroupMutation) AddImagePrice1k(f float64) { + if m.addimage_price_1k != nil { + *m.addimage_price_1k += f + } else { + m.addimage_price_1k = &f + } +} + +// AddedImagePrice1k returns the value that was added to the "image_price_1k" field in this mutation. +func (m *GroupMutation) AddedImagePrice1k() (r float64, exists bool) { + v := m.addimage_price_1k + if v == nil { + return + } + return *v, true +} + +// ClearImagePrice1k clears the value of the "image_price_1k" field. +func (m *GroupMutation) ClearImagePrice1k() { + m.image_price_1k = nil + m.addimage_price_1k = nil + m.clearedFields[group.FieldImagePrice1k] = struct{}{} +} + +// ImagePrice1kCleared returns if the "image_price_1k" field was cleared in this mutation. +func (m *GroupMutation) ImagePrice1kCleared() bool { + _, ok := m.clearedFields[group.FieldImagePrice1k] + return ok +} + +// ResetImagePrice1k resets all changes to the "image_price_1k" field. +func (m *GroupMutation) ResetImagePrice1k() { + m.image_price_1k = nil + m.addimage_price_1k = nil + delete(m.clearedFields, group.FieldImagePrice1k) +} + +// SetImagePrice2k sets the "image_price_2k" field. +func (m *GroupMutation) SetImagePrice2k(f float64) { + m.image_price_2k = &f + m.addimage_price_2k = nil +} + +// ImagePrice2k returns the value of the "image_price_2k" field in the mutation. +func (m *GroupMutation) ImagePrice2k() (r float64, exists bool) { + v := m.image_price_2k + if v == nil { + return + } + return *v, true +} + +// OldImagePrice2k returns the old "image_price_2k" 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) OldImagePrice2k(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImagePrice2k is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImagePrice2k requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImagePrice2k: %w", err) + } + return oldValue.ImagePrice2k, nil +} + +// AddImagePrice2k adds f to the "image_price_2k" field. +func (m *GroupMutation) AddImagePrice2k(f float64) { + if m.addimage_price_2k != nil { + *m.addimage_price_2k += f + } else { + m.addimage_price_2k = &f + } +} + +// AddedImagePrice2k returns the value that was added to the "image_price_2k" field in this mutation. +func (m *GroupMutation) AddedImagePrice2k() (r float64, exists bool) { + v := m.addimage_price_2k + if v == nil { + return + } + return *v, true +} + +// ClearImagePrice2k clears the value of the "image_price_2k" field. +func (m *GroupMutation) ClearImagePrice2k() { + m.image_price_2k = nil + m.addimage_price_2k = nil + m.clearedFields[group.FieldImagePrice2k] = struct{}{} +} + +// ImagePrice2kCleared returns if the "image_price_2k" field was cleared in this mutation. +func (m *GroupMutation) ImagePrice2kCleared() bool { + _, ok := m.clearedFields[group.FieldImagePrice2k] + return ok +} + +// ResetImagePrice2k resets all changes to the "image_price_2k" field. +func (m *GroupMutation) ResetImagePrice2k() { + m.image_price_2k = nil + m.addimage_price_2k = nil + delete(m.clearedFields, group.FieldImagePrice2k) +} + +// SetImagePrice4k sets the "image_price_4k" field. +func (m *GroupMutation) SetImagePrice4k(f float64) { + m.image_price_4k = &f + m.addimage_price_4k = nil +} + +// ImagePrice4k returns the value of the "image_price_4k" field in the mutation. +func (m *GroupMutation) ImagePrice4k() (r float64, exists bool) { + v := m.image_price_4k + if v == nil { + return + } + return *v, true +} + +// OldImagePrice4k returns the old "image_price_4k" 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) OldImagePrice4k(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImagePrice4k is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImagePrice4k requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImagePrice4k: %w", err) + } + return oldValue.ImagePrice4k, nil +} + +// AddImagePrice4k adds f to the "image_price_4k" field. +func (m *GroupMutation) AddImagePrice4k(f float64) { + if m.addimage_price_4k != nil { + *m.addimage_price_4k += f + } else { + m.addimage_price_4k = &f + } +} + +// AddedImagePrice4k returns the value that was added to the "image_price_4k" field in this mutation. +func (m *GroupMutation) AddedImagePrice4k() (r float64, exists bool) { + v := m.addimage_price_4k + if v == nil { + return + } + return *v, true +} + +// ClearImagePrice4k clears the value of the "image_price_4k" field. +func (m *GroupMutation) ClearImagePrice4k() { + m.image_price_4k = nil + m.addimage_price_4k = nil + m.clearedFields[group.FieldImagePrice4k] = struct{}{} +} + +// ImagePrice4kCleared returns if the "image_price_4k" field was cleared in this mutation. +func (m *GroupMutation) ImagePrice4kCleared() bool { + _, ok := m.clearedFields[group.FieldImagePrice4k] + return ok +} + +// ResetImagePrice4k resets all changes to the "image_price_4k" field. +func (m *GroupMutation) ResetImagePrice4k() { + m.image_price_4k = nil + m.addimage_price_4k = nil + delete(m.clearedFields, group.FieldImagePrice4k) +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -4609,7 +4825,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, 14) + fields := make([]string, 0, 17) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -4652,6 +4868,15 @@ func (m *GroupMutation) Fields() []string { if m.default_validity_days != nil { fields = append(fields, group.FieldDefaultValidityDays) } + if m.image_price_1k != nil { + fields = append(fields, group.FieldImagePrice1k) + } + if m.image_price_2k != nil { + fields = append(fields, group.FieldImagePrice2k) + } + if m.image_price_4k != nil { + fields = append(fields, group.FieldImagePrice4k) + } return fields } @@ -4688,6 +4913,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.MonthlyLimitUsd() case group.FieldDefaultValidityDays: return m.DefaultValidityDays() + case group.FieldImagePrice1k: + return m.ImagePrice1k() + case group.FieldImagePrice2k: + return m.ImagePrice2k() + case group.FieldImagePrice4k: + return m.ImagePrice4k() } return nil, false } @@ -4725,6 +4956,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldMonthlyLimitUsd(ctx) case group.FieldDefaultValidityDays: return m.OldDefaultValidityDays(ctx) + case group.FieldImagePrice1k: + return m.OldImagePrice1k(ctx) + case group.FieldImagePrice2k: + return m.OldImagePrice2k(ctx) + case group.FieldImagePrice4k: + return m.OldImagePrice4k(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -4832,6 +5069,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetDefaultValidityDays(v) return nil + case group.FieldImagePrice1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImagePrice1k(v) + return nil + case group.FieldImagePrice2k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImagePrice2k(v) + return nil + case group.FieldImagePrice4k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImagePrice4k(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -4855,6 +5113,15 @@ func (m *GroupMutation) AddedFields() []string { if m.adddefault_validity_days != nil { fields = append(fields, group.FieldDefaultValidityDays) } + if m.addimage_price_1k != nil { + fields = append(fields, group.FieldImagePrice1k) + } + if m.addimage_price_2k != nil { + fields = append(fields, group.FieldImagePrice2k) + } + if m.addimage_price_4k != nil { + fields = append(fields, group.FieldImagePrice4k) + } return fields } @@ -4873,6 +5140,12 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedMonthlyLimitUsd() case group.FieldDefaultValidityDays: return m.AddedDefaultValidityDays() + case group.FieldImagePrice1k: + return m.AddedImagePrice1k() + case group.FieldImagePrice2k: + return m.AddedImagePrice2k() + case group.FieldImagePrice4k: + return m.AddedImagePrice4k() } return nil, false } @@ -4917,6 +5190,27 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddDefaultValidityDays(v) return nil + case group.FieldImagePrice1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddImagePrice1k(v) + return nil + case group.FieldImagePrice2k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddImagePrice2k(v) + return nil + case group.FieldImagePrice4k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddImagePrice4k(v) + return nil } return fmt.Errorf("unknown Group numeric field %s", name) } @@ -4940,6 +5234,15 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldMonthlyLimitUsd) { fields = append(fields, group.FieldMonthlyLimitUsd) } + if m.FieldCleared(group.FieldImagePrice1k) { + fields = append(fields, group.FieldImagePrice1k) + } + if m.FieldCleared(group.FieldImagePrice2k) { + fields = append(fields, group.FieldImagePrice2k) + } + if m.FieldCleared(group.FieldImagePrice4k) { + fields = append(fields, group.FieldImagePrice4k) + } return fields } @@ -4969,6 +5272,15 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldMonthlyLimitUsd: m.ClearMonthlyLimitUsd() return nil + case group.FieldImagePrice1k: + m.ClearImagePrice1k() + return nil + case group.FieldImagePrice2k: + m.ClearImagePrice2k() + return nil + case group.FieldImagePrice4k: + m.ClearImagePrice4k() + return nil } return fmt.Errorf("unknown Group nullable field %s", name) } @@ -5019,6 +5331,15 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldDefaultValidityDays: m.ResetDefaultValidityDays() return nil + case group.FieldImagePrice1k: + m.ResetImagePrice1k() + return nil + case group.FieldImagePrice2k: + m.ResetImagePrice2k() + return nil + case group.FieldImagePrice4k: + m.ResetImagePrice4k() + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -7786,6 +8107,9 @@ type UsageLogMutation struct { addduration_ms *int first_token_ms *int addfirst_token_ms *int + image_count *int + addimage_count *int + image_size *string created_at *time.Time clearedFields map[string]struct{} user *int64 @@ -9139,6 +9463,111 @@ func (m *UsageLogMutation) ResetFirstTokenMs() { delete(m.clearedFields, usagelog.FieldFirstTokenMs) } +// SetImageCount sets the "image_count" field. +func (m *UsageLogMutation) SetImageCount(i int) { + m.image_count = &i + m.addimage_count = nil +} + +// ImageCount returns the value of the "image_count" field in the mutation. +func (m *UsageLogMutation) ImageCount() (r int, exists bool) { + v := m.image_count + if v == nil { + return + } + return *v, true +} + +// OldImageCount returns the old "image_count" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldImageCount(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageCount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageCount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageCount: %w", err) + } + return oldValue.ImageCount, nil +} + +// AddImageCount adds i to the "image_count" field. +func (m *UsageLogMutation) AddImageCount(i int) { + if m.addimage_count != nil { + *m.addimage_count += i + } else { + m.addimage_count = &i + } +} + +// AddedImageCount returns the value that was added to the "image_count" field in this mutation. +func (m *UsageLogMutation) AddedImageCount() (r int, exists bool) { + v := m.addimage_count + if v == nil { + return + } + return *v, true +} + +// ResetImageCount resets all changes to the "image_count" field. +func (m *UsageLogMutation) ResetImageCount() { + m.image_count = nil + m.addimage_count = nil +} + +// SetImageSize sets the "image_size" field. +func (m *UsageLogMutation) SetImageSize(s string) { + m.image_size = &s +} + +// ImageSize returns the value of the "image_size" field in the mutation. +func (m *UsageLogMutation) ImageSize() (r string, exists bool) { + v := m.image_size + if v == nil { + return + } + return *v, true +} + +// OldImageSize returns the old "image_size" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldImageSize(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageSize is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageSize requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageSize: %w", err) + } + return oldValue.ImageSize, nil +} + +// ClearImageSize clears the value of the "image_size" field. +func (m *UsageLogMutation) ClearImageSize() { + m.image_size = nil + m.clearedFields[usagelog.FieldImageSize] = struct{}{} +} + +// ImageSizeCleared returns if the "image_size" field was cleared in this mutation. +func (m *UsageLogMutation) ImageSizeCleared() bool { + _, ok := m.clearedFields[usagelog.FieldImageSize] + return ok +} + +// ResetImageSize resets all changes to the "image_size" field. +func (m *UsageLogMutation) ResetImageSize() { + m.image_size = nil + delete(m.clearedFields, usagelog.FieldImageSize) +} + // SetCreatedAt sets the "created_at" field. func (m *UsageLogMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -9344,7 +9773,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 25) + fields := make([]string, 0, 27) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -9417,6 +9846,12 @@ func (m *UsageLogMutation) Fields() []string { if m.first_token_ms != nil { fields = append(fields, usagelog.FieldFirstTokenMs) } + if m.image_count != nil { + fields = append(fields, usagelog.FieldImageCount) + } + if m.image_size != nil { + fields = append(fields, usagelog.FieldImageSize) + } if m.created_at != nil { fields = append(fields, usagelog.FieldCreatedAt) } @@ -9476,6 +9911,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.DurationMs() case usagelog.FieldFirstTokenMs: return m.FirstTokenMs() + case usagelog.FieldImageCount: + return m.ImageCount() + case usagelog.FieldImageSize: + return m.ImageSize() case usagelog.FieldCreatedAt: return m.CreatedAt() } @@ -9535,6 +9974,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldDurationMs(ctx) case usagelog.FieldFirstTokenMs: return m.OldFirstTokenMs(ctx) + case usagelog.FieldImageCount: + return m.OldImageCount(ctx) + case usagelog.FieldImageSize: + return m.OldImageSize(ctx) case usagelog.FieldCreatedAt: return m.OldCreatedAt(ctx) } @@ -9714,6 +10157,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetFirstTokenMs(v) return nil + case usagelog.FieldImageCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageCount(v) + return nil + case usagelog.FieldImageSize: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageSize(v) + return nil case usagelog.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -9777,6 +10234,9 @@ func (m *UsageLogMutation) AddedFields() []string { if m.addfirst_token_ms != nil { fields = append(fields, usagelog.FieldFirstTokenMs) } + if m.addimage_count != nil { + fields = append(fields, usagelog.FieldImageCount) + } return fields } @@ -9817,6 +10277,8 @@ func (m *UsageLogMutation) AddedField(name string) (ent.Value, bool) { return m.AddedDurationMs() case usagelog.FieldFirstTokenMs: return m.AddedFirstTokenMs() + case usagelog.FieldImageCount: + return m.AddedImageCount() } return nil, false } @@ -9938,6 +10400,13 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error { } m.AddFirstTokenMs(v) return nil + case usagelog.FieldImageCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddImageCount(v) + return nil } return fmt.Errorf("unknown UsageLog numeric field %s", name) } @@ -9958,6 +10427,9 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldFirstTokenMs) { fields = append(fields, usagelog.FieldFirstTokenMs) } + if m.FieldCleared(usagelog.FieldImageSize) { + fields = append(fields, usagelog.FieldImageSize) + } return fields } @@ -9984,6 +10456,9 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldFirstTokenMs: m.ClearFirstTokenMs() return nil + case usagelog.FieldImageSize: + m.ClearImageSize() + return nil } return fmt.Errorf("unknown UsageLog nullable field %s", name) } @@ -10064,6 +10539,12 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldFirstTokenMs: m.ResetFirstTokenMs() return nil + case usagelog.FieldImageCount: + m.ResetImageCount() + return nil + case usagelog.FieldImageSize: + m.ResetImageSize() + return nil case usagelog.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index aa985c3d..e2cb6a3c 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -521,8 +521,16 @@ func init() { usagelogDescStream := usagelogFields[21].Descriptor() // usagelog.DefaultStream holds the default value on creation for the stream field. usagelog.DefaultStream = usagelogDescStream.Default.(bool) + // usagelogDescImageCount is the schema descriptor for image_count field. + usagelogDescImageCount := usagelogFields[24].Descriptor() + // usagelog.DefaultImageCount holds the default value on creation for the image_count field. + usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int) + // usagelogDescImageSize is the schema descriptor for image_size field. + usagelogDescImageSize := usagelogFields[25].Descriptor() + // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. + usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[24].Descriptor() + usagelogDescCreatedAt := usagelogFields[26].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 93dab1ab..7b5f77b1 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -72,6 +72,20 @@ func (Group) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), field.Int("default_validity_days"). Default(30), + + // 图片生成计费配置(antigravity 和 gemini 平台使用) + field.Float("image_price_1k"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.Float("image_price_2k"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.Float("image_price_4k"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), } } diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index 81effa46..af99904d 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -97,6 +97,14 @@ func (UsageLog) Fields() []ent.Field { Optional(). Nillable(), + // 图片生成字段(仅 gemini-3-pro-image 等图片模型使用) + field.Int("image_count"). + Default(0), + field.String("image_size"). + MaxLen(10). + Optional(). + Nillable(), + // 时间戳(只有 created_at,日志不可修改) field.Time("created_at"). Default(time.Now). diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index 75e3173d..35cd337f 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -70,6 +70,10 @@ type UsageLog struct { DurationMs *int `json:"duration_ms,omitempty"` // FirstTokenMs holds the value of the "first_token_ms" field. FirstTokenMs *int `json:"first_token_ms,omitempty"` + // ImageCount holds the value of the "image_count" field. + ImageCount int `json:"image_count,omitempty"` + // ImageSize holds the value of the "image_size" field. + ImageSize *string `json:"image_size,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -159,9 +163,9 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier: values[i] = new(sql.NullFloat64) - case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs: + case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldImageSize: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -334,6 +338,19 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.FirstTokenMs = new(int) *_m.FirstTokenMs = int(value.Int64) } + case usagelog.FieldImageCount: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field image_count", values[i]) + } else if value.Valid { + _m.ImageCount = int(value.Int64) + } + case usagelog.FieldImageSize: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field image_size", values[i]) + } else if value.Valid { + _m.ImageSize = new(string) + *_m.ImageSize = value.String + } case usagelog.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -481,6 +498,14 @@ func (_m *UsageLog) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("image_count=") + builder.WriteString(fmt.Sprintf("%v", _m.ImageCount)) + builder.WriteString(", ") + if v := _m.ImageSize; v != nil { + builder.WriteString("image_size=") + builder.WriteString(*v) + } + builder.WriteString(", ") builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteByte(')') diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index 139721c4..bc0cedc8 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -62,6 +62,10 @@ const ( FieldDurationMs = "duration_ms" // FieldFirstTokenMs holds the string denoting the first_token_ms field in the database. FieldFirstTokenMs = "first_token_ms" + // FieldImageCount holds the string denoting the image_count field in the database. + FieldImageCount = "image_count" + // FieldImageSize holds the string denoting the image_size field in the database. + FieldImageSize = "image_size" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // EdgeUser holds the string denoting the user edge name in mutations. @@ -140,6 +144,8 @@ var Columns = []string{ FieldStream, FieldDurationMs, FieldFirstTokenMs, + FieldImageCount, + FieldImageSize, FieldCreatedAt, } @@ -188,6 +194,10 @@ var ( DefaultBillingType int8 // DefaultStream holds the default value on creation for the "stream" field. DefaultStream bool + // DefaultImageCount holds the default value on creation for the "image_count" field. + DefaultImageCount int + // ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. + ImageSizeValidator func(string) error // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time ) @@ -320,6 +330,16 @@ func ByFirstTokenMs(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldFirstTokenMs, opts...).ToFunc() } +// ByImageCount orders the results by the image_count field. +func ByImageCount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageCount, opts...).ToFunc() +} + +// ByImageSize orders the results by the image_size field. +func ByImageSize(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageSize, opts...).ToFunc() +} + // ByCreatedAt orders the results by the created_at field. func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index 9db01140..7d9edae1 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -175,6 +175,16 @@ func FirstTokenMs(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldFirstTokenMs, v)) } +// ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ. +func ImageCount(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) +} + +// ImageSize applies equality check predicate on the "image_size" field. It's identical to ImageSizeEQ. +func ImageSize(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v)) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v)) @@ -1100,6 +1110,121 @@ func FirstTokenMsNotNil() predicate.UsageLog { return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs)) } +// ImageCountEQ applies the EQ predicate on the "image_count" field. +func ImageCountEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) +} + +// ImageCountNEQ applies the NEQ predicate on the "image_count" field. +func ImageCountNEQ(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldImageCount, v)) +} + +// ImageCountIn applies the In predicate on the "image_count" field. +func ImageCountIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldImageCount, vs...)) +} + +// ImageCountNotIn applies the NotIn predicate on the "image_count" field. +func ImageCountNotIn(vs ...int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldImageCount, vs...)) +} + +// ImageCountGT applies the GT predicate on the "image_count" field. +func ImageCountGT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldImageCount, v)) +} + +// ImageCountGTE applies the GTE predicate on the "image_count" field. +func ImageCountGTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldImageCount, v)) +} + +// ImageCountLT applies the LT predicate on the "image_count" field. +func ImageCountLT(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldImageCount, v)) +} + +// ImageCountLTE applies the LTE predicate on the "image_count" field. +func ImageCountLTE(v int) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldImageCount, v)) +} + +// ImageSizeEQ applies the EQ predicate on the "image_size" field. +func ImageSizeEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v)) +} + +// ImageSizeNEQ applies the NEQ predicate on the "image_size" field. +func ImageSizeNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldImageSize, v)) +} + +// ImageSizeIn applies the In predicate on the "image_size" field. +func ImageSizeIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldImageSize, vs...)) +} + +// ImageSizeNotIn applies the NotIn predicate on the "image_size" field. +func ImageSizeNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldImageSize, vs...)) +} + +// ImageSizeGT applies the GT predicate on the "image_size" field. +func ImageSizeGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldImageSize, v)) +} + +// ImageSizeGTE applies the GTE predicate on the "image_size" field. +func ImageSizeGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldImageSize, v)) +} + +// ImageSizeLT applies the LT predicate on the "image_size" field. +func ImageSizeLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldImageSize, v)) +} + +// ImageSizeLTE applies the LTE predicate on the "image_size" field. +func ImageSizeLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldImageSize, v)) +} + +// ImageSizeContains applies the Contains predicate on the "image_size" field. +func ImageSizeContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldImageSize, v)) +} + +// ImageSizeHasPrefix applies the HasPrefix predicate on the "image_size" field. +func ImageSizeHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldImageSize, v)) +} + +// ImageSizeHasSuffix applies the HasSuffix predicate on the "image_size" field. +func ImageSizeHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldImageSize, v)) +} + +// ImageSizeIsNil applies the IsNil predicate on the "image_size" field. +func ImageSizeIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldImageSize)) +} + +// ImageSizeNotNil applies the NotNil predicate on the "image_size" field. +func ImageSizeNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldImageSize)) +} + +// ImageSizeEqualFold applies the EqualFold predicate on the "image_size" field. +func ImageSizeEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldImageSize, v)) +} + +// ImageSizeContainsFold applies the ContainsFold predicate on the "image_size" field. +func ImageSizeContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 36f3d277..ef4a9ca2 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -323,6 +323,34 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate { return _c } +// SetImageCount sets the "image_count" field. +func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate { + _c.mutation.SetImageCount(v) + return _c +} + +// SetNillableImageCount sets the "image_count" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableImageCount(v *int) *UsageLogCreate { + if v != nil { + _c.SetImageCount(*v) + } + return _c +} + +// SetImageSize sets the "image_size" field. +func (_c *UsageLogCreate) SetImageSize(v string) *UsageLogCreate { + _c.mutation.SetImageSize(v) + return _c +} + +// SetNillableImageSize sets the "image_size" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate { + if v != nil { + _c.SetImageSize(*v) + } + return _c +} + // SetCreatedAt sets the "created_at" field. func (_c *UsageLogCreate) SetCreatedAt(v time.Time) *UsageLogCreate { _c.mutation.SetCreatedAt(v) @@ -457,6 +485,10 @@ func (_c *UsageLogCreate) defaults() { v := usagelog.DefaultStream _c.mutation.SetStream(v) } + if _, ok := _c.mutation.ImageCount(); !ok { + v := usagelog.DefaultImageCount + _c.mutation.SetImageCount(v) + } if _, ok := _c.mutation.CreatedAt(); !ok { v := usagelog.DefaultCreatedAt() _c.mutation.SetCreatedAt(v) @@ -535,6 +567,14 @@ func (_c *UsageLogCreate) check() error { if _, ok := _c.mutation.Stream(); !ok { return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)} } + if _, ok := _c.mutation.ImageCount(); !ok { + return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)} + } + if v, ok := _c.mutation.ImageSize(); ok { + if err := usagelog.ImageSizeValidator(v); err != nil { + return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} + } + } if _, ok := _c.mutation.CreatedAt(); !ok { return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "UsageLog.created_at"`)} } @@ -650,6 +690,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value) _node.FirstTokenMs = &value } + if value, ok := _c.mutation.ImageCount(); ok { + _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) + _node.ImageCount = value + } + if value, ok := _c.mutation.ImageSize(); ok { + _spec.SetField(usagelog.FieldImageSize, field.TypeString, value) + _node.ImageSize = &value + } if value, ok := _c.mutation.CreatedAt(); ok { _spec.SetField(usagelog.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value @@ -1199,6 +1247,42 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert { return u } +// SetImageCount sets the "image_count" field. +func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert { + u.Set(usagelog.FieldImageCount, v) + return u +} + +// UpdateImageCount sets the "image_count" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageCount() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageCount) + return u +} + +// AddImageCount adds v to the "image_count" field. +func (u *UsageLogUpsert) AddImageCount(v int) *UsageLogUpsert { + u.Add(usagelog.FieldImageCount, v) + return u +} + +// SetImageSize sets the "image_size" field. +func (u *UsageLogUpsert) SetImageSize(v string) *UsageLogUpsert { + u.Set(usagelog.FieldImageSize, v) + return u +} + +// UpdateImageSize sets the "image_size" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateImageSize() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldImageSize) + return u +} + +// ClearImageSize clears the value of the "image_size" field. +func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert { + u.SetNull(usagelog.FieldImageSize) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -1720,6 +1804,48 @@ func (u *UsageLogUpsertOne) ClearFirstTokenMs() *UsageLogUpsertOne { }) } +// SetImageCount sets the "image_count" field. +func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageCount(v) + }) +} + +// AddImageCount adds v to the "image_count" field. +func (u *UsageLogUpsertOne) AddImageCount(v int) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.AddImageCount(v) + }) +} + +// UpdateImageCount sets the "image_count" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageCount() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageCount() + }) +} + +// SetImageSize sets the "image_size" field. +func (u *UsageLogUpsertOne) SetImageSize(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSize(v) + }) +} + +// UpdateImageSize sets the "image_size" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateImageSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSize() + }) +} + +// ClearImageSize clears the value of the "image_size" field. +func (u *UsageLogUpsertOne) ClearImageSize() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSize() + }) +} + // Exec executes the query. func (u *UsageLogUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -2407,6 +2533,48 @@ func (u *UsageLogUpsertBulk) ClearFirstTokenMs() *UsageLogUpsertBulk { }) } +// SetImageCount sets the "image_count" field. +func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageCount(v) + }) +} + +// AddImageCount adds v to the "image_count" field. +func (u *UsageLogUpsertBulk) AddImageCount(v int) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.AddImageCount(v) + }) +} + +// UpdateImageCount sets the "image_count" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageCount() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageCount() + }) +} + +// SetImageSize sets the "image_size" field. +func (u *UsageLogUpsertBulk) SetImageSize(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetImageSize(v) + }) +} + +// UpdateImageSize sets the "image_size" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateImageSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateImageSize() + }) +} + +// ClearImageSize clears the value of the "image_size" field. +func (u *UsageLogUpsertBulk) ClearImageSize() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearImageSize() + }) +} + // Exec executes the query. func (u *UsageLogUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 45ad2e2a..7eb2132b 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -504,6 +504,47 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate { return _u } +// SetImageCount sets the "image_count" field. +func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate { + _u.mutation.ResetImageCount() + _u.mutation.SetImageCount(v) + return _u +} + +// SetNillableImageCount sets the "image_count" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableImageCount(v *int) *UsageLogUpdate { + if v != nil { + _u.SetImageCount(*v) + } + return _u +} + +// AddImageCount adds value to the "image_count" field. +func (_u *UsageLogUpdate) AddImageCount(v int) *UsageLogUpdate { + _u.mutation.AddImageCount(v) + return _u +} + +// SetImageSize sets the "image_size" field. +func (_u *UsageLogUpdate) SetImageSize(v string) *UsageLogUpdate { + _u.mutation.SetImageSize(v) + return _u +} + +// SetNillableImageSize sets the "image_size" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableImageSize(v *string) *UsageLogUpdate { + if v != nil { + _u.SetImageSize(*v) + } + return _u +} + +// ClearImageSize clears the value of the "image_size" field. +func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate { + _u.mutation.ClearImageSize() + return _u +} + // SetUser sets the "user" edge to the User entity. func (_u *UsageLogUpdate) SetUser(v *User) *UsageLogUpdate { return _u.SetUserID(v.ID) @@ -603,6 +644,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)} } } + if v, ok := _u.mutation.ImageSize(); ok { + if err := usagelog.ImageSizeValidator(v); err != nil { + return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -738,6 +784,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.FirstTokenMsCleared() { _spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) } + if value, ok := _u.mutation.ImageCount(); ok { + _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedImageCount(); ok { + _spec.AddField(usagelog.FieldImageCount, field.TypeInt, value) + } + if value, ok := _u.mutation.ImageSize(); ok { + _spec.SetField(usagelog.FieldImageSize, field.TypeString, value) + } + if _u.mutation.ImageSizeCleared() { + _spec.ClearField(usagelog.FieldImageSize, field.TypeString) + } if _u.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -1375,6 +1433,47 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne { return _u } +// SetImageCount sets the "image_count" field. +func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne { + _u.mutation.ResetImageCount() + _u.mutation.SetImageCount(v) + return _u +} + +// SetNillableImageCount sets the "image_count" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableImageCount(v *int) *UsageLogUpdateOne { + if v != nil { + _u.SetImageCount(*v) + } + return _u +} + +// AddImageCount adds value to the "image_count" field. +func (_u *UsageLogUpdateOne) AddImageCount(v int) *UsageLogUpdateOne { + _u.mutation.AddImageCount(v) + return _u +} + +// SetImageSize sets the "image_size" field. +func (_u *UsageLogUpdateOne) SetImageSize(v string) *UsageLogUpdateOne { + _u.mutation.SetImageSize(v) + return _u +} + +// SetNillableImageSize sets the "image_size" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableImageSize(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetImageSize(*v) + } + return _u +} + +// ClearImageSize clears the value of the "image_size" field. +func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne { + _u.mutation.ClearImageSize() + return _u +} + // SetUser sets the "user" edge to the User entity. func (_u *UsageLogUpdateOne) SetUser(v *User) *UsageLogUpdateOne { return _u.SetUserID(v.ID) @@ -1487,6 +1586,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %w`, err)} } } + if v, ok := _u.mutation.ImageSize(); ok { + if err := usagelog.ImageSizeValidator(v); err != nil { + return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} + } + } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "UsageLog.user"`) } @@ -1639,6 +1743,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.FirstTokenMsCleared() { _spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt) } + if value, ok := _u.mutation.ImageCount(); ok { + _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedImageCount(); ok { + _spec.AddField(usagelog.FieldImageCount, field.TypeInt, value) + } + if value, ok := _u.mutation.ImageSize(); ok { + _spec.SetField(usagelog.FieldImageSize, field.TypeString, value) + } + if _u.mutation.ImageSizeCleared() { + _spec.ClearField(usagelog.FieldImageSize, field.TypeString) + } if _u.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b02a1c97..cab6ce14 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "log" + "os" "strings" "time" @@ -17,7 +18,7 @@ const ( RunModeSimple = "simple" ) -const DefaultCSPPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // 连接池隔离策略常量 // 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗 @@ -338,8 +339,19 @@ func NormalizeRunMode(value string) string { func Load() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") + + // Add config paths in priority order + // 1. DATA_DIR environment variable (highest priority) + if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { + viper.AddConfigPath(dataDir) + } + // 2. Docker data directory + viper.AddConfigPath("/app/data") + // 3. Current directory viper.AddConfigPath(".") + // 4. Config subdirectory viper.AddConfigPath("./config") + // 5. System config directory viper.AddConfigPath("/etc/sub2api") // 环境变量支持 @@ -372,13 +384,13 @@ func Load() (*Config, error) { cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove) cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy) - if cfg.Server.Mode != "release" && cfg.JWT.Secret == "" { + if cfg.JWT.Secret == "" { secret, err := generateJWTSecret(64) if err != nil { return nil, fmt.Errorf("generate jwt secret error: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } if err := cfg.Validate(); err != nil { @@ -392,7 +404,7 @@ func Load() (*Config, error) { log.Println("Warning: security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).") } - if cfg.Server.Mode != "release" && cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) { + if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) { log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.") } if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 { @@ -549,17 +561,6 @@ func setDefaults() { } func (c *Config) Validate() error { - if c.Server.Mode == "release" { - if c.JWT.Secret == "" { - return fmt.Errorf("jwt.secret is required in release mode") - } - if len(c.JWT.Secret) < 32 { - return fmt.Errorf("jwt.secret must be at least 32 characters") - } - if isWeakJWTSecret(c.JWT.Secret) { - return fmt.Errorf("jwt.secret is too weak") - } - } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 64c5e300..4303e020 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -34,15 +34,16 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler { // AccountHandler handles admin account management type AccountHandler struct { - adminService service.AdminService - oauthService *service.OAuthService - openaiOAuthService *service.OpenAIOAuthService - geminiOAuthService *service.GeminiOAuthService - rateLimitService *service.RateLimitService - accountUsageService *service.AccountUsageService - accountTestService *service.AccountTestService - concurrencyService *service.ConcurrencyService - crsSyncService *service.CRSSyncService + adminService service.AdminService + oauthService *service.OAuthService + openaiOAuthService *service.OpenAIOAuthService + geminiOAuthService *service.GeminiOAuthService + antigravityOAuthService *service.AntigravityOAuthService + rateLimitService *service.RateLimitService + accountUsageService *service.AccountUsageService + accountTestService *service.AccountTestService + concurrencyService *service.ConcurrencyService + crsSyncService *service.CRSSyncService } // NewAccountHandler creates a new admin account handler @@ -51,6 +52,7 @@ func NewAccountHandler( oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, geminiOAuthService *service.GeminiOAuthService, + antigravityOAuthService *service.AntigravityOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, @@ -58,15 +60,16 @@ func NewAccountHandler( crsSyncService *service.CRSSyncService, ) *AccountHandler { return &AccountHandler{ - adminService: adminService, - oauthService: oauthService, - openaiOAuthService: openaiOAuthService, - geminiOAuthService: geminiOAuthService, - rateLimitService: rateLimitService, - accountUsageService: accountUsageService, - accountTestService: accountTestService, - concurrencyService: concurrencyService, - crsSyncService: crsSyncService, + adminService: adminService, + oauthService: oauthService, + openaiOAuthService: openaiOAuthService, + geminiOAuthService: geminiOAuthService, + antigravityOAuthService: antigravityOAuthService, + rateLimitService: rateLimitService, + accountUsageService: accountUsageService, + accountTestService: accountTestService, + concurrencyService: concurrencyService, + crsSyncService: crsSyncService, } } @@ -420,6 +423,19 @@ func (h *AccountHandler) Refresh(c *gin.Context) { newCredentials[k] = v } } + } else if account.Platform == service.PlatformAntigravity { + tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(c.Request.Context(), account) + if err != nil { + response.ErrorFrom(c, err) + return + } + + newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo) + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } } else { // Use Anthropic/Claude OAuth service to refresh token tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index fe54d75f..30cdd914 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -26,31 +26,33 @@ func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardH } // parseTimeRange parses start_date, end_date query parameters +// Uses user's timezone if provided, otherwise falls back to server timezone func parseTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 1ca54aaf..182d26d0 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -33,6 +33,10 @@ type CreateGroupRequest struct { DailyLimitUSD *float64 `json:"daily_limit_usd"` WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` } // UpdateGroupRequest represents update group request @@ -47,6 +51,10 @@ type UpdateGroupRequest struct { DailyLimitUSD *float64 `json:"daily_limit_usd"` WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` } // List handles listing all groups with pagination @@ -139,6 +147,9 @@ func (h *GroupHandler) Create(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD, WeeklyLimitUSD: req.WeeklyLimitUSD, MonthlyLimitUSD: req.MonthlyLimitUSD, + ImagePrice1K: req.ImagePrice1K, + ImagePrice2K: req.ImagePrice2K, + ImagePrice4K: req.ImagePrice4K, }) if err != nil { response.ErrorFrom(c, err) @@ -174,6 +185,9 @@ func (h *GroupHandler) Update(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD, WeeklyLimitUSD: req.WeeklyLimitUSD, MonthlyLimitUSD: req.MonthlyLimitUSD, + ImagePrice1K: req.ImagePrice1K, + ImagePrice2K: req.ImagePrice2K, + ImagePrice4K: req.ImagePrice4K, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 37da93d3..9d14afd2 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -102,8 +102,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -112,7 +113,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -172,7 +173,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // Parse date range - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time startDateStr := c.Query("start_date") @@ -180,12 +182,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -195,13 +197,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index dedc37f2..d937ed77 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -78,6 +78,9 @@ func GroupFromServiceShallow(g *service.Group) *Group { DailyLimitUSD: g.DailyLimitUSD, WeeklyLimitUSD: g.WeeklyLimitUSD, MonthlyLimitUSD: g.MonthlyLimitUSD, + ImagePrice1K: g.ImagePrice1K, + ImagePrice2K: g.ImagePrice2K, + ImagePrice4K: g.ImagePrice4K, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, AccountCount: g.AccountCount, @@ -247,6 +250,8 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog { Stream: l.Stream, DurationMs: l.DurationMs, FirstTokenMs: l.FirstTokenMs, + ImageCount: l.ImageCount, + ImageSize: l.ImageSize, CreatedAt: l.CreatedAt, User: UserFromServiceShallow(l.User), APIKey: APIKeyFromService(l.APIKey), diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 66612f97..a8761f81 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -47,6 +47,11 @@ type Group struct { WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + // 图片生成计费配置(仅 antigravity 平台使用) + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -169,6 +174,10 @@ type UsageLog struct { DurationMs *int `json:"duration_ms"` FirstTokenMs *int `json:"first_token_ms"` + // 图片生成字段 + ImageCount int `json:"image_count"` + ImageSize *string `json:"image_size"` + CreatedAt time.Time `json:"created_at"` User *User `json:"user,omitempty"` diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9e503d4c..129dbfa6 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -88,8 +88,9 @@ func (h *UsageHandler) List(c *gin.Context) { // Parse date range var startTime, endTime *time.Time + userTZ := c.Query("timezone") // Get user's timezone from request if startDateStr := c.Query("start_date"); startDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", startDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return @@ -98,7 +99,7 @@ func (h *UsageHandler) List(c *gin.Context) { } if endDateStr := c.Query("end_date"); endDateStr != "" { - t, err := timezone.ParseInLocation("2006-01-02", endDateStr) + t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -194,7 +195,8 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // 获取时间范围参数 - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time // 优先使用 start_date 和 end_date 参数 @@ -204,12 +206,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { if startDateStr != "" && endDateStr != "" { // 使用自定义日期范围 var err error - startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr) + startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD") return } - endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr) + endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ) if err != nil { response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD") return @@ -221,13 +223,13 @@ func (h *UsageHandler) Stats(c *gin.Context) { period := c.DefaultQuery("period", "today") switch period { case "today": - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) case "week": startTime = now.AddDate(0, 0, -7) case "month": startTime = now.AddDate(0, -1, 0) default: - startTime = timezone.StartOfDay(now) + startTime = timezone.StartOfDayInUserLocation(now, userTZ) } endTime = now } @@ -248,31 +250,33 @@ func (h *UsageHandler) Stats(c *gin.Context) { } // parseUserTimeRange parses start_date, end_date query parameters for user dashboard +// Uses user's timezone if provided, otherwise falls back to server timezone func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) { - now := timezone.Now() + userTZ := c.Query("timezone") // Get user's timezone from request + now := timezone.NowInUserLocation(userTZ) startDate := c.Query("start_date") endDate := c.Query("end_date") var startTime, endTime time.Time if startDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil { startTime = t } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } } else { - startTime = timezone.StartOfDay(now.AddDate(0, 0, -7)) + startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ) } if endDate != "" { - if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil { + if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil { endTime = t.Add(24 * time.Hour) // Include the end date } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } } else { - endTime = timezone.StartOfDay(now.AddDate(0, 0, 1)) + endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ) } return startTime, endTime diff --git a/backend/internal/pkg/antigravity/gemini_types.go b/backend/internal/pkg/antigravity/gemini_types.go index 67f6c3e7..f688332f 100644 --- a/backend/internal/pkg/antigravity/gemini_types.go +++ b/backend/internal/pkg/antigravity/gemini_types.go @@ -67,6 +67,13 @@ type GeminiGenerationConfig struct { TopK *int `json:"topK,omitempty"` ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` + ImageConfig *GeminiImageConfig `json:"imageConfig,omitempty"` +} + +// GeminiImageConfig Gemini 图片生成配置(仅 gemini-3-pro-image 支持) +type GeminiImageConfig struct { + AspectRatio string `json:"aspectRatio,omitempty"` // "1:1", "16:9", "9:16", "4:3", "3:4" + ImageSize string `json:"imageSize,omitempty"` // "1K", "2K", "4K" } // GeminiThinkingConfig Gemini thinking 配置 diff --git a/backend/internal/pkg/timezone/timezone.go b/backend/internal/pkg/timezone/timezone.go index 35795648..40f6e38f 100644 --- a/backend/internal/pkg/timezone/timezone.go +++ b/backend/internal/pkg/timezone/timezone.go @@ -122,3 +122,40 @@ func StartOfMonth(t time.Time) time.Time { func ParseInLocation(layout, value string) (time.Time, error) { return time.ParseInLocation(layout, value, Location()) } + +// ParseInUserLocation parses a time string in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func ParseInUserLocation(layout, value, userTZ string) (time.Time, error) { + loc := Location() // default to server timezone + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + return time.ParseInLocation(layout, value, loc) +} + +// NowInUserLocation returns the current time in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func NowInUserLocation(userTZ string) time.Time { + if userTZ == "" { + return Now() + } + if userLoc, err := time.LoadLocation(userTZ); err == nil { + return time.Now().In(userLoc) + } + return Now() +} + +// StartOfDayInUserLocation returns the start of the given day in the user's timezone. +// If userTZ is empty or invalid, falls back to the configured server timezone. +func StartOfDayInUserLocation(t time.Time, userTZ string) time.Time { + loc := Location() + if userTZ != "" { + if userLoc, err := time.LoadLocation(userTZ); err == nil { + loc = userLoc + } + } + t = t.In(loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 927349bf..1073ae0d 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -773,9 +773,14 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates idx++ } if updates.ProxyID != nil { - setClauses = append(setClauses, "proxy_id = $"+itoa(idx)) - args = append(args, *updates.ProxyID) - idx++ + // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) + if *updates.ProxyID == 0 { + setClauses = append(setClauses, "proxy_id = NULL") + } else { + setClauses = append(setClauses, "proxy_id = $"+itoa(idx)) + args = append(args, *updates.ProxyID) + idx++ + } } if updates.Concurrency != nil { setClauses = append(setClauses, "concurrency = $"+itoa(idx)) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 530d86f7..4384bff5 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -321,6 +321,9 @@ func groupEntityToService(g *dbent.Group) *service.Group { DailyLimitUSD: g.DailyLimitUsd, WeeklyLimitUSD: g.WeeklyLimitUsd, MonthlyLimitUSD: g.MonthlyLimitUsd, + ImagePrice1K: g.ImagePrice1k, + ImagePrice2K: g.ImagePrice2k, + ImagePrice4K: g.ImagePrice4k, DefaultValidityDays: g.DefaultValidityDays, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index 9df74a83..8005f114 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -56,7 +56,7 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { // 确保数据库 schema 已准备就绪。 // SQL 迁移文件是 schema 的权威来源(source of truth)。 // 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。 - migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil { _ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露 diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index c4597ce2..729c1404 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -43,6 +43,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetNillableDailyLimitUsd(groupIn.DailyLimitUSD). SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD). SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD). + SetNillableImagePrice1k(groupIn.ImagePrice1K). + SetNillableImagePrice2k(groupIn.ImagePrice2K). + SetNillableImagePrice4k(groupIn.ImagePrice4K). SetDefaultValidityDays(groupIn.DefaultValidityDays) created, err := builder.Save(ctx) @@ -80,6 +83,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetNillableDailyLimitUsd(groupIn.DailyLimitUSD). SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD). SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD). + SetNillableImagePrice1k(groupIn.ImagePrice1K). + SetNillableImagePrice2k(groupIn.ImagePrice2K). + SetNillableImagePrice4k(groupIn.ImagePrice4K). SetDefaultValidityDays(groupIn.DefaultValidityDays). Save(ctx) if err != nil { diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index aaa38f81..82d5e833 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -22,7 +22,7 @@ import ( "github.com/lib/pq" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, image_count, image_size, created_at" type usageLogRepository struct { client *dbent.Client @@ -109,6 +109,8 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) stream, duration_ms, first_token_ms, + image_count, + image_size, created_at ) VALUES ( $1, $2, $3, $4, $5, @@ -116,7 +118,8 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, $25 + $20, $21, $22, $23, $24, + $25, $26, $27 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -126,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) subscriptionID := nullInt64(log.SubscriptionID) duration := nullInt(log.DurationMs) firstToken := nullInt(log.FirstTokenMs) + imageSize := nullString(log.ImageSize) var requestIDArg any if requestID != "" { @@ -157,6 +161,8 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) log.Stream, duration, firstToken, + log.ImageCount, + imageSize, createdAt, } if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil { @@ -1789,6 +1795,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e stream bool durationMs sql.NullInt64 firstTokenMs sql.NullInt64 + imageCount int + imageSize sql.NullString createdAt time.Time ) @@ -1818,6 +1826,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &stream, &durationMs, &firstTokenMs, + &imageCount, + &imageSize, &createdAt, ); err != nil { return nil, err @@ -1844,6 +1854,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e RateMultiplier: rateMultiplier, BillingType: int8(billingType), Stream: stream, + ImageCount: imageCount, CreatedAt: createdAt, } @@ -1866,6 +1877,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e value := int(firstTokenMs.Int64) log.FirstTokenMs = &value } + if imageSize.Valid { + log.ImageSize = &imageSize.String + } return log, nil } @@ -1938,6 +1952,13 @@ func nullInt(v *int) sql.NullInt64 { return sql.NullInt64{Int64: int64(*v), Valid: true} } +func nullString(v *string) sql.NullString { + if v == nil || *v == "" { + return sql.NullString{} + } + return sql.NullString{String: *v, Valid: true} +} + func setToSlice(set map[int64]struct{}) []int64 { out := make([]int64, 0, len(set)) for id := range set { diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 0d8c25c6..006a5464 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -329,17 +329,20 @@ func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount flo return nil } +// DeductBalance 扣除用户余额 +// 透支策略:允许余额变为负数,确保当前请求能够完成 +// 中间件会阻止余额 <= 0 的用户发起后续请求 func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount float64) error { client := clientFromContext(ctx, r.client) n, err := client.User.Update(). - Where(dbuser.IDEQ(id), dbuser.BalanceGTE(amount)). + Where(dbuser.IDEQ(id)). AddBalance(-amount). Save(ctx) if err != nil { return err } if n == 0 { - return service.ErrInsufficientBalance + return service.ErrUserNotFound } return nil } diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index ab2195e3..f5d0f9ff 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -290,9 +290,14 @@ func (s *UserRepoSuite) TestDeductBalance() { func (s *UserRepoSuite) TestDeductBalance_InsufficientFunds() { user := s.mustCreateUser(&service.User{Email: "insuf@test.com", Balance: 5}) + // 透支策略:允许扣除超过余额的金额 err := s.repo.DeductBalance(s.ctx, user.ID, 999) - s.Require().Error(err, "expected error for insufficient balance") - s.Require().ErrorIs(err, service.ErrInsufficientBalance) + s.Require().NoError(err, "DeductBalance should allow overdraft") + + // 验证余额变为负数 + got, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Require().InDelta(-994.0, got.Balance, 1e-6, "Balance should be negative after overdraft") } func (s *UserRepoSuite) TestDeductBalance_ExactAmount() { @@ -306,6 +311,19 @@ func (s *UserRepoSuite) TestDeductBalance_ExactAmount() { s.Require().InDelta(0.0, got.Balance, 1e-6) } +func (s *UserRepoSuite) TestDeductBalance_AllowsOverdraft() { + user := s.mustCreateUser(&service.User{Email: "overdraft@test.com", Balance: 5.0}) + + // 扣除超过余额的金额 - 应该成功 + err := s.repo.DeductBalance(s.ctx, user.ID, 10.0) + s.Require().NoError(err, "DeductBalance should allow overdraft") + + // 验证余额为负 + got, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Require().InDelta(-5.0, got.Balance, 1e-6, "Balance should be -5.0 after overdraft") +} + // --- Concurrency --- func (s *UserRepoSuite) TestUpdateConcurrency() { @@ -477,9 +495,12 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() { s.Require().NoError(err, "GetByID after DeductBalance") s.Require().InDelta(7.5, got4.Balance, 1e-6) + // 透支策略:允许扣除超过余额的金额 err = s.repo.DeductBalance(s.ctx, user1.ID, 999) - s.Require().Error(err, "DeductBalance expected error for insufficient balance") - s.Require().ErrorIs(err, service.ErrInsufficientBalance, "DeductBalance unexpected error") + s.Require().NoError(err, "DeductBalance should allow overdraft") + gotOverdraft, err := s.repo.GetByID(s.ctx, user1.ID) + s.Require().NoError(err, "GetByID after overdraft") + s.Require().Less(gotOverdraft.Balance, 0.0, "Balance should be negative after overdraft") s.Require().NoError(s.repo.UpdateConcurrency(s.ctx, user1.ID, 3), "UpdateConcurrency") got5, err := s.repo.GetByID(s.ctx, user1.ID) @@ -511,6 +532,6 @@ func (s *UserRepoSuite) TestUpdateConcurrency_NotFound() { func (s *UserRepoSuite) TestDeductBalance_NotFound() { err := s.repo.DeductBalance(s.ctx, 999999, 5) s.Require().Error(err, "expected error for non-existent user") - // DeductBalance 在用户不存在时返回 ErrInsufficientBalance 因为 WHERE 条件不匹配 - s.Require().ErrorIs(err, service.ErrInsufficientBalance) + // DeductBalance 在用户不存在时返回 ErrUserNotFound + s.Require().ErrorIs(err, service.ErrUserNotFound) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d7ab1ceb..f98ebc59 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -241,6 +241,8 @@ func TestAPIContracts(t *testing.T) { "stream": true, "duration_ms": 100, "first_token_ms": 50, + "image_count": 0, + "image_size": null, "created_at": "2025-01-02T03:04:05Z" } ], diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index f3626733..0eacfd16 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -98,6 +98,10 @@ type CreateGroupInput struct { DailyLimitUSD *float64 // 日限额 (USD) WeeklyLimitUSD *float64 // 周限额 (USD) MonthlyLimitUSD *float64 // 月限额 (USD) + // 图片生成计费配置(仅 antigravity 平台使用) + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 } type UpdateGroupInput struct { @@ -111,6 +115,10 @@ type UpdateGroupInput struct { DailyLimitUSD *float64 // 日限额 (USD) WeeklyLimitUSD *float64 // 周限额 (USD) MonthlyLimitUSD *float64 // 月限额 (USD) + // 图片生成计费配置(仅 antigravity 平台使用) + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 } type CreateAccountInput struct { @@ -498,6 +506,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn weeklyLimit := normalizeLimit(input.WeeklyLimitUSD) monthlyLimit := normalizeLimit(input.MonthlyLimitUSD) + // 图片价格:负数表示清除(使用默认价格),0 保留(表示免费) + imagePrice1K := normalizePrice(input.ImagePrice1K) + imagePrice2K := normalizePrice(input.ImagePrice2K) + imagePrice4K := normalizePrice(input.ImagePrice4K) + group := &Group{ Name: input.Name, Description: input.Description, @@ -509,6 +522,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn DailyLimitUSD: dailyLimit, WeeklyLimitUSD: weeklyLimit, MonthlyLimitUSD: monthlyLimit, + ImagePrice1K: imagePrice1K, + ImagePrice2K: imagePrice2K, + ImagePrice4K: imagePrice4K, } if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err @@ -524,6 +540,14 @@ func normalizeLimit(limit *float64) *float64 { return limit } +// normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费) +func normalizePrice(price *float64) *float64 { + if price == nil || *price < 0 { + return nil + } + return price +} + func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) { group, err := s.groupRepo.GetByID(ctx, id) if err != nil { @@ -563,6 +587,16 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.MonthlyLimitUSD != nil { group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD) } + // 图片生成计费配置:负数表示清除(使用默认价格) + if input.ImagePrice1K != nil { + group.ImagePrice1K = normalizePrice(input.ImagePrice1K) + } + if input.ImagePrice2K != nil { + group.ImagePrice2K = normalizePrice(input.ImagePrice2K) + } + if input.ImagePrice4K != nil { + group.ImagePrice4K = normalizePrice(input.ImagePrice4K) + } if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err @@ -702,7 +736,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Extra = input.Extra } if input.ProxyID != nil { - account.ProxyID = input.ProxyID + // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) + if *input.ProxyID == 0 { + account.ProxyID = nil + } else { + account.ProxyID = input.ProxyID + } account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID } // 只在指针非 nil 时更新 Concurrency(支持设置为 0) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go new file mode 100644 index 00000000..3171de11 --- /dev/null +++ b/backend/internal/service/admin_service_group_test.go @@ -0,0 +1,197 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +// groupRepoStubForAdmin 用于测试 AdminService 的 GroupRepository Stub +type groupRepoStubForAdmin struct { + created *Group // 记录 Create 调用的参数 + updated *Group // 记录 Update 调用的参数 + getByID *Group // GetByID 返回值 + getErr error // GetByID 返回的错误 +} + +func (s *groupRepoStubForAdmin) Create(_ context.Context, g *Group) error { + s.created = g + return nil +} + +func (s *groupRepoStubForAdmin) Update(_ context.Context, g *Group) error { + s.updated = g + return nil +} + +func (s *groupRepoStubForAdmin) GetByID(_ context.Context, _ int64) (*Group, error) { + if s.getErr != nil { + return nil, s.getErr + } + return s.getByID, nil +} + +func (s *groupRepoStubForAdmin) Delete(_ context.Context, _ int64) error { + panic("unexpected Delete call") +} + +func (s *groupRepoStubForAdmin) DeleteCascade(_ context.Context, _ int64) ([]int64, error) { + panic("unexpected DeleteCascade call") +} + +func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _ string, _ *bool) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (s *groupRepoStubForAdmin) ListActive(_ context.Context) ([]Group, error) { + panic("unexpected ListActive call") +} + +func (s *groupRepoStubForAdmin) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) { + panic("unexpected ListActiveByPlatform call") +} + +func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool, error) { + panic("unexpected ExistsByName call") +} + +func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) { + panic("unexpected GetAccountCount call") +} + +func (s *groupRepoStubForAdmin) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) { + panic("unexpected DeleteAccountGroupsByGroupID call") +} + +// TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递 +func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + price1K := 0.10 + price2K := 0.15 + price4K := 0.30 + + input := &CreateGroupInput{ + Name: "test-group", + Description: "Test group", + Platform: PlatformAntigravity, + RateMultiplier: 1.0, + ImagePrice1K: &price1K, + ImagePrice2K: &price2K, + ImagePrice4K: &price4K, + } + + group, err := svc.CreateGroup(context.Background(), input) + require.NoError(t, err) + require.NotNil(t, group) + + // 验证 repo 收到了正确的字段 + require.NotNil(t, repo.created) + require.NotNil(t, repo.created.ImagePrice1K) + require.NotNil(t, repo.created.ImagePrice2K) + require.NotNil(t, repo.created.ImagePrice4K) + require.InDelta(t, 0.10, *repo.created.ImagePrice1K, 0.0001) + require.InDelta(t, 0.15, *repo.created.ImagePrice2K, 0.0001) + require.InDelta(t, 0.30, *repo.created.ImagePrice4K, 0.0001) +} + +// TestAdminService_CreateGroup_NilImagePricing 测试 ImagePrice 为 nil 时正常创建 +func TestAdminService_CreateGroup_NilImagePricing(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + input := &CreateGroupInput{ + Name: "test-group", + Description: "Test group", + Platform: PlatformAntigravity, + RateMultiplier: 1.0, + // ImagePrice 字段全部为 nil + } + + group, err := svc.CreateGroup(context.Background(), input) + require.NoError(t, err) + require.NotNil(t, group) + + // 验证 ImagePrice 字段为 nil + require.NotNil(t, repo.created) + require.Nil(t, repo.created.ImagePrice1K) + require.Nil(t, repo.created.ImagePrice2K) + require.Nil(t, repo.created.ImagePrice4K) +} + +// TestAdminService_UpdateGroup_WithImagePricing 测试更新分组时 ImagePrice 字段正确更新 +func TestAdminService_UpdateGroup_WithImagePricing(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformAntigravity, + Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + price1K := 0.12 + price2K := 0.18 + price4K := 0.36 + + input := &UpdateGroupInput{ + ImagePrice1K: &price1K, + ImagePrice2K: &price2K, + ImagePrice4K: &price4K, + } + + group, err := svc.UpdateGroup(context.Background(), 1, input) + require.NoError(t, err) + require.NotNil(t, group) + + // 验证 repo 收到了更新后的字段 + require.NotNil(t, repo.updated) + require.NotNil(t, repo.updated.ImagePrice1K) + require.NotNil(t, repo.updated.ImagePrice2K) + require.NotNil(t, repo.updated.ImagePrice4K) + require.InDelta(t, 0.12, *repo.updated.ImagePrice1K, 0.0001) + require.InDelta(t, 0.18, *repo.updated.ImagePrice2K, 0.0001) + require.InDelta(t, 0.36, *repo.updated.ImagePrice4K, 0.0001) +} + +// TestAdminService_UpdateGroup_PartialImagePricing 测试仅更新部分 ImagePrice 字段 +func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) { + oldPrice2K := 0.15 + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformAntigravity, + Status: StatusActive, + ImagePrice2K: &oldPrice2K, // 已有 2K 价格 + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + // 只更新 1K 价格 + price1K := 0.10 + input := &UpdateGroupInput{ + ImagePrice1K: &price1K, + // ImagePrice2K 和 ImagePrice4K 为 nil,不更新 + } + + group, err := svc.UpdateGroup(context.Background(), 1, input) + require.NoError(t, err) + require.NotNil(t, group) + + // 验证:1K 被更新,2K 保持原值,4K 仍为 nil + require.NotNil(t, repo.updated) + require.NotNil(t, repo.updated.ImagePrice1K) + require.InDelta(t, 0.10, *repo.updated.ImagePrice1K, 0.0001) + require.NotNil(t, repo.updated.ImagePrice2K) + require.InDelta(t, 0.15, *repo.updated.ImagePrice2K, 0.0001) // 原值保持 + require.Nil(t, repo.updated.ImagePrice4K) +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index cfbb85d2..9216ff81 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + mathrand "math/rand" "net/http" "strings" "sync/atomic" @@ -405,6 +406,14 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { + // 检查 context 是否已取消(客户端断开连接) + select { + case <-ctx.Done(): + log.Printf("%s status=context_canceled error=%v", prefix, ctx.Err()) + return nil, ctx.Err() + default: + } + upstreamReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, geminiBody) if err != nil { return nil, err @@ -414,7 +423,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, if err != nil { if attempt < antigravityMaxRetries { log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err) @@ -427,7 +439,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, if attempt < antigravityMaxRetries { log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } // 所有重试都失败,标记限流状态 @@ -845,6 +860,9 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty") } + // 解析请求以获取 image_size(用于图片计费) + imageSize := s.extractImageSize(body) + switch action { case "generateContent", "streamGenerateContent": // ok @@ -901,6 +919,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { + // 检查 context 是否已取消(客户端断开连接) + select { + case <-ctx.Done(): + log.Printf("%s status=context_canceled error=%v", prefix, ctx.Err()) + return nil, ctx.Err() + default: + } + upstreamReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, wrappedBody) if err != nil { return nil, err @@ -910,7 +936,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if err != nil { if attempt < antigravityMaxRetries { log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err) @@ -923,7 +952,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if attempt < antigravityMaxRetries { log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } // 所有重试都失败,标记限流状态 @@ -1030,6 +1062,13 @@ handleSuccess: usage = &ClaudeUsage{} } + // 判断是否为图片生成模型 + imageCount := 0 + if isImageGenerationModel(mappedModel) { + // Gemini 图片生成 API 每次请求只生成一张图片(API 限制) + imageCount = 1 + } + return &ForwardResult{ RequestID: requestID, Usage: *usage, @@ -1037,6 +1076,8 @@ handleSuccess: Stream: stream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, }, nil } @@ -1058,8 +1099,28 @@ func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int) } } -func sleepAntigravityBackoff(attempt int) { - sleepGeminiBackoff(attempt) // 复用 Gemini 的退避逻辑 +// sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待 +// 返回 true 表示正常完成等待,false 表示 context 已取消 +func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { + delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { + delay = geminiRetryMaxDelay + } + + // +/- 20% jitter + r := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) + jitter := time.Duration(float64(delay) * 0.2 * (r.Float64()*2 - 1)) + sleepFor := delay + jitter + if sleepFor < 0 { + sleepFor = 0 + } + + select { + case <-ctx.Done(): + return false + case <-time.After(sleepFor): + return true + } } func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte) { @@ -1523,3 +1584,36 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context } } + +// extractImageSize 从 Gemini 请求中提取 image_size 参数 +func (s *AntigravityGatewayService) extractImageSize(body []byte) string { + var req antigravity.GeminiRequest + if err := json.Unmarshal(body, &req); err != nil { + return "2K" // 默认 2K + } + + if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { + size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) + if size == "1K" || size == "2K" || size == "4K" { + return size + } + } + + return "2K" // 默认 2K +} + +// isImageGenerationModel 判断模型是否为图片生成模型 +// 支持的模型:gemini-3-pro-image, gemini-3-pro-image-preview, gemini-2.5-flash-image 等 +func isImageGenerationModel(model string) bool { + modelLower := strings.ToLower(model) + // 移除 models/ 前缀 + modelLower = strings.TrimPrefix(modelLower, "models/") + + // 精确匹配或前缀匹配 + return modelLower == "gemini-3-pro-image" || + modelLower == "gemini-3-pro-image-preview" || + strings.HasPrefix(modelLower, "gemini-3-pro-image-") || + modelLower == "gemini-2.5-flash-image" || + modelLower == "gemini-2.5-flash-image-preview" || + strings.HasPrefix(modelLower, "gemini-2.5-flash-image-") +} diff --git a/backend/internal/service/antigravity_image_test.go b/backend/internal/service/antigravity_image_test.go new file mode 100644 index 00000000..7fd2f843 --- /dev/null +++ b/backend/internal/service/antigravity_image_test.go @@ -0,0 +1,123 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestIsImageGenerationModel_GeminiProImage 测试 gemini-3-pro-image 识别 +func TestIsImageGenerationModel_GeminiProImage(t *testing.T) { + require.True(t, isImageGenerationModel("gemini-3-pro-image")) + require.True(t, isImageGenerationModel("gemini-3-pro-image-preview")) + require.True(t, isImageGenerationModel("models/gemini-3-pro-image")) +} + +// TestIsImageGenerationModel_GeminiFlashImage 测试 gemini-2.5-flash-image 识别 +func TestIsImageGenerationModel_GeminiFlashImage(t *testing.T) { + require.True(t, isImageGenerationModel("gemini-2.5-flash-image")) + require.True(t, isImageGenerationModel("gemini-2.5-flash-image-preview")) +} + +// TestIsImageGenerationModel_RegularModel 测试普通模型不被识别为图片模型 +func TestIsImageGenerationModel_RegularModel(t *testing.T) { + require.False(t, isImageGenerationModel("claude-3-opus")) + require.False(t, isImageGenerationModel("claude-sonnet-4-20250514")) + require.False(t, isImageGenerationModel("gpt-4o")) + require.False(t, isImageGenerationModel("gemini-2.5-pro")) // 非图片模型 + require.False(t, isImageGenerationModel("gemini-2.5-flash")) + // 验证不会误匹配包含关键词的自定义模型名 + require.False(t, isImageGenerationModel("my-gemini-3-pro-image-test")) + require.False(t, isImageGenerationModel("custom-gemini-2.5-flash-image-wrapper")) +} + +// TestIsImageGenerationModel_CaseInsensitive 测试大小写不敏感 +func TestIsImageGenerationModel_CaseInsensitive(t *testing.T) { + require.True(t, isImageGenerationModel("GEMINI-3-PRO-IMAGE")) + require.True(t, isImageGenerationModel("Gemini-3-Pro-Image")) + require.True(t, isImageGenerationModel("GEMINI-2.5-FLASH-IMAGE")) +} + +// TestExtractImageSize_ValidSizes 测试有效尺寸解析 +func TestExtractImageSize_ValidSizes(t *testing.T) { + svc := &AntigravityGatewayService{} + + // 1K + body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"1K"}}}`) + require.Equal(t, "1K", svc.extractImageSize(body)) + + // 2K + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"2K"}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + // 4K + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"4K"}}}`) + require.Equal(t, "4K", svc.extractImageSize(body)) +} + +// TestExtractImageSize_CaseInsensitive 测试大小写不敏感 +func TestExtractImageSize_CaseInsensitive(t *testing.T) { + svc := &AntigravityGatewayService{} + + body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"1k"}}}`) + require.Equal(t, "1K", svc.extractImageSize(body)) + + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"4k"}}}`) + require.Equal(t, "4K", svc.extractImageSize(body)) +} + +// TestExtractImageSize_Default 测试无 imageConfig 返回默认 2K +func TestExtractImageSize_Default(t *testing.T) { + svc := &AntigravityGatewayService{} + + // 无 generationConfig + body := []byte(`{"contents":[]}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + // 有 generationConfig 但无 imageConfig + body = []byte(`{"generationConfig":{"temperature":0.7}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + // 有 imageConfig 但无 imageSize + body = []byte(`{"generationConfig":{"imageConfig":{}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) +} + +// TestExtractImageSize_InvalidJSON 测试非法 JSON 返回默认 2K +func TestExtractImageSize_InvalidJSON(t *testing.T) { + svc := &AntigravityGatewayService{} + + body := []byte(`not valid json`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + body = []byte(`{"broken":`) + require.Equal(t, "2K", svc.extractImageSize(body)) +} + +// TestExtractImageSize_EmptySize 测试空 imageSize 返回默认 2K +func TestExtractImageSize_EmptySize(t *testing.T) { + svc := &AntigravityGatewayService{} + + body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":""}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + // 空格 + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":" "}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) +} + +// TestExtractImageSize_InvalidSize 测试无效尺寸返回默认 2K +func TestExtractImageSize_InvalidSize(t *testing.T) { + svc := &AntigravityGatewayService{} + + body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"3K"}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"8K"}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) + + body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"invalid"}}}`) + require.Equal(t, "2K", svc.extractImageSize(body)) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a2254744..f2afc343 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -295,3 +295,88 @@ func (s *BillingService) ForceUpdatePricing() error { } return fmt.Errorf("pricing service not initialized") } + +// ImagePriceConfig 图片计费配置 +type ImagePriceConfig struct { + Price1K *float64 // 1K 尺寸价格(nil 表示使用默认值) + Price2K *float64 // 2K 尺寸价格(nil 表示使用默认值) + Price4K *float64 // 4K 尺寸价格(nil 表示使用默认值) +} + +// CalculateImageCost 计算图片生成费用 +// model: 请求的模型名称(用于获取 LiteLLM 默认价格) +// imageSize: 图片尺寸 "1K", "2K", "4K" +// imageCount: 生成的图片数量 +// groupConfig: 分组配置的价格(可能为 nil,表示使用默认值) +// rateMultiplier: 费率倍数 +func (s *BillingService) CalculateImageCost(model string, imageSize string, imageCount int, groupConfig *ImagePriceConfig, rateMultiplier float64) *CostBreakdown { + if imageCount <= 0 { + return &CostBreakdown{} + } + + // 获取单价 + unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig) + + // 计算总费用 + totalCost := unitPrice * float64(imageCount) + + // 应用倍率 + if rateMultiplier <= 0 { + rateMultiplier = 1.0 + } + actualCost := totalCost * rateMultiplier + + return &CostBreakdown{ + TotalCost: totalCost, + ActualCost: actualCost, + } +} + +// getImageUnitPrice 获取图片单价 +func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 { + // 优先使用分组配置的价格 + if groupConfig != nil { + switch imageSize { + case "1K": + if groupConfig.Price1K != nil { + return *groupConfig.Price1K + } + case "2K": + if groupConfig.Price2K != nil { + return *groupConfig.Price2K + } + case "4K": + if groupConfig.Price4K != nil { + return *groupConfig.Price4K + } + } + } + + // 回退到 LiteLLM 默认价格 + return s.getDefaultImagePrice(model, imageSize) +} + +// getDefaultImagePrice 获取 LiteLLM 默认图片价格 +func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 { + basePrice := 0.0 + + // 从 PricingService 获取 output_cost_per_image + if s.pricingService != nil { + pricing := s.pricingService.GetModelPricing(model) + if pricing != nil && pricing.OutputCostPerImage > 0 { + basePrice = pricing.OutputCostPerImage + } + } + + // 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview) + if basePrice <= 0 { + basePrice = 0.134 + } + + // 4K 尺寸翻倍 + if imageSize == "4K" { + return basePrice * 2 + } + + return basePrice +} diff --git a/backend/internal/service/billing_service_image_test.go b/backend/internal/service/billing_service_image_test.go new file mode 100644 index 00000000..18a6b74d --- /dev/null +++ b/backend/internal/service/billing_service_image_test.go @@ -0,0 +1,149 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCalculateImageCost_DefaultPricing 测试无分组配置时使用默认价格 +func TestCalculateImageCost_DefaultPricing(t *testing.T) { + svc := &BillingService{} // pricingService 为 nil,使用硬编码默认值 + + // 2K 尺寸,默认价格 $0.134 + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) + require.InDelta(t, 0.134, cost.ActualCost, 0.0001) + + // 多张图片 + cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0) + require.InDelta(t, 0.402, cost.TotalCost, 0.0001) +} + +// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格 +func TestCalculateImageCost_GroupCustomPricing(t *testing.T) { + svc := &BillingService{} + + price1K := 0.10 + price2K := 0.15 + price4K := 0.30 + groupConfig := &ImagePriceConfig{ + Price1K: &price1K, + Price2K: &price2K, + Price4K: &price4K, + } + + // 1K 使用分组价格 + cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 2, groupConfig, 1.0) + require.InDelta(t, 0.20, cost.TotalCost, 0.0001) + + // 2K 使用分组价格 + cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0) + require.InDelta(t, 0.15, cost.TotalCost, 0.0001) + + // 4K 使用分组价格 + cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0) + require.InDelta(t, 0.30, cost.TotalCost, 0.0001) +} + +// TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍 +func TestCalculateImageCost_4KDoublePrice(t *testing.T) { + svc := &BillingService{} + + // 4K 尺寸,默认价格翻倍 $0.134 * 2 = $0.268 + cost := svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, nil, 1.0) + require.InDelta(t, 0.268, cost.TotalCost, 0.0001) +} + +// TestCalculateImageCost_RateMultiplier 测试费率倍数 +func TestCalculateImageCost_RateMultiplier(t *testing.T) { + svc := &BillingService{} + + // 费率倍数 1.5x + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) // TotalCost 不变 + require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // ActualCost = 0.134 * 1.5 + + // 费率倍数 2.0x + cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0) + require.InDelta(t, 0.268, cost.TotalCost, 0.0001) + require.InDelta(t, 0.536, cost.ActualCost, 0.0001) +} + +// TestCalculateImageCost_ZeroCount 测试 imageCount=0 +func TestCalculateImageCost_ZeroCount(t *testing.T) { + svc := &BillingService{} + + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 0, nil, 1.0) + require.Equal(t, 0.0, cost.TotalCost) + require.Equal(t, 0.0, cost.ActualCost) +} + +// TestCalculateImageCost_NegativeCount 测试 imageCount=-1 +func TestCalculateImageCost_NegativeCount(t *testing.T) { + svc := &BillingService{} + + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", -1, nil, 1.0) + require.Equal(t, 0.0, cost.TotalCost) + require.Equal(t, 0.0, cost.ActualCost) +} + +// TestCalculateImageCost_ZeroRateMultiplier 测试费率倍数为 0 时默认使用 1.0 +func TestCalculateImageCost_ZeroRateMultiplier(t *testing.T) { + svc := &BillingService{} + + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) + require.InDelta(t, 0.134, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理 +} + +// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格 +func TestGetImageUnitPrice_GroupPriorityOverDefault(t *testing.T) { + svc := &BillingService{} + + price2K := 0.20 + groupConfig := &ImagePriceConfig{ + Price2K: &price2K, + } + + // 分组配置了 2K 价格,应该使用分组价格而不是默认的 $0.134 + cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0) + require.InDelta(t, 0.20, cost.TotalCost, 0.0001) +} + +// TestGetImageUnitPrice_PartialGroupConfig 测试分组部分配置时回退默认 +func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) { + svc := &BillingService{} + + // 只配置 1K 价格 + price1K := 0.10 + groupConfig := &ImagePriceConfig{ + Price1K: &price1K, + } + + // 1K 使用分组价格 + cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0) + require.InDelta(t, 0.10, cost.TotalCost, 0.0001) + + // 2K 回退默认价格 $0.134 + cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) + + // 4K 回退默认价格 $0.268 (翻倍) + cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0) + require.InDelta(t, 0.268, cost.TotalCost, 0.0001) +} + +// TestGetDefaultImagePrice_FallbackHardcoded 测试 PricingService 无数据时使用硬编码默认值 +func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) { + svc := &BillingService{} // pricingService 为 nil + + // 1K 和 2K 使用相同的默认价格 $0.134 + cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) + + cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0) + require.InDelta(t, 0.134, cost.TotalCost, 0.0001) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index b964d391..a83e7d05 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -104,6 +104,10 @@ type ForwardResult struct { Stream bool Duration time.Duration FirstTokenMs *int // 首字时间(流式请求) + + // 图片生成计费字段(仅 gemini-3-pro-image 使用) + ImageCount int // 生成的图片数量 + ImageSize string // 图片尺寸 "1K", "2K", "4K" } // UpstreamFailoverError indicates an upstream error that should trigger account failover. @@ -2009,25 +2013,40 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu account := input.Account subscription := input.Subscription - // 计算费用 - tokens := UsageTokens{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - } - // 获取费率倍数 multiplier := s.cfg.Default.RateMultiplier if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = apiKey.Group.RateMultiplier } - cost, err := s.billingService.CalculateCost(result.Model, tokens, multiplier) - if err != nil { - log.Printf("Calculate cost failed: %v", err) - // 使用默认费用继续 - cost = &CostBreakdown{ActualCost: 0} + var cost *CostBreakdown + + // 根据请求类型选择计费方式 + if result.ImageCount > 0 { + // 图片生成计费 + var groupConfig *ImagePriceConfig + if apiKey.Group != nil { + groupConfig = &ImagePriceConfig{ + Price1K: apiKey.Group.ImagePrice1K, + Price2K: apiKey.Group.ImagePrice2K, + Price4K: apiKey.Group.ImagePrice4K, + } + } + cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier) + } else { + // Token 计费 + tokens := UsageTokens{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + } + var err error + cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier) + if err != nil { + log.Printf("Calculate cost failed: %v", err) + cost = &CostBreakdown{ActualCost: 0} + } } // 判断计费方式:订阅模式 vs 余额模式 @@ -2039,6 +2058,10 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu // 创建使用日志 durationMs := int(result.Duration.Milliseconds()) + var imageSize *string + if result.ImageSize != "" { + imageSize = &result.ImageSize + } usageLog := &UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, @@ -2060,6 +2083,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu Stream: result.Stream, DurationMs: &durationMs, FirstTokenMs: result.FirstTokenMs, + ImageCount: result.ImageCount, + ImageSize: imageSize, CreatedAt: time.Now(), } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 7d6f407d..01b6b513 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -17,6 +17,11 @@ type Group struct { MonthlyLimitUSD *float64 DefaultValidityDays int + // 图片生成计费配置(antigravity 和 gemini 平台使用) + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 + CreatedAt time.Time UpdatedAt time.Time @@ -47,3 +52,19 @@ func (g *Group) HasWeeklyLimit() bool { func (g *Group) HasMonthlyLimit() bool { return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0 } + +// GetImagePrice 根据 image_size 返回对应的图片生成价格 +// 如果分组未配置价格,返回 nil(调用方应使用默认值) +func (g *Group) GetImagePrice(imageSize string) *float64 { + switch imageSize { + case "1K": + return g.ImagePrice1K + case "2K": + return g.ImagePrice2K + case "4K": + return g.ImagePrice4K + default: + // 未知尺寸默认按 2K 计费 + return g.ImagePrice2K + } +} diff --git a/backend/internal/service/group_test.go b/backend/internal/service/group_test.go new file mode 100644 index 00000000..a0f9672c --- /dev/null +++ b/backend/internal/service/group_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGroup_GetImagePrice_1K 测试 1K 尺寸返回正确价格 +func TestGroup_GetImagePrice_1K(t *testing.T) { + price := 0.10 + group := &Group{ + ImagePrice1K: &price, + } + + result := group.GetImagePrice("1K") + require.NotNil(t, result) + require.InDelta(t, 0.10, *result, 0.0001) +} + +// TestGroup_GetImagePrice_2K 测试 2K 尺寸返回正确价格 +func TestGroup_GetImagePrice_2K(t *testing.T) { + price := 0.15 + group := &Group{ + ImagePrice2K: &price, + } + + result := group.GetImagePrice("2K") + require.NotNil(t, result) + require.InDelta(t, 0.15, *result, 0.0001) +} + +// TestGroup_GetImagePrice_4K 测试 4K 尺寸返回正确价格 +func TestGroup_GetImagePrice_4K(t *testing.T) { + price := 0.30 + group := &Group{ + ImagePrice4K: &price, + } + + result := group.GetImagePrice("4K") + require.NotNil(t, result) + require.InDelta(t, 0.30, *result, 0.0001) +} + +// TestGroup_GetImagePrice_UnknownSize 测试未知尺寸回退 2K +func TestGroup_GetImagePrice_UnknownSize(t *testing.T) { + price2K := 0.15 + group := &Group{ + ImagePrice2K: &price2K, + } + + // 未知尺寸 "3K" 应该回退到 2K + result := group.GetImagePrice("3K") + require.NotNil(t, result) + require.InDelta(t, 0.15, *result, 0.0001) + + // 空字符串也回退到 2K + result = group.GetImagePrice("") + require.NotNil(t, result) + require.InDelta(t, 0.15, *result, 0.0001) +} + +// TestGroup_GetImagePrice_NilValues 测试未配置时返回 nil +func TestGroup_GetImagePrice_NilValues(t *testing.T) { + group := &Group{ + // 所有 ImagePrice 字段都是 nil + } + + require.Nil(t, group.GetImagePrice("1K")) + require.Nil(t, group.GetImagePrice("2K")) + require.Nil(t, group.GetImagePrice("4K")) + require.Nil(t, group.GetImagePrice("unknown")) +} + +// TestGroup_GetImagePrice_PartialConfig 测试部分配置 +func TestGroup_GetImagePrice_PartialConfig(t *testing.T) { + price1K := 0.10 + group := &Group{ + ImagePrice1K: &price1K, + // ImagePrice2K 和 ImagePrice4K 未配置 + } + + result := group.GetImagePrice("1K") + require.NotNil(t, result) + require.InDelta(t, 0.10, *result, 0.0001) + + // 2K 和 4K 返回 nil + require.Nil(t, group.GetImagePrice("2K")) + require.Nil(t, group.GetImagePrice("4K")) +} diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 30b53c83..392fb65c 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -34,6 +34,7 @@ type LiteLLMModelPricing struct { LiteLLMProvider string `json:"litellm_provider"` Mode string `json:"mode"` SupportsPromptCaching bool `json:"supports_prompt_caching"` + OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格 } // PricingRemoteClient 远程价格数据获取接口 @@ -51,6 +52,7 @@ type LiteLLMRawEntry struct { LiteLLMProvider string `json:"litellm_provider"` Mode string `json:"mode"` SupportsPromptCaching bool `json:"supports_prompt_caching"` + OutputCostPerImage *float64 `json:"output_cost_per_image"` } // PricingService 动态价格服务 @@ -319,6 +321,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if entry.CacheReadInputTokenCost != nil { pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost } + if entry.OutputCostPerImage != nil { + pricing.OutputCostPerImage = *entry.OutputCostPerImage + } result[modelName] = pricing } diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index ed0a8eb7..255f0440 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -39,6 +39,10 @@ type UsageLog struct { DurationMs *int FirstTokenMs *int + // 图片生成字段 + ImageCount int + ImageSize *string + CreatedAt time.Time User *User diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index ad077735..65118161 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -9,7 +9,6 @@ import ( "log" "os" "strconv" - "strings" "time" "github.com/Wei-Shaw/sub2api/internal/repository" @@ -22,10 +21,44 @@ import ( // Config paths const ( - ConfigFile = "config.yaml" - EnvFile = ".env" + ConfigFileName = "config.yaml" + InstallLockFile = ".installed" ) +// GetDataDir returns the data directory for storing config and lock files. +// Priority: DATA_DIR env > /app/data (if exists and writable) > current directory +func GetDataDir() string { + // Check DATA_DIR environment variable first + if dir := os.Getenv("DATA_DIR"); dir != "" { + return dir + } + + // Check if /app/data exists and is writable (Docker environment) + dockerDataDir := "/app/data" + if info, err := os.Stat(dockerDataDir); err == nil && info.IsDir() { + // Try to check if writable by creating a temp file + testFile := dockerDataDir + "/.write_test" + if f, err := os.Create(testFile); err == nil { + _ = f.Close() + _ = os.Remove(testFile) + return dockerDataDir + } + } + + // Default to current directory + return "." +} + +// GetConfigFilePath returns the full path to config.yaml +func GetConfigFilePath() string { + return GetDataDir() + "/" + ConfigFileName +} + +// GetInstallLockPath returns the full path to .installed lock file +func GetInstallLockPath() string { + return GetDataDir() + "/" + InstallLockFile +} + // SetupConfig holds the setup configuration type SetupConfig struct { Database DatabaseConfig `json:"database" yaml:"database"` @@ -72,13 +105,12 @@ type JWTConfig struct { // Uses multiple checks to prevent attackers from forcing re-setup by deleting config func NeedsSetup() bool { // Check 1: Config file must not exist - if _, err := os.Stat(ConfigFile); !os.IsNotExist(err) { + if _, err := os.Stat(GetConfigFilePath()); !os.IsNotExist(err) { return false // Config exists, no setup needed } // Check 2: Installation lock file (harder to bypass) - lockFile := ".installed" - if _, err := os.Stat(lockFile); !os.IsNotExist(err) { + if _, err := os.Stat(GetInstallLockPath()); !os.IsNotExist(err) { return false // Lock file exists, already installed } @@ -197,17 +229,12 @@ func Install(cfg *SetupConfig) error { // Generate JWT secret if not provided if cfg.JWT.Secret == "" { - if strings.EqualFold(cfg.Server.Mode, "release") { - return fmt.Errorf("jwt secret is required in release mode") - } secret, err := generateSecret(32) if err != nil { return fmt.Errorf("failed to generate jwt secret: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") - } else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 { - return fmt.Errorf("jwt secret must be at least 32 characters in release mode") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } // Test connections @@ -244,9 +271,8 @@ func Install(cfg *SetupConfig) error { // createInstallLock creates a lock file to prevent re-installation attacks func createInstallLock() error { - lockFile := ".installed" content := fmt.Sprintf("installed_at=%s\n", time.Now().UTC().Format(time.RFC3339)) - return os.WriteFile(lockFile, []byte(content), 0400) // Read-only for owner + return os.WriteFile(GetInstallLockPath(), []byte(content), 0400) // Read-only for owner } func initializeDatabase(cfg *SetupConfig) error { @@ -397,7 +423,7 @@ func writeConfigFile(cfg *SetupConfig) error { return err } - return os.WriteFile(ConfigFile, data, 0600) + return os.WriteFile(GetConfigFilePath(), data, 0600) } func generateSecret(length int) (string, error) { @@ -440,6 +466,7 @@ func getEnvIntOrDefault(key string, defaultValue int) int { // This is designed for Docker deployment where all config is passed via env vars func AutoSetupFromEnv() error { log.Println("Auto setup enabled, configuring from environment variables...") + log.Printf("Data directory: %s", GetDataDir()) // Get timezone from TZ or TIMEZONE env var (TZ is standard for Docker) tz := getEnvOrDefault("TZ", "") @@ -481,17 +508,12 @@ func AutoSetupFromEnv() error { // Generate JWT secret if not provided if cfg.JWT.Secret == "" { - if strings.EqualFold(cfg.Server.Mode, "release") { - return fmt.Errorf("jwt secret is required in release mode") - } secret, err := generateSecret(32) if err != nil { return fmt.Errorf("failed to generate jwt secret: %w", err) } cfg.JWT.Secret = secret - log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") - } else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 { - return fmt.Errorf("jwt secret must be at least 32 characters in release mode") + log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } // Generate admin password if not provided diff --git a/backend/migrations/028_group_image_pricing.sql b/backend/migrations/028_group_image_pricing.sql new file mode 100644 index 00000000..19961d1c --- /dev/null +++ b/backend/migrations/028_group_image_pricing.sql @@ -0,0 +1,10 @@ +-- 为 Antigravity 分组添加图片生成计费配置 +-- 支持 gemini-3-pro-image 模型的 1K/2K/4K 分辨率按次计费 + +ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_1k DECIMAL(20,8); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_2k DECIMAL(20,8); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_4k DECIMAL(20,8); + +COMMENT ON COLUMN groups.image_price_1k IS '1K 分辨率图片生成单价 (USD),仅 antigravity 平台使用'; +COMMENT ON COLUMN groups.image_price_2k IS '2K 分辨率图片生成单价 (USD),仅 antigravity 平台使用'; +COMMENT ON COLUMN groups.image_price_4k IS '4K 分辨率图片生成单价 (USD),仅 antigravity 平台使用'; diff --git a/backend/migrations/029_usage_log_image_fields.sql b/backend/migrations/029_usage_log_image_fields.sql new file mode 100644 index 00000000..16304d24 --- /dev/null +++ b/backend/migrations/029_usage_log_image_fields.sql @@ -0,0 +1,5 @@ +-- 为使用日志添加图片生成统计字段 +-- 用于记录 gemini-3-pro-image 等图片生成模型的使用情况 + +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_count INT DEFAULT 0; +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_size VARCHAR(10); diff --git a/deploy/.env.example b/deploy/.env.example index d9e81959..93d97667 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -54,7 +54,10 @@ ADMIN_PASSWORD= # ----------------------------------------------------------------------------- # JWT Configuration # ----------------------------------------------------------------------------- -# Leave empty to auto-generate (recommended) +# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being +# invalidated after container restarts. If left empty, a random secret will +# be generated on each startup, causing all users to be logged out. +# Generate a secure secret: openssl rand -hex 32 JWT_SECRET= JWT_EXPIRE_HOUR=24 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index f43c9c19..84f5f578 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -97,7 +97,7 @@ security: enabled: true # Default CSP policy (override if you host assets on other domains) # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) - policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1c9d06b0..cc10ed63 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -72,7 +72,10 @@ services: # ======================================================================= # JWT Configuration # ======================================================================= - # Leave empty to auto-generate (recommended) + # IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being + # invalidated after container restarts. If left empty, a random secret + # will be generated on each startup. + # Generate a secure secret: openssl rand -hex 32 - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1cc8e55b..4e53069a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({ // ==================== Request Interceptor ==================== +// Get user's timezone +const getUserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone + } catch { + return 'UTC' + } +} + apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Attach token from localStorage @@ -34,6 +43,14 @@ apiClient.interceptors.request.use( config.headers['Accept-Language'] = getLocale() } + // Attach timezone for all GET requests (backend may use it for default date ranges) + if (config.method === 'get') { + if (!config.params) { + config.params = {} + } + config.params.timezone = getUserTimezone() + } + return config }, (error) => { diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue index 93f38a83..92016699 100644 --- a/frontend/src/components/account/AccountStatsModal.vue +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -97,19 +90,7 @@ t('admin.accounts.stats.totalRequests') }}
- - - +

@@ -129,19 +110,12 @@ t('admin.accounts.stats.avgDailyCost') }}

- - - +

@@ -245,19 +219,12 @@

- - - +
{{ t('admin.accounts.stats.highestCostDay') @@ -295,19 +262,12 @@
- - - +
{{ t('admin.accounts.stats.highestRequestDay') @@ -348,19 +308,7 @@
- - - +
{{ t('admin.accounts.stats.accumulatedTokens') @@ -390,19 +338,7 @@
- - - +
{{ t('admin.accounts.stats.performance') @@ -432,19 +368,12 @@
- - - +
{{ t('admin.accounts.stats.recentActivity') @@ -504,14 +433,7 @@ v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400" > - - - +

{{ t('admin.accounts.stats.noData') }}

@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs' import BaseDialog from '@/components/common/BaseDialog.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' +import Icon from '@/components/icons/Icon.vue' import { adminAPI } from '@/api/admin' import type { Account, AccountUsageStatsResponse } from '@/types' diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 281bf832..7dae33bb 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -48,13 +48,7 @@ - - - + 429 @@ -73,13 +67,7 @@ - - - + 529 @@ -100,6 +88,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' import { formatTime } from '@/utils/format' +import Icon from '@/components/icons/Icon.vue' const { t } = useI18n() @@ -179,4 +168,4 @@ const handleTempUnschedClick = () => { emit('show-temp-unsched', props.account) } - \ No newline at end of file + diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index 619a2ba3..42f3c1b9 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -70,14 +63,7 @@ >
- - - + {{ t('admin.accounts.readyToTest') }}
@@ -128,14 +114,7 @@ v-else-if="status === 'error'" class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400" > - - - + {{ errorMessage }}
@@ -147,14 +126,7 @@ class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100" :title="t('admin.accounts.copyOutput')" > - - - +
@@ -162,26 +134,12 @@
- - - + {{ t('admin.accounts.testModel') }}
- - - + {{ t('admin.accounts.testPrompt') }}
@@ -278,6 +236,7 @@ import { ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 60b3d364..51ad32d1 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -318,19 +318,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -391,14 +379,7 @@ class="hover:text-red-900 dark:hover:text-red-300" @click="removeErrorCode(code)" > - - - + @@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' +import Icon from '@/components/icons/Icon.vue' interface Props { show: boolean @@ -849,7 +831,8 @@ const buildUpdatePayload = (): Record | null => { let credentialsChanged = false if (enableProxy.value) { - updates.proxy_id = proxyId.value + // 后端期望 proxy_id: 0 表示清除代理,而不是 null + updates.proxy_id = proxyId.value === null ? 0 : proxyId.value } if (enableConcurrency.value) { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 88b2815b..0091873c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -81,19 +81,7 @@ : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200' ]" > - - - + Anthropic
@@ -196,19 +172,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
{{ @@ -238,19 +202,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
{{ @@ -286,19 +238,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
OAuth @@ -324,19 +264,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
API Key @@ -380,19 +308,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -487,9 +403,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -532,9 +446,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -710,19 +622,7 @@ class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" >
- - - +
OAuth @@ -1012,19 +912,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -1083,14 +971,7 @@ @click="removeErrorCode(code)" class="hover:text-red-900 dark:hover:text-red-300" > - - - + @@ -1158,23 +1039,11 @@
-

- - - - {{ t('admin.accounts.tempUnschedulable.notice') }} -

-
+

+ + {{ t('admin.accounts.tempUnschedulable.notice') }} +

+
@@ -1734,6 +1594,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dae316fa..4ac149f2 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -265,19 +265,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -336,14 +324,7 @@ @click="removeErrorCode(code)" class="hover:text-red-900 dark:hover:text-red-300" > - - - + @@ -412,19 +393,7 @@

- - - + {{ t('admin.accounts.tempUnschedulable.notice') }}

@@ -458,9 +427,7 @@ @click="moveTempUnschedRule(index, -1)" class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200" > - - - +
@@ -702,6 +662,7 @@ import { adminAPI } from '@/api/admin' import type { Account, Proxy, Group } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' @@ -1092,6 +1053,10 @@ const handleSubmit = async () => { submitting.value = true try { const updatePayload: Record = { ...form } + // 后端期望 proxy_id: 0 表示清除代理,而不是 null + if (updatePayload.proxy_id === null) { + updatePayload.proxy_id = 0 + } // For apikey type, handle credentials update if (props.account.type === 'apikey') { diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index b029d376..c8c1b852 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -21,9 +21,7 @@ @click.stop="removeModel(model)" class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500" > - - - +
@@ -126,6 +124,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import ModelIcon from '@/components/common/ModelIcon.vue' +import Icon from '@/components/icons/Icon.vue' import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist' const { t } = useI18n() diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 7ce30b46..194237fa 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -2,21 +2,9 @@
-
+
- - - +

{{ oauthTitle }}

@@ -66,19 +54,7 @@
@@ -427,19 +358,7 @@ >

- - - + {{ oauthAuthCodeHint }}

@@ -471,19 +378,12 @@ class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30" >
- - - +

{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}

{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}

@@ -514,6 +414,7 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useClipboard } from '@/composables/useClipboard' +import Icon from '@/components/icons/Icon.vue' import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth' interface Props { diff --git a/frontend/src/components/account/ReAuthAccountModal.vue b/frontend/src/components/account/ReAuthAccountModal.vue index 26320451..43d1198f 100644 --- a/frontend/src/components/account/ReAuthAccountModal.vue +++ b/frontend/src/components/account/ReAuthAccountModal.vue @@ -23,19 +23,7 @@ : 'from-orange-500 to-orange-600' ]" > - - - +
{{ @@ -135,19 +123,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -179,19 +155,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -295,6 +259,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Account } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 9fa7d718..980fd352 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -3,12 +3,33 @@
@@ -16,6 +37,14 @@ \ No newline at end of file +import { Icon } from '@/components/icons' +import type { Account } from '@/types' + +const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>() +defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit']) +const { t } = useI18n() +const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) +const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date()) + diff --git a/frontend/src/components/admin/account/AccountStatsModal.vue b/frontend/src/components/admin/account/AccountStatsModal.vue index 93f38a83..138f5811 100644 --- a/frontend/src/components/admin/account/AccountStatsModal.vue +++ b/frontend/src/components/admin/account/AccountStatsModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -60,19 +53,7 @@ t('admin.accounts.stats.totalCost') }}
- - - +

@@ -97,19 +78,7 @@ t('admin.accounts.stats.totalRequests') }}

- - - +

@@ -129,19 +98,11 @@ t('admin.accounts.stats.avgDailyCost') }}

- - - +

@@ -195,19 +156,7 @@

- - - +
{{ t('admin.accounts.stats.todayOverview') @@ -245,19 +194,7 @@
- - - +
{{ t('admin.accounts.stats.highestCostDay') @@ -295,19 +232,11 @@
- - - +
{{ t('admin.accounts.stats.highestRequestDay') @@ -348,19 +277,7 @@
- - - +
{{ t('admin.accounts.stats.accumulatedTokens') @@ -390,19 +307,7 @@
- - - +
{{ t('admin.accounts.stats.performance') @@ -432,19 +337,11 @@
- - - +
{{ t('admin.accounts.stats.recentActivity') @@ -504,14 +401,7 @@ v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400" > - - - +

{{ t('admin.accounts.stats.noData') }}

@@ -547,6 +437,7 @@ import { Line } from 'vue-chartjs' import BaseDialog from '@/components/common/BaseDialog.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' +import Icon from '@/components/icons/Icon.vue' import { adminAPI } from '@/api/admin' import type { Account, AccountUsageStatsResponse } from '@/types' diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index 035c9f83..96fceaa0 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -1,11 +1,19 @@ diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 457afe8c..47ceedd7 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -1,17 +1,15 @@ @@ -19,7 +17,9 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue' const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n() const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) } +const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) } const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) } -const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }]) -const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'error', label: t('admin.accounts.status.error') }]) +const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }]) +const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }]) +const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }]) diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 619a2ba3..2cb1c5a5 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -70,32 +63,11 @@ >
- - - + {{ t('admin.accounts.readyToTest') }}
- - - - + {{ t('admin.accounts.connectingToApi') }}
@@ -114,28 +86,14 @@ v-if="status === 'success'" class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400" > - - - + {{ t('admin.accounts.testCompleted') }}
- - - + {{ errorMessage }}
@@ -147,14 +105,7 @@ class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100" :title="t('admin.accounts.copyOutput')" > - - - +
@@ -162,26 +113,12 @@
- - - + {{ t('admin.accounts.testModel') }}
- - - + {{ t('admin.accounts.testPrompt') }}
@@ -210,54 +147,15 @@ : 'bg-primary-500 text-white hover:bg-primary-600' ]" > - - - - - - - - - - - + name="refresh" + size="sm" + class="animate-spin" + :stroke-width="2" + /> + + {{ status === 'connecting' @@ -278,6 +176,7 @@ import { ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import { Icon } from '@/components/icons' import { useClipboard } from '@/composables/useClipboard' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue index 9bfa9530..d9838a2e 100644 --- a/frontend/src/components/admin/account/ReAuthAccountModal.vue +++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue @@ -23,19 +23,7 @@ : 'from-orange-500 to-orange-600' ]" > - - - +
{{ @@ -107,9 +95,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
Google One @@ -135,19 +121,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -179,19 +153,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -295,6 +257,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Account } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component diff --git a/frontend/src/components/admin/usage/UsageStatsCards.vue b/frontend/src/components/admin/usage/UsageStatsCards.vue index c214fc50..2af25e36 100644 --- a/frontend/src/components/admin/usage/UsageStatsCards.vue +++ b/frontend/src/components/admin/usage/UsageStatsCards.vue @@ -1,7 +1,9 @@ \ No newline at end of file +import { useI18n } from 'vue-i18n' +import type { AdminUsageStatsResponse } from '@/api/admin/usage' +import Icon from '@/components/icons/Icon.vue' + +defineProps<{ stats: AdminUsageStatsResponse | null }>() + +const { t } = useI18n() + +const formatDuration = (ms: number) => + ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s` + +const formatTokens = (value: number) => { + if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B' + if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M' + if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K' + return value.toLocaleString() +} + diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 91e71e42..1b021d64 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -1,22 +1,163 @@ \ No newline at end of file + +const formatCacheTokens = (tokens: number): string => { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` + return tokens.toString() +} + +const formatDuration = (ms: number | null | undefined): string => { + if (ms == null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +const copyRequestId = async (requestId: string) => { + try { + await navigator.clipboard.writeText(requestId) + copiedRequestId.value = requestId + appStore.showSuccess(t('admin.usage.requestIdCopied')) + setTimeout(() => { copiedRequestId.value = null }, 2000) + } catch { + appStore.showError(t('common.copyFailed')) + } +} + diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index e538e4e5..c1783fd2 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -52,7 +52,7 @@ const load = async () => { loading.value = true; try { const res = await adminAP const handleSave = async () => { if (!props.user) return; submitting.value = true try { - await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value.length > 0 ? selectedIds.value : null }) + await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value }) appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close') } catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false } } diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue index 41050629..1918577a 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -37,10 +37,22 @@ watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } }) const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0) const handleBalanceSubmit = async () => { - if (!props.user) return; submitting.value = true + if (!props.user) return + if (!form.amount || form.amount <= 0) { + appStore.showError(t('admin.users.amountRequired')) + return + } + if (props.operation === 'subtract' && form.amount > props.user.balance) { + appStore.showError(t('admin.users.insufficientBalance')) + return + } + submitting.value = true try { await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes) appStore.showSuccess(t('common.success')); emit('success'); emit('close') - } catch (error) { console.error('Failed to update balance:', error) } finally { submitting.value = false } + } catch (e: any) { + console.error('Failed to update balance:', e) + appStore.showError(e.response?.data?.detail || t('common.error')) + } finally { submitting.value = false } } - \ No newline at end of file + diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue index 2f28bf52..f2ab1e02 100644 --- a/frontend/src/components/admin/user/UserCreateModal.vue +++ b/frontend/src/components/admin/user/UserCreateModal.vue @@ -17,7 +17,7 @@
@@ -52,6 +52,7 @@ import { reactive, watch } from 'vue' import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin' import { useForm } from '@/composables/useForm' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ show: boolean }>() const emit = defineEmits(['close', 'success']); const { t } = useI18n() diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 3f6fd206..2c4b117a 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -21,7 +21,7 @@
@@ -59,6 +59,7 @@ import { adminAPI } from '@/api/admin' import type { User, UserAttributeValuesMap } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import UserAttributeForm from '@/components/user/UserAttributeForm.vue' +import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ show: boolean, user: User | null }>() const emit = defineEmits(['close', 'success']) @@ -86,6 +87,14 @@ const copyPassword = async () => { } const handleUpdateUser = async () => { if (!props.user) return + if (!form.email.trim()) { + appStore.showError(t('admin.users.emailRequired')) + return + } + if (form.concurrency < 1) { + appStore.showError(t('admin.users.concurrencyMin')) + return + } submitting.value = true try { const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency } @@ -98,4 +107,4 @@ const handleUpdateUser = async () => { appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate')) } finally { submitting.value = false } } - \ No newline at end of file + diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index fab48fe0..3d38b568 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -21,15 +21,7 @@ class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300" aria-label="Close modal" > - - - +
@@ -50,6 +42,7 @@ diff --git a/frontend/src/components/icons/index.ts b/frontend/src/components/icons/index.ts new file mode 100644 index 00000000..ea5ccfd4 --- /dev/null +++ b/frontend/src/components/icons/index.ts @@ -0,0 +1 @@ +export { default as Icon } from './Icon.vue' diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 3d687b5a..16c39bf8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -81,9 +81,7 @@ >

- - - + {{ file.hint }}

@@ -117,9 +115,7 @@
- - - +

{{ platformNote }}

@@ -144,6 +140,7 @@ import { ref, computed, h, watch, type Component } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' import type { GroupPlatform } from '@/types' diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index e3985619..fd8742c3 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -8,19 +8,7 @@ class="btn-ghost btn-icon lg:hidden" aria-label="Toggle Menu" > - - - +
- +
@@ -171,15 +163,11 @@ @click="removeOption(index)" class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600" > - - - +
@@ -256,6 +244,7 @@ import { adminAPI } from '@/api/admin' import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' +import Icon from '@/components/icons/Icon.vue' import Select from '@/components/common/Select.vue' const { t } = useI18n() @@ -344,6 +333,18 @@ const removeOption = (index: number) => { } const handleSave = async () => { + if (!form.key.trim()) { + appStore.showError(t('admin.users.attributes.keyRequired')) + return + } + if (!form.name.trim()) { + appStore.showError(t('admin.users.attributes.nameRequired')) + return + } + if ((form.type === 'select' || form.type === 'multi_select') && form.options.length === 0) { + appStore.showError(t('admin.users.attributes.optionsRequired')) + return + } saving.value = true try { const data = { diff --git a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue index 83180025..9d884aed 100644 --- a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue +++ b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue @@ -6,47 +6,47 @@
@@ -55,6 +55,7 @@ \ No newline at end of file + diff --git a/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue b/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue index 56f361bb..a0605c76 100644 --- a/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue +++ b/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue @@ -15,9 +15,7 @@
- - - +

{{ log.model }}

@@ -35,9 +33,7 @@ {{ t('dashboard.viewAllUsage') }} - - - +
@@ -48,6 +44,7 @@ import { useI18n } from 'vue-i18n' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import EmptyState from '@/components/common/EmptyState.vue' +import Icon from '@/components/icons/Icon.vue' import { formatDateTime } from '@/utils/format' import type { UsageLog } from '@/types' diff --git a/frontend/src/components/user/dashboard/UserDashboardStats.vue b/frontend/src/components/user/dashboard/UserDashboardStats.vue index 6cf7e07f..dfba3a51 100644 --- a/frontend/src/components/user/dashboard/UserDashboardStats.vue +++ b/frontend/src/components/user/dashboard/UserDashboardStats.vue @@ -21,9 +21,7 @@
- - - +

{{ t('dashboard.apiKeys') }}

@@ -37,9 +35,7 @@
- - - +

{{ t('dashboard.todayRequests') }}

@@ -53,9 +49,7 @@
- - - +

{{ t('dashboard.todayCost') }}

@@ -79,9 +73,7 @@
- - - +

{{ t('dashboard.todayTokens') }}

@@ -95,9 +87,7 @@
- - - +

{{ t('dashboard.totalTokens') }}

@@ -111,9 +101,7 @@
- - - +

{{ t('dashboard.performance') }}

@@ -133,9 +121,7 @@
- - - +

{{ t('dashboard.avgResponse') }}

@@ -149,6 +135,7 @@ diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 03187c4b..b6f6022d 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -30,38 +30,14 @@
- - - + {{ user?.email }}
- - - + {{ user.username }}
@@ -71,6 +47,7 @@ diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index a16e05fa..d4bf555c 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -8,19 +8,11 @@
- - - + - - - +
@@ -136,9 +116,7 @@ @click="showFilterDropdown = !showFilterDropdown" class="btn btn-secondary" > - - - + {{ t('admin.users.filterSettings') }} @@ -154,16 +132,13 @@ class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > {{ filter.name }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
{{ attr.name }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
@@ -214,44 +186,24 @@ class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > {{ col.label }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
@@ -333,19 +285,7 @@ v-else class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-2 py-1 text-xs text-gray-400 dark:bg-dark-700/50 dark:text-dark-500" > - - - + {{ t('admin.users.noSubscription') }} @@ -400,19 +340,7 @@ @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" > - - - + {{ t('common.edit') }} @@ -427,34 +355,8 @@ : 'hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400' ]" > - - - - - - + + {{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }} @@ -465,19 +367,7 @@ class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }" > - - - + {{ t('common.more') }}
@@ -522,9 +412,7 @@ @click="handleViewApiKeys(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.apiKeys') }} @@ -533,9 +421,7 @@ @click="handleAllowedGroups(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.groups') }} @@ -546,9 +432,7 @@ @click="handleDeposit(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.deposit') }} @@ -571,9 +455,7 @@ @click="handleDelete(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" > - - - + {{ t('common.delete') }} @@ -597,6 +479,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { formatDateTime } from '@/utils/format' +import Icon from '@/components/icons/Icon.vue' const { t } = useI18n() import { adminAPI } from '@/api/admin' diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index 50644887..abb43cab 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -19,19 +19,7 @@ >
- - - +

{{ t('auth.sessionExpired') }}

@@ -73,19 +61,7 @@ >
- - - +

Verification code sent! Please check your inbox. @@ -115,19 +91,7 @@ >

- - - +

{{ errorMessage }} @@ -158,20 +122,7 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - - - + {{ isLoading ? 'Verifying...' : 'Verify & Create Account' }} @@ -210,19 +161,7 @@ @click="handleBack" class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300" > - - - + Back to registration @@ -234,6 +173,7 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings, sendVerifyCode } from '@/api/auth' diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 477530b4..903db100 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -20,19 +20,7 @@

- - - +
- - - +
- - - - - - - + +

@@ -151,19 +96,7 @@ >

- - - +

{{ errorMessage }} @@ -198,20 +131,7 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - - - + {{ isLoading ? t('auth.signingIn') : t('auth.signIn') }} @@ -237,6 +157,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings } from '@/api/auth' diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 65865a11..9f3555d4 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -18,19 +18,7 @@ >

- - - +

{{ t('auth.registrationDisabled') }} @@ -47,19 +35,7 @@

- - - +
- - - +
- - - - - - - + +

@@ -181,19 +114,7 @@ >

- - - +

{{ errorMessage }} @@ -228,20 +149,7 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - - - + {{ isLoading ? t('auth.processing') @@ -273,6 +181,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings } from '@/api/auth' diff --git a/frontend/src/views/setup/SetupWizardView.vue b/frontend/src/views/setup/SetupWizardView.vue index bc100533..2be837f5 100644 --- a/frontend/src/views/setup/SetupWizardView.vue +++ b/frontend/src/views/setup/SetupWizardView.vue @@ -8,24 +8,7 @@

- - - - +

{{ t('setup.title') }}

{{ t('setup.description') }}

@@ -46,16 +29,12 @@ : 'bg-gray-200 text-gray-500 dark:bg-dark-700 dark:text-dark-400' ]" > - - - + name="check" + size="md" + :stroke-width="2" + /> {{ index + 1 }}
- - - + {{ testingDb ? t('setup.status.testing') @@ -280,16 +250,13 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - - - + name="check" + size="md" + class="mr-2 text-green-500" + :stroke-width="2" + /> {{ testingRedis ? t('setup.status.testing') @@ -395,19 +362,7 @@ class="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20" >
- - - +

{{ errorMessage }}

@@ -438,20 +393,7 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - - - +

{{ t('setup.status.completed') }} @@ -474,19 +416,7 @@ @click="currentStep--" class="btn btn-secondary" > - - - + {{ t('common.back') }}

@@ -498,15 +428,7 @@ class="btn btn-primary" > {{ t('common.next') }} - - - +
@@ -55,30 +35,13 @@ " :title="copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')" > - - - - - - + name="check" + size="sm" + :stroke-width="2" + /> +
@@ -156,19 +119,7 @@ @click="openUseKeyModal(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400" > - - - + {{ t('keys.useKey') }} @@ -176,19 +127,7 @@ @click="importToCcswitch(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400" > - - - + {{ t('keys.importToCcSwitch') }} @@ -201,34 +140,8 @@ : 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400' ]" > - - - - - - + + {{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }} @@ -236,19 +149,7 @@ @click="editKey(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" > - - - + {{ t('common.edit') }} @@ -256,19 +157,7 @@ @click="confirmDelete(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" > - - - + {{ t('common.delete') }}
@@ -465,30 +354,34 @@

{{ t('keys.ccsClientSelect.description') }} -

-
- - -
-
+

+
+ + +
+