diff --git a/.gitignore b/.gitignore index c33cde99..c386360e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,8 @@ temp/ *.log *.bak .cache/ +.gemini-clipboard/ +migrations/ # =================== # 构建产物 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1cab8802 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning. + +## [Unreleased] + +### Breaking Changes + +- Admin ops error logs: `GET /api/v1/admin/ops/error-logs` now enforces `limit <= 500` (previously `<= 5000`). Requests with `limit > 500` return `400 Bad Request` (`Invalid limit (must be 1-500)`). + +### Migration + +- Prefer the paginated endpoint `GET /api/v1/admin/ops/errors` using `page` / `page_size`. +- If you must keep using `.../error-logs`, reduce `limit` to `<= 500` and fetch multiple pages by splitting queries (e.g., by time window) instead of requesting a single large result set. + diff --git a/backend/cmd/jwtgen/main.go b/backend/cmd/jwtgen/main.go new file mode 100644 index 00000000..1b7f4aa4 --- /dev/null +++ b/backend/cmd/jwtgen/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "time" + + _ "github.com/Wei-Shaw/sub2api/ent/runtime" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/repository" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func main() { + email := flag.String("email", "", "Admin email to issue a JWT for (defaults to first active admin)") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + client, sqlDB, err := repository.InitEnt(cfg) + if err != nil { + log.Fatalf("failed to init db: %v", err) + } + defer func() { + if err := client.Close(); err != nil { + log.Printf("failed to close db: %v", err) + } + }() + + userRepo := repository.NewUserRepository(client, sqlDB) + authService := service.NewAuthService(userRepo, cfg, nil, nil, nil, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var user *service.User + if *email != "" { + user, err = userRepo.GetByEmail(ctx, *email) + } else { + user, err = userRepo.GetFirstAdmin(ctx) + } + if err != nil { + log.Fatalf("failed to resolve admin user: %v", err) + } + + token, err := authService.GenerateToken(user) + if err != nil { + log.Fatalf("failed to generate token: %v", err) + } + + fmt.Printf("ADMIN_EMAIL=%s\nADMIN_USER_ID=%d\nJWT=%s\n", user.Email, user.ID, token) +} diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 8596b8ba..dcc807c3 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -71,7 +71,12 @@ func provideCleanup( geminiOAuth *service.GeminiOAuthService, antigravityOAuth *service.AntigravityOAuthService, antigravityQuota *service.AntigravityQuotaRefresher, + opsMetricsCollector *service.OpsMetricsCollector, + opsAlertService *service.OpsAlertService, ) func() { + if opsAlertService != nil { + opsAlertService.Start() + } return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -81,6 +86,14 @@ func provideCleanup( name string fn func() error }{ + {"OpsMetricsCollector", func() error { + opsMetricsCollector.Stop() + return nil + }}, + {"OpsAlertService", func() error { + opsAlertService.Stop() + return nil + }}, {"TokenRefreshService", func() error { tokenRefresh.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5cbc774d..b186c68e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -55,11 +55,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userService := service.NewUserService(userRepository) authHandler := handler.NewAuthHandler(configConfig, authService, userService) userHandler := handler.NewUserHandler(userService) - apiKeyRepository := repository.NewApiKeyRepository(client) + apiKeyRepository := repository.NewAPIKeyRepository(client) groupRepository := repository.NewGroupRepository(client, db) userSubscriptionRepository := repository.NewUserSubscriptionRepository(client) - apiKeyCache := repository.NewApiKeyCache(redisClient) - apiKeyService := service.NewApiKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) + apiKeyCache := repository.NewAPIKeyCache(redisClient) + apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository) @@ -74,6 +74,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) dashboardService := service.NewDashboardService(usageLogRepository) dashboardHandler := admin.NewDashboardHandler(dashboardService) + opsRepository := repository.NewOpsRepository(client, db, redisClient) + opsService := service.NewOpsService(opsRepository, db) + opsHandler := admin.NewOpsHandler(opsService) accountRepository := repository.NewAccountRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber() @@ -121,7 +124,24 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) + adminHandlers := handler.ProvideAdminHandlers( + dashboardHandler, + opsHandler, + adminUserHandler, + groupHandler, + accountHandler, + oAuthHandler, + openAIOAuthHandler, + geminiOAuthHandler, + antigravityOAuthHandler, + proxyHandler, + adminRedeemHandler, + settingHandler, + systemHandler, + adminSubscriptionHandler, + adminUsageHandler, + userAttributeHandler, + ) pricingRemoteClient := repository.NewPricingRemoteClient() pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { @@ -134,19 +154,21 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService) + gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, opsService) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService) - openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService) + openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, opsService) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) - apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) + apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig, opsService) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) antigravityQuotaRefresher := service.ProvideAntigravityQuotaRefresher(accountRepository, proxyRepository, antigravityOAuthService, configConfig) - v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, antigravityQuotaRefresher) + opsAlertService := service.ProvideOpsAlertService(opsService, userService, emailService) + opsMetricsCollector := service.ProvideOpsMetricsCollector(opsService, concurrencyService) + v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, antigravityQuotaRefresher, opsMetricsCollector, opsAlertService) application := &Application{ Server: httpServer, Cleanup: v, @@ -180,7 +202,12 @@ func provideCleanup( geminiOAuth *service.GeminiOAuthService, antigravityOAuth *service.AntigravityOAuthService, antigravityQuota *service.AntigravityQuotaRefresher, + opsMetricsCollector *service.OpsMetricsCollector, + opsAlertService *service.OpsAlertService, ) func() { + if opsAlertService != nil { + opsAlertService.Start() + } return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -189,6 +216,14 @@ func provideCleanup( name string fn func() error }{ + {"OpsMetricsCollector", func() error { + opsMetricsCollector.Stop() + return nil + }}, + {"OpsAlertService", func() error { + opsAlertService.Stop() + return nil + }}, {"TokenRefreshService", func() error { tokenRefresh.Stop() return nil diff --git a/backend/ent/apikey.go b/backend/ent/apikey.go index 61ac15fa..fe3ad0cf 100644 --- a/backend/ent/apikey.go +++ b/backend/ent/apikey.go @@ -14,8 +14,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/user" ) -// ApiKey is the model entity for the ApiKey schema. -type ApiKey struct { +// APIKey is the model entity for the APIKey schema. +type APIKey struct { config `json:"-"` // ID of the ent. ID int64 `json:"id,omitempty"` @@ -36,13 +36,13 @@ type ApiKey struct { // Status holds the value of the "status" field. Status string `json:"status,omitempty"` // Edges holds the relations/edges for other nodes in the graph. - // The values are being populated by the ApiKeyQuery when eager-loading is set. - Edges ApiKeyEdges `json:"edges"` + // The values are being populated by the APIKeyQuery when eager-loading is set. + Edges APIKeyEdges `json:"edges"` selectValues sql.SelectValues } -// ApiKeyEdges holds the relations/edges for other nodes in the graph. -type ApiKeyEdges struct { +// APIKeyEdges holds the relations/edges for other nodes in the graph. +type APIKeyEdges struct { // User holds the value of the user edge. User *User `json:"user,omitempty"` // Group holds the value of the group edge. @@ -56,7 +56,7 @@ type ApiKeyEdges struct { // UserOrErr returns the User value or an error if the edge // was not loaded in eager-loading, or loaded but was not found. -func (e ApiKeyEdges) UserOrErr() (*User, error) { +func (e APIKeyEdges) UserOrErr() (*User, error) { if e.User != nil { return e.User, nil } else if e.loadedTypes[0] { @@ -67,7 +67,7 @@ func (e ApiKeyEdges) UserOrErr() (*User, error) { // GroupOrErr returns the Group value or an error if the edge // was not loaded in eager-loading, or loaded but was not found. -func (e ApiKeyEdges) GroupOrErr() (*Group, error) { +func (e APIKeyEdges) GroupOrErr() (*Group, error) { if e.Group != nil { return e.Group, nil } else if e.loadedTypes[1] { @@ -78,7 +78,7 @@ func (e ApiKeyEdges) GroupOrErr() (*Group, error) { // UsageLogsOrErr returns the UsageLogs value or an error if the edge // was not loaded in eager-loading. -func (e ApiKeyEdges) UsageLogsOrErr() ([]*UsageLog, error) { +func (e APIKeyEdges) UsageLogsOrErr() ([]*UsageLog, error) { if e.loadedTypes[2] { return e.UsageLogs, nil } @@ -86,7 +86,7 @@ func (e ApiKeyEdges) UsageLogsOrErr() ([]*UsageLog, error) { } // scanValues returns the types for scanning values from sql.Rows. -func (*ApiKey) scanValues(columns []string) ([]any, error) { +func (*APIKey) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { @@ -104,8 +104,8 @@ func (*ApiKey) scanValues(columns []string) ([]any, error) { } // assignValues assigns the values that were returned from sql.Rows (after scanning) -// to the ApiKey fields. -func (_m *ApiKey) assignValues(columns []string, values []any) error { +// to the APIKey fields. +func (_m *APIKey) assignValues(columns []string, values []any) error { if m, n := len(values), len(columns); m < n { return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) } @@ -174,49 +174,49 @@ func (_m *ApiKey) assignValues(columns []string, values []any) error { return nil } -// Value returns the ent.Value that was dynamically selected and assigned to the ApiKey. +// Value returns the ent.Value that was dynamically selected and assigned to the APIKey. // This includes values selected through modifiers, order, etc. -func (_m *ApiKey) Value(name string) (ent.Value, error) { +func (_m *APIKey) Value(name string) (ent.Value, error) { return _m.selectValues.Get(name) } -// QueryUser queries the "user" edge of the ApiKey entity. -func (_m *ApiKey) QueryUser() *UserQuery { - return NewApiKeyClient(_m.config).QueryUser(_m) +// QueryUser queries the "user" edge of the APIKey entity. +func (_m *APIKey) QueryUser() *UserQuery { + return NewAPIKeyClient(_m.config).QueryUser(_m) } -// QueryGroup queries the "group" edge of the ApiKey entity. -func (_m *ApiKey) QueryGroup() *GroupQuery { - return NewApiKeyClient(_m.config).QueryGroup(_m) +// QueryGroup queries the "group" edge of the APIKey entity. +func (_m *APIKey) QueryGroup() *GroupQuery { + return NewAPIKeyClient(_m.config).QueryGroup(_m) } -// QueryUsageLogs queries the "usage_logs" edge of the ApiKey entity. -func (_m *ApiKey) QueryUsageLogs() *UsageLogQuery { - return NewApiKeyClient(_m.config).QueryUsageLogs(_m) +// QueryUsageLogs queries the "usage_logs" edge of the APIKey entity. +func (_m *APIKey) QueryUsageLogs() *UsageLogQuery { + return NewAPIKeyClient(_m.config).QueryUsageLogs(_m) } -// Update returns a builder for updating this ApiKey. -// Note that you need to call ApiKey.Unwrap() before calling this method if this ApiKey +// Update returns a builder for updating this APIKey. +// Note that you need to call APIKey.Unwrap() before calling this method if this APIKey // was returned from a transaction, and the transaction was committed or rolled back. -func (_m *ApiKey) Update() *ApiKeyUpdateOne { - return NewApiKeyClient(_m.config).UpdateOne(_m) +func (_m *APIKey) Update() *APIKeyUpdateOne { + return NewAPIKeyClient(_m.config).UpdateOne(_m) } -// Unwrap unwraps the ApiKey entity that was returned from a transaction after it was closed, +// Unwrap unwraps the APIKey entity that was returned from a transaction after it was closed, // so that all future queries will be executed through the driver which created the transaction. -func (_m *ApiKey) Unwrap() *ApiKey { +func (_m *APIKey) Unwrap() *APIKey { _tx, ok := _m.config.driver.(*txDriver) if !ok { - panic("ent: ApiKey is not a transactional entity") + panic("ent: APIKey is not a transactional entity") } _m.config.driver = _tx.drv return _m } // String implements the fmt.Stringer. -func (_m *ApiKey) String() string { +func (_m *APIKey) String() string { var builder strings.Builder - builder.WriteString("ApiKey(") + builder.WriteString("APIKey(") builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) @@ -249,5 +249,5 @@ func (_m *ApiKey) String() string { return builder.String() } -// ApiKeys is a parsable slice of ApiKey. -type ApiKeys []*ApiKey +// APIKeys is a parsable slice of APIKey. +type APIKeys []*APIKey diff --git a/backend/ent/apikey/apikey.go b/backend/ent/apikey/apikey.go index f03b2daa..91f7d620 100644 --- a/backend/ent/apikey/apikey.go +++ b/backend/ent/apikey/apikey.go @@ -109,7 +109,7 @@ var ( StatusValidator func(string) error ) -// OrderOption defines the ordering options for the ApiKey queries. +// OrderOption defines the ordering options for the APIKey queries. type OrderOption func(*sql.Selector) // ByID orders the results by the id field. diff --git a/backend/ent/apikey/where.go b/backend/ent/apikey/where.go index 95bc4e2a..5e739006 100644 --- a/backend/ent/apikey/where.go +++ b/backend/ent/apikey/where.go @@ -11,468 +11,468 @@ import ( ) // ID filters vertices based on their ID field. -func ID(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldID, id)) +func ID(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldID, id)) } // IDEQ applies the EQ predicate on the ID field. -func IDEQ(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldID, id)) +func IDEQ(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldID, id)) } // IDNEQ applies the NEQ predicate on the ID field. -func IDNEQ(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldID, id)) +func IDNEQ(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldID, id)) } // IDIn applies the In predicate on the ID field. -func IDIn(ids ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldID, ids...)) +func IDIn(ids ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldID, ids...)) } // IDNotIn applies the NotIn predicate on the ID field. -func IDNotIn(ids ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldID, ids...)) +func IDNotIn(ids ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldID, ids...)) } // IDGT applies the GT predicate on the ID field. -func IDGT(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldID, id)) +func IDGT(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldID, id)) } // IDGTE applies the GTE predicate on the ID field. -func IDGTE(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldID, id)) +func IDGTE(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldID, id)) } // IDLT applies the LT predicate on the ID field. -func IDLT(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldID, id)) +func IDLT(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldID, id)) } // IDLTE applies the LTE predicate on the ID field. -func IDLTE(id int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldID, id)) +func IDLTE(id int64) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldID, id)) } // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. -func CreatedAt(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldCreatedAt, v)) +func CreatedAt(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldCreatedAt, v)) } // UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. -func UpdatedAt(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldUpdatedAt, v)) +func UpdatedAt(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldUpdatedAt, v)) } // DeletedAt applies equality check predicate on the "deleted_at" field. It's identical to DeletedAtEQ. -func DeletedAt(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldDeletedAt, v)) +func DeletedAt(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldDeletedAt, v)) } // UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. -func UserID(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldUserID, v)) +func UserID(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldUserID, v)) } // Key applies equality check predicate on the "key" field. It's identical to KeyEQ. -func Key(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldKey, v)) +func Key(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldKey, v)) } // Name applies equality check predicate on the "name" field. It's identical to NameEQ. -func Name(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldName, v)) +func Name(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldName, v)) } // GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ. -func GroupID(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldGroupID, v)) +func GroupID(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldGroupID, v)) } // Status applies equality check predicate on the "status" field. It's identical to StatusEQ. -func Status(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldStatus, v)) +func Status(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldStatus, v)) } // CreatedAtEQ applies the EQ predicate on the "created_at" field. -func CreatedAtEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldCreatedAt, v)) +func CreatedAtEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldCreatedAt, v)) } // CreatedAtNEQ applies the NEQ predicate on the "created_at" field. -func CreatedAtNEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldCreatedAt, v)) +func CreatedAtNEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldCreatedAt, v)) } // CreatedAtIn applies the In predicate on the "created_at" field. -func CreatedAtIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldCreatedAt, vs...)) +func CreatedAtIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldCreatedAt, vs...)) } // CreatedAtNotIn applies the NotIn predicate on the "created_at" field. -func CreatedAtNotIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldCreatedAt, vs...)) +func CreatedAtNotIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldCreatedAt, vs...)) } // CreatedAtGT applies the GT predicate on the "created_at" field. -func CreatedAtGT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldCreatedAt, v)) +func CreatedAtGT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldCreatedAt, v)) } // CreatedAtGTE applies the GTE predicate on the "created_at" field. -func CreatedAtGTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldCreatedAt, v)) +func CreatedAtGTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldCreatedAt, v)) } // CreatedAtLT applies the LT predicate on the "created_at" field. -func CreatedAtLT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldCreatedAt, v)) +func CreatedAtLT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldCreatedAt, v)) } // CreatedAtLTE applies the LTE predicate on the "created_at" field. -func CreatedAtLTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldCreatedAt, v)) +func CreatedAtLTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldCreatedAt, v)) } // UpdatedAtEQ applies the EQ predicate on the "updated_at" field. -func UpdatedAtEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldUpdatedAt, v)) +func UpdatedAtEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldUpdatedAt, v)) } // UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. -func UpdatedAtNEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldUpdatedAt, v)) +func UpdatedAtNEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldUpdatedAt, v)) } // UpdatedAtIn applies the In predicate on the "updated_at" field. -func UpdatedAtIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldUpdatedAt, vs...)) +func UpdatedAtIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldUpdatedAt, vs...)) } // UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. -func UpdatedAtNotIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldUpdatedAt, vs...)) +func UpdatedAtNotIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldUpdatedAt, vs...)) } // UpdatedAtGT applies the GT predicate on the "updated_at" field. -func UpdatedAtGT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldUpdatedAt, v)) +func UpdatedAtGT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldUpdatedAt, v)) } // UpdatedAtGTE applies the GTE predicate on the "updated_at" field. -func UpdatedAtGTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldUpdatedAt, v)) +func UpdatedAtGTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldUpdatedAt, v)) } // UpdatedAtLT applies the LT predicate on the "updated_at" field. -func UpdatedAtLT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldUpdatedAt, v)) +func UpdatedAtLT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldUpdatedAt, v)) } // UpdatedAtLTE applies the LTE predicate on the "updated_at" field. -func UpdatedAtLTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldUpdatedAt, v)) +func UpdatedAtLTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldUpdatedAt, v)) } // DeletedAtEQ applies the EQ predicate on the "deleted_at" field. -func DeletedAtEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldDeletedAt, v)) +func DeletedAtEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldDeletedAt, v)) } // DeletedAtNEQ applies the NEQ predicate on the "deleted_at" field. -func DeletedAtNEQ(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldDeletedAt, v)) +func DeletedAtNEQ(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldDeletedAt, v)) } // DeletedAtIn applies the In predicate on the "deleted_at" field. -func DeletedAtIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldDeletedAt, vs...)) +func DeletedAtIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldDeletedAt, vs...)) } // DeletedAtNotIn applies the NotIn predicate on the "deleted_at" field. -func DeletedAtNotIn(vs ...time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldDeletedAt, vs...)) +func DeletedAtNotIn(vs ...time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldDeletedAt, vs...)) } // DeletedAtGT applies the GT predicate on the "deleted_at" field. -func DeletedAtGT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldDeletedAt, v)) +func DeletedAtGT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldDeletedAt, v)) } // DeletedAtGTE applies the GTE predicate on the "deleted_at" field. -func DeletedAtGTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldDeletedAt, v)) +func DeletedAtGTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldDeletedAt, v)) } // DeletedAtLT applies the LT predicate on the "deleted_at" field. -func DeletedAtLT(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldDeletedAt, v)) +func DeletedAtLT(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldDeletedAt, v)) } // DeletedAtLTE applies the LTE predicate on the "deleted_at" field. -func DeletedAtLTE(v time.Time) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldDeletedAt, v)) +func DeletedAtLTE(v time.Time) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldDeletedAt, v)) } // DeletedAtIsNil applies the IsNil predicate on the "deleted_at" field. -func DeletedAtIsNil() predicate.ApiKey { - return predicate.ApiKey(sql.FieldIsNull(FieldDeletedAt)) +func DeletedAtIsNil() predicate.APIKey { + return predicate.APIKey(sql.FieldIsNull(FieldDeletedAt)) } // DeletedAtNotNil applies the NotNil predicate on the "deleted_at" field. -func DeletedAtNotNil() predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotNull(FieldDeletedAt)) +func DeletedAtNotNil() predicate.APIKey { + return predicate.APIKey(sql.FieldNotNull(FieldDeletedAt)) } // UserIDEQ applies the EQ predicate on the "user_id" field. -func UserIDEQ(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldUserID, v)) +func UserIDEQ(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldUserID, v)) } // UserIDNEQ applies the NEQ predicate on the "user_id" field. -func UserIDNEQ(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldUserID, v)) +func UserIDNEQ(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldUserID, v)) } // UserIDIn applies the In predicate on the "user_id" field. -func UserIDIn(vs ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldUserID, vs...)) +func UserIDIn(vs ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldUserID, vs...)) } // UserIDNotIn applies the NotIn predicate on the "user_id" field. -func UserIDNotIn(vs ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldUserID, vs...)) +func UserIDNotIn(vs ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldUserID, vs...)) } // KeyEQ applies the EQ predicate on the "key" field. -func KeyEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldKey, v)) +func KeyEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldKey, v)) } // KeyNEQ applies the NEQ predicate on the "key" field. -func KeyNEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldKey, v)) +func KeyNEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldKey, v)) } // KeyIn applies the In predicate on the "key" field. -func KeyIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldKey, vs...)) +func KeyIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldKey, vs...)) } // KeyNotIn applies the NotIn predicate on the "key" field. -func KeyNotIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldKey, vs...)) +func KeyNotIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldKey, vs...)) } // KeyGT applies the GT predicate on the "key" field. -func KeyGT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldKey, v)) +func KeyGT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldKey, v)) } // KeyGTE applies the GTE predicate on the "key" field. -func KeyGTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldKey, v)) +func KeyGTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldKey, v)) } // KeyLT applies the LT predicate on the "key" field. -func KeyLT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldKey, v)) +func KeyLT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldKey, v)) } // KeyLTE applies the LTE predicate on the "key" field. -func KeyLTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldKey, v)) +func KeyLTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldKey, v)) } // KeyContains applies the Contains predicate on the "key" field. -func KeyContains(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContains(FieldKey, v)) +func KeyContains(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContains(FieldKey, v)) } // KeyHasPrefix applies the HasPrefix predicate on the "key" field. -func KeyHasPrefix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasPrefix(FieldKey, v)) +func KeyHasPrefix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasPrefix(FieldKey, v)) } // KeyHasSuffix applies the HasSuffix predicate on the "key" field. -func KeyHasSuffix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasSuffix(FieldKey, v)) +func KeyHasSuffix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasSuffix(FieldKey, v)) } // KeyEqualFold applies the EqualFold predicate on the "key" field. -func KeyEqualFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEqualFold(FieldKey, v)) +func KeyEqualFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEqualFold(FieldKey, v)) } // KeyContainsFold applies the ContainsFold predicate on the "key" field. -func KeyContainsFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContainsFold(FieldKey, v)) +func KeyContainsFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContainsFold(FieldKey, v)) } // NameEQ applies the EQ predicate on the "name" field. -func NameEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldName, v)) +func NameEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldName, v)) } // NameNEQ applies the NEQ predicate on the "name" field. -func NameNEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldName, v)) +func NameNEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldName, v)) } // NameIn applies the In predicate on the "name" field. -func NameIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldName, vs...)) +func NameIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldName, vs...)) } // NameNotIn applies the NotIn predicate on the "name" field. -func NameNotIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldName, vs...)) +func NameNotIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldName, vs...)) } // NameGT applies the GT predicate on the "name" field. -func NameGT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldName, v)) +func NameGT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldName, v)) } // NameGTE applies the GTE predicate on the "name" field. -func NameGTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldName, v)) +func NameGTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldName, v)) } // NameLT applies the LT predicate on the "name" field. -func NameLT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldName, v)) +func NameLT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldName, v)) } // NameLTE applies the LTE predicate on the "name" field. -func NameLTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldName, v)) +func NameLTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldName, v)) } // NameContains applies the Contains predicate on the "name" field. -func NameContains(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContains(FieldName, v)) +func NameContains(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContains(FieldName, v)) } // NameHasPrefix applies the HasPrefix predicate on the "name" field. -func NameHasPrefix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasPrefix(FieldName, v)) +func NameHasPrefix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasPrefix(FieldName, v)) } // NameHasSuffix applies the HasSuffix predicate on the "name" field. -func NameHasSuffix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasSuffix(FieldName, v)) +func NameHasSuffix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasSuffix(FieldName, v)) } // NameEqualFold applies the EqualFold predicate on the "name" field. -func NameEqualFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEqualFold(FieldName, v)) +func NameEqualFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEqualFold(FieldName, v)) } // NameContainsFold applies the ContainsFold predicate on the "name" field. -func NameContainsFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContainsFold(FieldName, v)) +func NameContainsFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContainsFold(FieldName, v)) } // GroupIDEQ applies the EQ predicate on the "group_id" field. -func GroupIDEQ(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldGroupID, v)) +func GroupIDEQ(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldGroupID, v)) } // GroupIDNEQ applies the NEQ predicate on the "group_id" field. -func GroupIDNEQ(v int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldGroupID, v)) +func GroupIDNEQ(v int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldGroupID, v)) } // GroupIDIn applies the In predicate on the "group_id" field. -func GroupIDIn(vs ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldGroupID, vs...)) +func GroupIDIn(vs ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldGroupID, vs...)) } // GroupIDNotIn applies the NotIn predicate on the "group_id" field. -func GroupIDNotIn(vs ...int64) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldGroupID, vs...)) +func GroupIDNotIn(vs ...int64) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldGroupID, vs...)) } // GroupIDIsNil applies the IsNil predicate on the "group_id" field. -func GroupIDIsNil() predicate.ApiKey { - return predicate.ApiKey(sql.FieldIsNull(FieldGroupID)) +func GroupIDIsNil() predicate.APIKey { + return predicate.APIKey(sql.FieldIsNull(FieldGroupID)) } // GroupIDNotNil applies the NotNil predicate on the "group_id" field. -func GroupIDNotNil() predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotNull(FieldGroupID)) +func GroupIDNotNil() predicate.APIKey { + return predicate.APIKey(sql.FieldNotNull(FieldGroupID)) } // StatusEQ applies the EQ predicate on the "status" field. -func StatusEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEQ(FieldStatus, v)) +func StatusEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEQ(FieldStatus, v)) } // StatusNEQ applies the NEQ predicate on the "status" field. -func StatusNEQ(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNEQ(FieldStatus, v)) +func StatusNEQ(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldNEQ(FieldStatus, v)) } // StatusIn applies the In predicate on the "status" field. -func StatusIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldIn(FieldStatus, vs...)) +func StatusIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldIn(FieldStatus, vs...)) } // StatusNotIn applies the NotIn predicate on the "status" field. -func StatusNotIn(vs ...string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldNotIn(FieldStatus, vs...)) +func StatusNotIn(vs ...string) predicate.APIKey { + return predicate.APIKey(sql.FieldNotIn(FieldStatus, vs...)) } // StatusGT applies the GT predicate on the "status" field. -func StatusGT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGT(FieldStatus, v)) +func StatusGT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGT(FieldStatus, v)) } // StatusGTE applies the GTE predicate on the "status" field. -func StatusGTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldGTE(FieldStatus, v)) +func StatusGTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldGTE(FieldStatus, v)) } // StatusLT applies the LT predicate on the "status" field. -func StatusLT(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLT(FieldStatus, v)) +func StatusLT(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLT(FieldStatus, v)) } // StatusLTE applies the LTE predicate on the "status" field. -func StatusLTE(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldLTE(FieldStatus, v)) +func StatusLTE(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldLTE(FieldStatus, v)) } // StatusContains applies the Contains predicate on the "status" field. -func StatusContains(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContains(FieldStatus, v)) +func StatusContains(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContains(FieldStatus, v)) } // StatusHasPrefix applies the HasPrefix predicate on the "status" field. -func StatusHasPrefix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasPrefix(FieldStatus, v)) +func StatusHasPrefix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasPrefix(FieldStatus, v)) } // StatusHasSuffix applies the HasSuffix predicate on the "status" field. -func StatusHasSuffix(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldHasSuffix(FieldStatus, v)) +func StatusHasSuffix(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldHasSuffix(FieldStatus, v)) } // StatusEqualFold applies the EqualFold predicate on the "status" field. -func StatusEqualFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldEqualFold(FieldStatus, v)) +func StatusEqualFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldEqualFold(FieldStatus, v)) } // StatusContainsFold applies the ContainsFold predicate on the "status" field. -func StatusContainsFold(v string) predicate.ApiKey { - return predicate.ApiKey(sql.FieldContainsFold(FieldStatus, v)) +func StatusContainsFold(v string) predicate.APIKey { + return predicate.APIKey(sql.FieldContainsFold(FieldStatus, v)) } // HasUser applies the HasEdge predicate on the "user" edge. -func HasUser() predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasUser() predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := sqlgraph.NewStep( sqlgraph.From(Table, FieldID), sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), @@ -482,8 +482,8 @@ func HasUser() predicate.ApiKey { } // HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). -func HasUserWith(preds ...predicate.User) predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasUserWith(preds ...predicate.User) predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := newUserStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { for _, p := range preds { @@ -494,8 +494,8 @@ func HasUserWith(preds ...predicate.User) predicate.ApiKey { } // HasGroup applies the HasEdge predicate on the "group" edge. -func HasGroup() predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasGroup() predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := sqlgraph.NewStep( sqlgraph.From(Table, FieldID), sqlgraph.Edge(sqlgraph.M2O, true, GroupTable, GroupColumn), @@ -505,8 +505,8 @@ func HasGroup() predicate.ApiKey { } // HasGroupWith applies the HasEdge predicate on the "group" edge with a given conditions (other predicates). -func HasGroupWith(preds ...predicate.Group) predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasGroupWith(preds ...predicate.Group) predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := newGroupStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { for _, p := range preds { @@ -517,8 +517,8 @@ func HasGroupWith(preds ...predicate.Group) predicate.ApiKey { } // HasUsageLogs applies the HasEdge predicate on the "usage_logs" edge. -func HasUsageLogs() predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasUsageLogs() predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := sqlgraph.NewStep( sqlgraph.From(Table, FieldID), sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn), @@ -528,8 +528,8 @@ func HasUsageLogs() predicate.ApiKey { } // HasUsageLogsWith applies the HasEdge predicate on the "usage_logs" edge with a given conditions (other predicates). -func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.ApiKey { - return predicate.ApiKey(func(s *sql.Selector) { +func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { step := newUsageLogsStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { for _, p := range preds { @@ -540,16 +540,16 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.ApiKey { } // And groups predicates with the AND operator between them. -func And(predicates ...predicate.ApiKey) predicate.ApiKey { - return predicate.ApiKey(sql.AndPredicates(predicates...)) +func And(predicates ...predicate.APIKey) predicate.APIKey { + return predicate.APIKey(sql.AndPredicates(predicates...)) } // Or groups predicates with the OR operator between them. -func Or(predicates ...predicate.ApiKey) predicate.ApiKey { - return predicate.ApiKey(sql.OrPredicates(predicates...)) +func Or(predicates ...predicate.APIKey) predicate.APIKey { + return predicate.APIKey(sql.OrPredicates(predicates...)) } // Not applies the not operator on the given predicate. -func Not(p predicate.ApiKey) predicate.ApiKey { - return predicate.ApiKey(sql.NotPredicates(p)) +func Not(p predicate.APIKey) predicate.APIKey { + return predicate.APIKey(sql.NotPredicates(p)) } diff --git a/backend/ent/apikey_create.go b/backend/ent/apikey_create.go index 5b984b21..2098872c 100644 --- a/backend/ent/apikey_create.go +++ b/backend/ent/apikey_create.go @@ -17,22 +17,22 @@ import ( "github.com/Wei-Shaw/sub2api/ent/user" ) -// ApiKeyCreate is the builder for creating a ApiKey entity. -type ApiKeyCreate struct { +// APIKeyCreate is the builder for creating a APIKey entity. +type APIKeyCreate struct { config - mutation *ApiKeyMutation + mutation *APIKeyMutation hooks []Hook conflict []sql.ConflictOption } // SetCreatedAt sets the "created_at" field. -func (_c *ApiKeyCreate) SetCreatedAt(v time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetCreatedAt(v time.Time) *APIKeyCreate { _c.mutation.SetCreatedAt(v) return _c } // SetNillableCreatedAt sets the "created_at" field if the given value is not nil. -func (_c *ApiKeyCreate) SetNillableCreatedAt(v *time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetNillableCreatedAt(v *time.Time) *APIKeyCreate { if v != nil { _c.SetCreatedAt(*v) } @@ -40,13 +40,13 @@ func (_c *ApiKeyCreate) SetNillableCreatedAt(v *time.Time) *ApiKeyCreate { } // SetUpdatedAt sets the "updated_at" field. -func (_c *ApiKeyCreate) SetUpdatedAt(v time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetUpdatedAt(v time.Time) *APIKeyCreate { _c.mutation.SetUpdatedAt(v) return _c } // SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. -func (_c *ApiKeyCreate) SetNillableUpdatedAt(v *time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetNillableUpdatedAt(v *time.Time) *APIKeyCreate { if v != nil { _c.SetUpdatedAt(*v) } @@ -54,13 +54,13 @@ func (_c *ApiKeyCreate) SetNillableUpdatedAt(v *time.Time) *ApiKeyCreate { } // SetDeletedAt sets the "deleted_at" field. -func (_c *ApiKeyCreate) SetDeletedAt(v time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetDeletedAt(v time.Time) *APIKeyCreate { _c.mutation.SetDeletedAt(v) return _c } // SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. -func (_c *ApiKeyCreate) SetNillableDeletedAt(v *time.Time) *ApiKeyCreate { +func (_c *APIKeyCreate) SetNillableDeletedAt(v *time.Time) *APIKeyCreate { if v != nil { _c.SetDeletedAt(*v) } @@ -68,31 +68,31 @@ func (_c *ApiKeyCreate) SetNillableDeletedAt(v *time.Time) *ApiKeyCreate { } // SetUserID sets the "user_id" field. -func (_c *ApiKeyCreate) SetUserID(v int64) *ApiKeyCreate { +func (_c *APIKeyCreate) SetUserID(v int64) *APIKeyCreate { _c.mutation.SetUserID(v) return _c } // SetKey sets the "key" field. -func (_c *ApiKeyCreate) SetKey(v string) *ApiKeyCreate { +func (_c *APIKeyCreate) SetKey(v string) *APIKeyCreate { _c.mutation.SetKey(v) return _c } // SetName sets the "name" field. -func (_c *ApiKeyCreate) SetName(v string) *ApiKeyCreate { +func (_c *APIKeyCreate) SetName(v string) *APIKeyCreate { _c.mutation.SetName(v) return _c } // SetGroupID sets the "group_id" field. -func (_c *ApiKeyCreate) SetGroupID(v int64) *ApiKeyCreate { +func (_c *APIKeyCreate) SetGroupID(v int64) *APIKeyCreate { _c.mutation.SetGroupID(v) return _c } // SetNillableGroupID sets the "group_id" field if the given value is not nil. -func (_c *ApiKeyCreate) SetNillableGroupID(v *int64) *ApiKeyCreate { +func (_c *APIKeyCreate) SetNillableGroupID(v *int64) *APIKeyCreate { if v != nil { _c.SetGroupID(*v) } @@ -100,13 +100,13 @@ func (_c *ApiKeyCreate) SetNillableGroupID(v *int64) *ApiKeyCreate { } // SetStatus sets the "status" field. -func (_c *ApiKeyCreate) SetStatus(v string) *ApiKeyCreate { +func (_c *APIKeyCreate) SetStatus(v string) *APIKeyCreate { _c.mutation.SetStatus(v) return _c } // SetNillableStatus sets the "status" field if the given value is not nil. -func (_c *ApiKeyCreate) SetNillableStatus(v *string) *ApiKeyCreate { +func (_c *APIKeyCreate) SetNillableStatus(v *string) *APIKeyCreate { if v != nil { _c.SetStatus(*v) } @@ -114,23 +114,23 @@ func (_c *ApiKeyCreate) SetNillableStatus(v *string) *ApiKeyCreate { } // SetUser sets the "user" edge to the User entity. -func (_c *ApiKeyCreate) SetUser(v *User) *ApiKeyCreate { +func (_c *APIKeyCreate) SetUser(v *User) *APIKeyCreate { return _c.SetUserID(v.ID) } // SetGroup sets the "group" edge to the Group entity. -func (_c *ApiKeyCreate) SetGroup(v *Group) *ApiKeyCreate { +func (_c *APIKeyCreate) SetGroup(v *Group) *APIKeyCreate { return _c.SetGroupID(v.ID) } // AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs. -func (_c *ApiKeyCreate) AddUsageLogIDs(ids ...int64) *ApiKeyCreate { +func (_c *APIKeyCreate) AddUsageLogIDs(ids ...int64) *APIKeyCreate { _c.mutation.AddUsageLogIDs(ids...) return _c } // AddUsageLogs adds the "usage_logs" edges to the UsageLog entity. -func (_c *ApiKeyCreate) AddUsageLogs(v ...*UsageLog) *ApiKeyCreate { +func (_c *APIKeyCreate) AddUsageLogs(v ...*UsageLog) *APIKeyCreate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -138,13 +138,13 @@ func (_c *ApiKeyCreate) AddUsageLogs(v ...*UsageLog) *ApiKeyCreate { return _c.AddUsageLogIDs(ids...) } -// Mutation returns the ApiKeyMutation object of the builder. -func (_c *ApiKeyCreate) Mutation() *ApiKeyMutation { +// Mutation returns the APIKeyMutation object of the builder. +func (_c *APIKeyCreate) Mutation() *APIKeyMutation { return _c.mutation } -// Save creates the ApiKey in the database. -func (_c *ApiKeyCreate) Save(ctx context.Context) (*ApiKey, error) { +// Save creates the APIKey in the database. +func (_c *APIKeyCreate) Save(ctx context.Context) (*APIKey, error) { if err := _c.defaults(); err != nil { return nil, err } @@ -152,7 +152,7 @@ func (_c *ApiKeyCreate) Save(ctx context.Context) (*ApiKey, error) { } // SaveX calls Save and panics if Save returns an error. -func (_c *ApiKeyCreate) SaveX(ctx context.Context) *ApiKey { +func (_c *APIKeyCreate) SaveX(ctx context.Context) *APIKey { v, err := _c.Save(ctx) if err != nil { panic(err) @@ -161,20 +161,20 @@ func (_c *ApiKeyCreate) SaveX(ctx context.Context) *ApiKey { } // Exec executes the query. -func (_c *ApiKeyCreate) Exec(ctx context.Context) error { +func (_c *APIKeyCreate) Exec(ctx context.Context) error { _, err := _c.Save(ctx) return err } // ExecX is like Exec, but panics if an error occurs. -func (_c *ApiKeyCreate) ExecX(ctx context.Context) { +func (_c *APIKeyCreate) ExecX(ctx context.Context) { if err := _c.Exec(ctx); err != nil { panic(err) } } // defaults sets the default values of the builder before save. -func (_c *ApiKeyCreate) defaults() error { +func (_c *APIKeyCreate) defaults() error { if _, ok := _c.mutation.CreatedAt(); !ok { if apikey.DefaultCreatedAt == nil { return fmt.Errorf("ent: uninitialized apikey.DefaultCreatedAt (forgotten import ent/runtime?)") @@ -197,47 +197,47 @@ func (_c *ApiKeyCreate) defaults() error { } // check runs all checks and user-defined validators on the builder. -func (_c *ApiKeyCreate) check() error { +func (_c *APIKeyCreate) check() error { if _, ok := _c.mutation.CreatedAt(); !ok { - return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "ApiKey.created_at"`)} + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "APIKey.created_at"`)} } if _, ok := _c.mutation.UpdatedAt(); !ok { - return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "ApiKey.updated_at"`)} + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "APIKey.updated_at"`)} } if _, ok := _c.mutation.UserID(); !ok { - return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "ApiKey.user_id"`)} + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "APIKey.user_id"`)} } if _, ok := _c.mutation.Key(); !ok { - return &ValidationError{Name: "key", err: errors.New(`ent: missing required field "ApiKey.key"`)} + return &ValidationError{Name: "key", err: errors.New(`ent: missing required field "APIKey.key"`)} } if v, ok := _c.mutation.Key(); ok { if err := apikey.KeyValidator(v); err != nil { - return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "ApiKey.key": %w`, err)} + return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)} } } if _, ok := _c.mutation.Name(); !ok { - return &ValidationError{Name: "name", err: errors.New(`ent: missing required field "ApiKey.name"`)} + return &ValidationError{Name: "name", err: errors.New(`ent: missing required field "APIKey.name"`)} } if v, ok := _c.mutation.Name(); ok { if err := apikey.NameValidator(v); err != nil { - return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "ApiKey.name": %w`, err)} + return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)} } } if _, ok := _c.mutation.Status(); !ok { - return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "ApiKey.status"`)} + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "APIKey.status"`)} } if v, ok := _c.mutation.Status(); ok { if err := apikey.StatusValidator(v); err != nil { - return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ApiKey.status": %w`, err)} + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "APIKey.status": %w`, err)} } } if len(_c.mutation.UserIDs()) == 0 { - return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "ApiKey.user"`)} + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "APIKey.user"`)} } return nil } -func (_c *ApiKeyCreate) sqlSave(ctx context.Context) (*ApiKey, error) { +func (_c *APIKeyCreate) sqlSave(ctx context.Context) (*APIKey, error) { if err := _c.check(); err != nil { return nil, err } @@ -255,9 +255,9 @@ func (_c *ApiKeyCreate) sqlSave(ctx context.Context) (*ApiKey, error) { return _node, nil } -func (_c *ApiKeyCreate) createSpec() (*ApiKey, *sqlgraph.CreateSpec) { +func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) { var ( - _node = &ApiKey{config: _c.config} + _node = &APIKey{config: _c.config} _spec = sqlgraph.NewCreateSpec(apikey.Table, sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64)) ) _spec.OnConflict = _c.conflict @@ -341,7 +341,7 @@ func (_c *ApiKeyCreate) createSpec() (*ApiKey, *sqlgraph.CreateSpec) { // OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause // of the `INSERT` statement. For example: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // SetCreatedAt(v). // OnConflict( // // Update the row with the new values @@ -350,13 +350,13 @@ func (_c *ApiKeyCreate) createSpec() (*ApiKey, *sqlgraph.CreateSpec) { // ). // // Override some of the fields with custom // // update values. -// Update(func(u *ent.ApiKeyUpsert) { +// Update(func(u *ent.APIKeyUpsert) { // SetCreatedAt(v+v). // }). // Exec(ctx) -func (_c *ApiKeyCreate) OnConflict(opts ...sql.ConflictOption) *ApiKeyUpsertOne { +func (_c *APIKeyCreate) OnConflict(opts ...sql.ConflictOption) *APIKeyUpsertOne { _c.conflict = opts - return &ApiKeyUpsertOne{ + return &APIKeyUpsertOne{ create: _c, } } @@ -364,121 +364,121 @@ func (_c *ApiKeyCreate) OnConflict(opts ...sql.ConflictOption) *ApiKeyUpsertOne // OnConflictColumns calls `OnConflict` and configures the columns // as conflict target. Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict(sql.ConflictColumns(columns...)). // Exec(ctx) -func (_c *ApiKeyCreate) OnConflictColumns(columns ...string) *ApiKeyUpsertOne { +func (_c *APIKeyCreate) OnConflictColumns(columns ...string) *APIKeyUpsertOne { _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) - return &ApiKeyUpsertOne{ + return &APIKeyUpsertOne{ create: _c, } } type ( - // ApiKeyUpsertOne is the builder for "upsert"-ing - // one ApiKey node. - ApiKeyUpsertOne struct { - create *ApiKeyCreate + // APIKeyUpsertOne is the builder for "upsert"-ing + // one APIKey node. + APIKeyUpsertOne struct { + create *APIKeyCreate } - // ApiKeyUpsert is the "OnConflict" setter. - ApiKeyUpsert struct { + // APIKeyUpsert is the "OnConflict" setter. + APIKeyUpsert struct { *sql.UpdateSet } ) // SetUpdatedAt sets the "updated_at" field. -func (u *ApiKeyUpsert) SetUpdatedAt(v time.Time) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetUpdatedAt(v time.Time) *APIKeyUpsert { u.Set(apikey.FieldUpdatedAt, v) return u } // UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateUpdatedAt() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateUpdatedAt() *APIKeyUpsert { u.SetExcluded(apikey.FieldUpdatedAt) return u } // SetDeletedAt sets the "deleted_at" field. -func (u *ApiKeyUpsert) SetDeletedAt(v time.Time) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetDeletedAt(v time.Time) *APIKeyUpsert { u.Set(apikey.FieldDeletedAt, v) return u } // UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateDeletedAt() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateDeletedAt() *APIKeyUpsert { u.SetExcluded(apikey.FieldDeletedAt) return u } // ClearDeletedAt clears the value of the "deleted_at" field. -func (u *ApiKeyUpsert) ClearDeletedAt() *ApiKeyUpsert { +func (u *APIKeyUpsert) ClearDeletedAt() *APIKeyUpsert { u.SetNull(apikey.FieldDeletedAt) return u } // SetUserID sets the "user_id" field. -func (u *ApiKeyUpsert) SetUserID(v int64) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetUserID(v int64) *APIKeyUpsert { u.Set(apikey.FieldUserID, v) return u } // UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateUserID() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateUserID() *APIKeyUpsert { u.SetExcluded(apikey.FieldUserID) return u } // SetKey sets the "key" field. -func (u *ApiKeyUpsert) SetKey(v string) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetKey(v string) *APIKeyUpsert { u.Set(apikey.FieldKey, v) return u } // UpdateKey sets the "key" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateKey() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateKey() *APIKeyUpsert { u.SetExcluded(apikey.FieldKey) return u } // SetName sets the "name" field. -func (u *ApiKeyUpsert) SetName(v string) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetName(v string) *APIKeyUpsert { u.Set(apikey.FieldName, v) return u } // UpdateName sets the "name" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateName() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateName() *APIKeyUpsert { u.SetExcluded(apikey.FieldName) return u } // SetGroupID sets the "group_id" field. -func (u *ApiKeyUpsert) SetGroupID(v int64) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetGroupID(v int64) *APIKeyUpsert { u.Set(apikey.FieldGroupID, v) return u } // UpdateGroupID sets the "group_id" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateGroupID() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateGroupID() *APIKeyUpsert { u.SetExcluded(apikey.FieldGroupID) return u } // ClearGroupID clears the value of the "group_id" field. -func (u *ApiKeyUpsert) ClearGroupID() *ApiKeyUpsert { +func (u *APIKeyUpsert) ClearGroupID() *APIKeyUpsert { u.SetNull(apikey.FieldGroupID) return u } // SetStatus sets the "status" field. -func (u *ApiKeyUpsert) SetStatus(v string) *ApiKeyUpsert { +func (u *APIKeyUpsert) SetStatus(v string) *APIKeyUpsert { u.Set(apikey.FieldStatus, v) return u } // UpdateStatus sets the "status" field to the value that was provided on create. -func (u *ApiKeyUpsert) UpdateStatus() *ApiKeyUpsert { +func (u *APIKeyUpsert) UpdateStatus() *APIKeyUpsert { u.SetExcluded(apikey.FieldStatus) return u } @@ -486,12 +486,12 @@ func (u *ApiKeyUpsert) UpdateStatus() *ApiKeyUpsert { // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict( // sql.ResolveWithNewValues(), // ). // Exec(ctx) -func (u *ApiKeyUpsertOne) UpdateNewValues() *ApiKeyUpsertOne { +func (u *APIKeyUpsertOne) UpdateNewValues() *APIKeyUpsertOne { u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { if _, exists := u.create.mutation.CreatedAt(); exists { @@ -504,159 +504,159 @@ func (u *ApiKeyUpsertOne) UpdateNewValues() *ApiKeyUpsertOne { // Ignore sets each column to itself in case of conflict. // Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict(sql.ResolveWithIgnore()). // Exec(ctx) -func (u *ApiKeyUpsertOne) Ignore() *ApiKeyUpsertOne { +func (u *APIKeyUpsertOne) Ignore() *APIKeyUpsertOne { u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) return u } // DoNothing configures the conflict_action to `DO NOTHING`. // Supported only by SQLite and PostgreSQL. -func (u *ApiKeyUpsertOne) DoNothing() *ApiKeyUpsertOne { +func (u *APIKeyUpsertOne) DoNothing() *APIKeyUpsertOne { u.create.conflict = append(u.create.conflict, sql.DoNothing()) return u } -// Update allows overriding fields `UPDATE` values. See the ApiKeyCreate.OnConflict +// Update allows overriding fields `UPDATE` values. See the APIKeyCreate.OnConflict // documentation for more info. -func (u *ApiKeyUpsertOne) Update(set func(*ApiKeyUpsert)) *ApiKeyUpsertOne { +func (u *APIKeyUpsertOne) Update(set func(*APIKeyUpsert)) *APIKeyUpsertOne { u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { - set(&ApiKeyUpsert{UpdateSet: update}) + set(&APIKeyUpsert{UpdateSet: update}) })) return u } // SetUpdatedAt sets the "updated_at" field. -func (u *ApiKeyUpsertOne) SetUpdatedAt(v time.Time) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetUpdatedAt(v time.Time) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetUpdatedAt(v) }) } // UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateUpdatedAt() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateUpdatedAt() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateUpdatedAt() }) } // SetDeletedAt sets the "deleted_at" field. -func (u *ApiKeyUpsertOne) SetDeletedAt(v time.Time) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetDeletedAt(v time.Time) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetDeletedAt(v) }) } // UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateDeletedAt() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateDeletedAt() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateDeletedAt() }) } // ClearDeletedAt clears the value of the "deleted_at" field. -func (u *ApiKeyUpsertOne) ClearDeletedAt() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) ClearDeletedAt() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.ClearDeletedAt() }) } // SetUserID sets the "user_id" field. -func (u *ApiKeyUpsertOne) SetUserID(v int64) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetUserID(v int64) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetUserID(v) }) } // UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateUserID() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateUserID() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateUserID() }) } // SetKey sets the "key" field. -func (u *ApiKeyUpsertOne) SetKey(v string) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetKey(v string) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetKey(v) }) } // UpdateKey sets the "key" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateKey() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateKey() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateKey() }) } // SetName sets the "name" field. -func (u *ApiKeyUpsertOne) SetName(v string) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetName(v string) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetName(v) }) } // UpdateName sets the "name" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateName() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateName() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateName() }) } // SetGroupID sets the "group_id" field. -func (u *ApiKeyUpsertOne) SetGroupID(v int64) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetGroupID(v int64) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetGroupID(v) }) } // UpdateGroupID sets the "group_id" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateGroupID() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateGroupID() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateGroupID() }) } // ClearGroupID clears the value of the "group_id" field. -func (u *ApiKeyUpsertOne) ClearGroupID() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) ClearGroupID() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.ClearGroupID() }) } // SetStatus sets the "status" field. -func (u *ApiKeyUpsertOne) SetStatus(v string) *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) SetStatus(v string) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.SetStatus(v) }) } // UpdateStatus sets the "status" field to the value that was provided on create. -func (u *ApiKeyUpsertOne) UpdateStatus() *ApiKeyUpsertOne { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertOne) UpdateStatus() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { s.UpdateStatus() }) } // Exec executes the query. -func (u *ApiKeyUpsertOne) Exec(ctx context.Context) error { +func (u *APIKeyUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { - return errors.New("ent: missing options for ApiKeyCreate.OnConflict") + return errors.New("ent: missing options for APIKeyCreate.OnConflict") } return u.create.Exec(ctx) } // ExecX is like Exec, but panics if an error occurs. -func (u *ApiKeyUpsertOne) ExecX(ctx context.Context) { +func (u *APIKeyUpsertOne) ExecX(ctx context.Context) { if err := u.create.Exec(ctx); err != nil { panic(err) } } // Exec executes the UPSERT query and returns the inserted/updated ID. -func (u *ApiKeyUpsertOne) ID(ctx context.Context) (id int64, err error) { +func (u *APIKeyUpsertOne) ID(ctx context.Context) (id int64, err error) { node, err := u.create.Save(ctx) if err != nil { return id, err @@ -665,7 +665,7 @@ func (u *ApiKeyUpsertOne) ID(ctx context.Context) (id int64, err error) { } // IDX is like ID, but panics if an error occurs. -func (u *ApiKeyUpsertOne) IDX(ctx context.Context) int64 { +func (u *APIKeyUpsertOne) IDX(ctx context.Context) int64 { id, err := u.ID(ctx) if err != nil { panic(err) @@ -673,28 +673,28 @@ func (u *ApiKeyUpsertOne) IDX(ctx context.Context) int64 { return id } -// ApiKeyCreateBulk is the builder for creating many ApiKey entities in bulk. -type ApiKeyCreateBulk struct { +// APIKeyCreateBulk is the builder for creating many APIKey entities in bulk. +type APIKeyCreateBulk struct { config err error - builders []*ApiKeyCreate + builders []*APIKeyCreate conflict []sql.ConflictOption } -// Save creates the ApiKey entities in the database. -func (_c *ApiKeyCreateBulk) Save(ctx context.Context) ([]*ApiKey, error) { +// Save creates the APIKey entities in the database. +func (_c *APIKeyCreateBulk) Save(ctx context.Context) ([]*APIKey, error) { if _c.err != nil { return nil, _c.err } specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) - nodes := make([]*ApiKey, len(_c.builders)) + nodes := make([]*APIKey, len(_c.builders)) mutators := make([]Mutator, len(_c.builders)) for i := range _c.builders { func(i int, root context.Context) { builder := _c.builders[i] builder.defaults() var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { - mutation, ok := m.(*ApiKeyMutation) + mutation, ok := m.(*APIKeyMutation) if !ok { return nil, fmt.Errorf("unexpected mutation type %T", m) } @@ -742,7 +742,7 @@ func (_c *ApiKeyCreateBulk) Save(ctx context.Context) ([]*ApiKey, error) { } // SaveX is like Save, but panics if an error occurs. -func (_c *ApiKeyCreateBulk) SaveX(ctx context.Context) []*ApiKey { +func (_c *APIKeyCreateBulk) SaveX(ctx context.Context) []*APIKey { v, err := _c.Save(ctx) if err != nil { panic(err) @@ -751,13 +751,13 @@ func (_c *ApiKeyCreateBulk) SaveX(ctx context.Context) []*ApiKey { } // Exec executes the query. -func (_c *ApiKeyCreateBulk) Exec(ctx context.Context) error { +func (_c *APIKeyCreateBulk) Exec(ctx context.Context) error { _, err := _c.Save(ctx) return err } // ExecX is like Exec, but panics if an error occurs. -func (_c *ApiKeyCreateBulk) ExecX(ctx context.Context) { +func (_c *APIKeyCreateBulk) ExecX(ctx context.Context) { if err := _c.Exec(ctx); err != nil { panic(err) } @@ -766,7 +766,7 @@ func (_c *ApiKeyCreateBulk) ExecX(ctx context.Context) { // OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause // of the `INSERT` statement. For example: // -// client.ApiKey.CreateBulk(builders...). +// client.APIKey.CreateBulk(builders...). // OnConflict( // // Update the row with the new values // // the was proposed for insertion. @@ -774,13 +774,13 @@ func (_c *ApiKeyCreateBulk) ExecX(ctx context.Context) { // ). // // Override some of the fields with custom // // update values. -// Update(func(u *ent.ApiKeyUpsert) { +// Update(func(u *ent.APIKeyUpsert) { // SetCreatedAt(v+v). // }). // Exec(ctx) -func (_c *ApiKeyCreateBulk) OnConflict(opts ...sql.ConflictOption) *ApiKeyUpsertBulk { +func (_c *APIKeyCreateBulk) OnConflict(opts ...sql.ConflictOption) *APIKeyUpsertBulk { _c.conflict = opts - return &ApiKeyUpsertBulk{ + return &APIKeyUpsertBulk{ create: _c, } } @@ -788,31 +788,31 @@ func (_c *ApiKeyCreateBulk) OnConflict(opts ...sql.ConflictOption) *ApiKeyUpsert // OnConflictColumns calls `OnConflict` and configures the columns // as conflict target. Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict(sql.ConflictColumns(columns...)). // Exec(ctx) -func (_c *ApiKeyCreateBulk) OnConflictColumns(columns ...string) *ApiKeyUpsertBulk { +func (_c *APIKeyCreateBulk) OnConflictColumns(columns ...string) *APIKeyUpsertBulk { _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) - return &ApiKeyUpsertBulk{ + return &APIKeyUpsertBulk{ create: _c, } } -// ApiKeyUpsertBulk is the builder for "upsert"-ing -// a bulk of ApiKey nodes. -type ApiKeyUpsertBulk struct { - create *ApiKeyCreateBulk +// APIKeyUpsertBulk is the builder for "upsert"-ing +// a bulk of APIKey nodes. +type APIKeyUpsertBulk struct { + create *APIKeyCreateBulk } // UpdateNewValues updates the mutable fields using the new values that // were set on create. Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict( // sql.ResolveWithNewValues(), // ). // Exec(ctx) -func (u *ApiKeyUpsertBulk) UpdateNewValues() *ApiKeyUpsertBulk { +func (u *APIKeyUpsertBulk) UpdateNewValues() *APIKeyUpsertBulk { u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { for _, b := range u.create.builders { @@ -827,160 +827,160 @@ func (u *ApiKeyUpsertBulk) UpdateNewValues() *ApiKeyUpsertBulk { // Ignore sets each column to itself in case of conflict. // Using this option is equivalent to using: // -// client.ApiKey.Create(). +// client.APIKey.Create(). // OnConflict(sql.ResolveWithIgnore()). // Exec(ctx) -func (u *ApiKeyUpsertBulk) Ignore() *ApiKeyUpsertBulk { +func (u *APIKeyUpsertBulk) Ignore() *APIKeyUpsertBulk { u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) return u } // DoNothing configures the conflict_action to `DO NOTHING`. // Supported only by SQLite and PostgreSQL. -func (u *ApiKeyUpsertBulk) DoNothing() *ApiKeyUpsertBulk { +func (u *APIKeyUpsertBulk) DoNothing() *APIKeyUpsertBulk { u.create.conflict = append(u.create.conflict, sql.DoNothing()) return u } -// Update allows overriding fields `UPDATE` values. See the ApiKeyCreateBulk.OnConflict +// Update allows overriding fields `UPDATE` values. See the APIKeyCreateBulk.OnConflict // documentation for more info. -func (u *ApiKeyUpsertBulk) Update(set func(*ApiKeyUpsert)) *ApiKeyUpsertBulk { +func (u *APIKeyUpsertBulk) Update(set func(*APIKeyUpsert)) *APIKeyUpsertBulk { u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { - set(&ApiKeyUpsert{UpdateSet: update}) + set(&APIKeyUpsert{UpdateSet: update}) })) return u } // SetUpdatedAt sets the "updated_at" field. -func (u *ApiKeyUpsertBulk) SetUpdatedAt(v time.Time) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetUpdatedAt(v time.Time) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetUpdatedAt(v) }) } // UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateUpdatedAt() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateUpdatedAt() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateUpdatedAt() }) } // SetDeletedAt sets the "deleted_at" field. -func (u *ApiKeyUpsertBulk) SetDeletedAt(v time.Time) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetDeletedAt(v time.Time) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetDeletedAt(v) }) } // UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateDeletedAt() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateDeletedAt() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateDeletedAt() }) } // ClearDeletedAt clears the value of the "deleted_at" field. -func (u *ApiKeyUpsertBulk) ClearDeletedAt() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) ClearDeletedAt() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.ClearDeletedAt() }) } // SetUserID sets the "user_id" field. -func (u *ApiKeyUpsertBulk) SetUserID(v int64) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetUserID(v int64) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetUserID(v) }) } // UpdateUserID sets the "user_id" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateUserID() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateUserID() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateUserID() }) } // SetKey sets the "key" field. -func (u *ApiKeyUpsertBulk) SetKey(v string) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetKey(v string) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetKey(v) }) } // UpdateKey sets the "key" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateKey() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateKey() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateKey() }) } // SetName sets the "name" field. -func (u *ApiKeyUpsertBulk) SetName(v string) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetName(v string) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetName(v) }) } // UpdateName sets the "name" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateName() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateName() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateName() }) } // SetGroupID sets the "group_id" field. -func (u *ApiKeyUpsertBulk) SetGroupID(v int64) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetGroupID(v int64) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetGroupID(v) }) } // UpdateGroupID sets the "group_id" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateGroupID() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateGroupID() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateGroupID() }) } // ClearGroupID clears the value of the "group_id" field. -func (u *ApiKeyUpsertBulk) ClearGroupID() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) ClearGroupID() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.ClearGroupID() }) } // SetStatus sets the "status" field. -func (u *ApiKeyUpsertBulk) SetStatus(v string) *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) SetStatus(v string) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.SetStatus(v) }) } // UpdateStatus sets the "status" field to the value that was provided on create. -func (u *ApiKeyUpsertBulk) UpdateStatus() *ApiKeyUpsertBulk { - return u.Update(func(s *ApiKeyUpsert) { +func (u *APIKeyUpsertBulk) UpdateStatus() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { s.UpdateStatus() }) } // Exec executes the query. -func (u *ApiKeyUpsertBulk) Exec(ctx context.Context) error { +func (u *APIKeyUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { return u.create.err } for i, b := range u.create.builders { if len(b.conflict) != 0 { - return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the ApiKeyCreateBulk instead", i) + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the APIKeyCreateBulk instead", i) } } if len(u.create.conflict) == 0 { - return errors.New("ent: missing options for ApiKeyCreateBulk.OnConflict") + return errors.New("ent: missing options for APIKeyCreateBulk.OnConflict") } return u.create.Exec(ctx) } // ExecX is like Exec, but panics if an error occurs. -func (u *ApiKeyUpsertBulk) ExecX(ctx context.Context) { +func (u *APIKeyUpsertBulk) ExecX(ctx context.Context) { if err := u.create.Exec(ctx); err != nil { panic(err) } diff --git a/backend/ent/apikey_delete.go b/backend/ent/apikey_delete.go index 6e5c200c..761db81d 100644 --- a/backend/ent/apikey_delete.go +++ b/backend/ent/apikey_delete.go @@ -12,26 +12,26 @@ import ( "github.com/Wei-Shaw/sub2api/ent/predicate" ) -// ApiKeyDelete is the builder for deleting a ApiKey entity. -type ApiKeyDelete struct { +// APIKeyDelete is the builder for deleting a APIKey entity. +type APIKeyDelete struct { config hooks []Hook - mutation *ApiKeyMutation + mutation *APIKeyMutation } -// Where appends a list predicates to the ApiKeyDelete builder. -func (_d *ApiKeyDelete) Where(ps ...predicate.ApiKey) *ApiKeyDelete { +// Where appends a list predicates to the APIKeyDelete builder. +func (_d *APIKeyDelete) Where(ps ...predicate.APIKey) *APIKeyDelete { _d.mutation.Where(ps...) return _d } // Exec executes the deletion query and returns how many vertices were deleted. -func (_d *ApiKeyDelete) Exec(ctx context.Context) (int, error) { +func (_d *APIKeyDelete) Exec(ctx context.Context) (int, error) { return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) } // ExecX is like Exec, but panics if an error occurs. -func (_d *ApiKeyDelete) ExecX(ctx context.Context) int { +func (_d *APIKeyDelete) ExecX(ctx context.Context) int { n, err := _d.Exec(ctx) if err != nil { panic(err) @@ -39,7 +39,7 @@ func (_d *ApiKeyDelete) ExecX(ctx context.Context) int { return n } -func (_d *ApiKeyDelete) sqlExec(ctx context.Context) (int, error) { +func (_d *APIKeyDelete) sqlExec(ctx context.Context) (int, error) { _spec := sqlgraph.NewDeleteSpec(apikey.Table, sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64)) if ps := _d.mutation.predicates; len(ps) > 0 { _spec.Predicate = func(selector *sql.Selector) { @@ -56,19 +56,19 @@ func (_d *ApiKeyDelete) sqlExec(ctx context.Context) (int, error) { return affected, err } -// ApiKeyDeleteOne is the builder for deleting a single ApiKey entity. -type ApiKeyDeleteOne struct { - _d *ApiKeyDelete +// APIKeyDeleteOne is the builder for deleting a single APIKey entity. +type APIKeyDeleteOne struct { + _d *APIKeyDelete } -// Where appends a list predicates to the ApiKeyDelete builder. -func (_d *ApiKeyDeleteOne) Where(ps ...predicate.ApiKey) *ApiKeyDeleteOne { +// Where appends a list predicates to the APIKeyDelete builder. +func (_d *APIKeyDeleteOne) Where(ps ...predicate.APIKey) *APIKeyDeleteOne { _d._d.mutation.Where(ps...) return _d } // Exec executes the deletion query. -func (_d *ApiKeyDeleteOne) Exec(ctx context.Context) error { +func (_d *APIKeyDeleteOne) Exec(ctx context.Context) error { n, err := _d._d.Exec(ctx) switch { case err != nil: @@ -81,7 +81,7 @@ func (_d *ApiKeyDeleteOne) Exec(ctx context.Context) error { } // ExecX is like Exec, but panics if an error occurs. -func (_d *ApiKeyDeleteOne) ExecX(ctx context.Context) { +func (_d *APIKeyDeleteOne) ExecX(ctx context.Context) { if err := _d.Exec(ctx); err != nil { panic(err) } diff --git a/backend/ent/apikey_query.go b/backend/ent/apikey_query.go index d4029feb..6e5c0f5e 100644 --- a/backend/ent/apikey_query.go +++ b/backend/ent/apikey_query.go @@ -19,13 +19,13 @@ import ( "github.com/Wei-Shaw/sub2api/ent/user" ) -// ApiKeyQuery is the builder for querying ApiKey entities. -type ApiKeyQuery struct { +// APIKeyQuery is the builder for querying APIKey entities. +type APIKeyQuery struct { config ctx *QueryContext order []apikey.OrderOption inters []Interceptor - predicates []predicate.ApiKey + predicates []predicate.APIKey withUser *UserQuery withGroup *GroupQuery withUsageLogs *UsageLogQuery @@ -34,39 +34,39 @@ type ApiKeyQuery struct { path func(context.Context) (*sql.Selector, error) } -// Where adds a new predicate for the ApiKeyQuery builder. -func (_q *ApiKeyQuery) Where(ps ...predicate.ApiKey) *ApiKeyQuery { +// Where adds a new predicate for the APIKeyQuery builder. +func (_q *APIKeyQuery) Where(ps ...predicate.APIKey) *APIKeyQuery { _q.predicates = append(_q.predicates, ps...) return _q } // Limit the number of records to be returned by this query. -func (_q *ApiKeyQuery) Limit(limit int) *ApiKeyQuery { +func (_q *APIKeyQuery) Limit(limit int) *APIKeyQuery { _q.ctx.Limit = &limit return _q } // Offset to start from. -func (_q *ApiKeyQuery) Offset(offset int) *ApiKeyQuery { +func (_q *APIKeyQuery) Offset(offset int) *APIKeyQuery { _q.ctx.Offset = &offset return _q } // Unique configures the query builder to filter duplicate records on query. // By default, unique is set to true, and can be disabled using this method. -func (_q *ApiKeyQuery) Unique(unique bool) *ApiKeyQuery { +func (_q *APIKeyQuery) Unique(unique bool) *APIKeyQuery { _q.ctx.Unique = &unique return _q } // Order specifies how the records should be ordered. -func (_q *ApiKeyQuery) Order(o ...apikey.OrderOption) *ApiKeyQuery { +func (_q *APIKeyQuery) Order(o ...apikey.OrderOption) *APIKeyQuery { _q.order = append(_q.order, o...) return _q } // QueryUser chains the current query on the "user" edge. -func (_q *ApiKeyQuery) QueryUser() *UserQuery { +func (_q *APIKeyQuery) QueryUser() *UserQuery { query := (&UserClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { @@ -88,7 +88,7 @@ func (_q *ApiKeyQuery) QueryUser() *UserQuery { } // QueryGroup chains the current query on the "group" edge. -func (_q *ApiKeyQuery) QueryGroup() *GroupQuery { +func (_q *APIKeyQuery) QueryGroup() *GroupQuery { query := (&GroupClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { @@ -110,7 +110,7 @@ func (_q *ApiKeyQuery) QueryGroup() *GroupQuery { } // QueryUsageLogs chains the current query on the "usage_logs" edge. -func (_q *ApiKeyQuery) QueryUsageLogs() *UsageLogQuery { +func (_q *APIKeyQuery) QueryUsageLogs() *UsageLogQuery { query := (&UsageLogClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { @@ -131,9 +131,9 @@ func (_q *ApiKeyQuery) QueryUsageLogs() *UsageLogQuery { return query } -// First returns the first ApiKey entity from the query. -// Returns a *NotFoundError when no ApiKey was found. -func (_q *ApiKeyQuery) First(ctx context.Context) (*ApiKey, error) { +// First returns the first APIKey entity from the query. +// Returns a *NotFoundError when no APIKey was found. +func (_q *APIKeyQuery) First(ctx context.Context) (*APIKey, error) { nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) if err != nil { return nil, err @@ -145,7 +145,7 @@ func (_q *ApiKeyQuery) First(ctx context.Context) (*ApiKey, error) { } // FirstX is like First, but panics if an error occurs. -func (_q *ApiKeyQuery) FirstX(ctx context.Context) *ApiKey { +func (_q *APIKeyQuery) FirstX(ctx context.Context) *APIKey { node, err := _q.First(ctx) if err != nil && !IsNotFound(err) { panic(err) @@ -153,9 +153,9 @@ func (_q *ApiKeyQuery) FirstX(ctx context.Context) *ApiKey { return node } -// FirstID returns the first ApiKey ID from the query. -// Returns a *NotFoundError when no ApiKey ID was found. -func (_q *ApiKeyQuery) FirstID(ctx context.Context) (id int64, err error) { +// FirstID returns the first APIKey ID from the query. +// Returns a *NotFoundError when no APIKey ID was found. +func (_q *APIKeyQuery) FirstID(ctx context.Context) (id int64, err error) { var ids []int64 if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { return @@ -168,7 +168,7 @@ func (_q *ApiKeyQuery) FirstID(ctx context.Context) (id int64, err error) { } // FirstIDX is like FirstID, but panics if an error occurs. -func (_q *ApiKeyQuery) FirstIDX(ctx context.Context) int64 { +func (_q *APIKeyQuery) FirstIDX(ctx context.Context) int64 { id, err := _q.FirstID(ctx) if err != nil && !IsNotFound(err) { panic(err) @@ -176,10 +176,10 @@ func (_q *ApiKeyQuery) FirstIDX(ctx context.Context) int64 { return id } -// Only returns a single ApiKey entity found by the query, ensuring it only returns one. -// Returns a *NotSingularError when more than one ApiKey entity is found. -// Returns a *NotFoundError when no ApiKey entities are found. -func (_q *ApiKeyQuery) Only(ctx context.Context) (*ApiKey, error) { +// Only returns a single APIKey entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one APIKey entity is found. +// Returns a *NotFoundError when no APIKey entities are found. +func (_q *APIKeyQuery) Only(ctx context.Context) (*APIKey, error) { nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) if err != nil { return nil, err @@ -195,7 +195,7 @@ func (_q *ApiKeyQuery) Only(ctx context.Context) (*ApiKey, error) { } // OnlyX is like Only, but panics if an error occurs. -func (_q *ApiKeyQuery) OnlyX(ctx context.Context) *ApiKey { +func (_q *APIKeyQuery) OnlyX(ctx context.Context) *APIKey { node, err := _q.Only(ctx) if err != nil { panic(err) @@ -203,10 +203,10 @@ func (_q *ApiKeyQuery) OnlyX(ctx context.Context) *ApiKey { return node } -// OnlyID is like Only, but returns the only ApiKey ID in the query. -// Returns a *NotSingularError when more than one ApiKey ID is found. +// OnlyID is like Only, but returns the only APIKey ID in the query. +// Returns a *NotSingularError when more than one APIKey ID is found. // Returns a *NotFoundError when no entities are found. -func (_q *ApiKeyQuery) OnlyID(ctx context.Context) (id int64, err error) { +func (_q *APIKeyQuery) OnlyID(ctx context.Context) (id int64, err error) { var ids []int64 if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { return @@ -223,7 +223,7 @@ func (_q *ApiKeyQuery) OnlyID(ctx context.Context) (id int64, err error) { } // OnlyIDX is like OnlyID, but panics if an error occurs. -func (_q *ApiKeyQuery) OnlyIDX(ctx context.Context) int64 { +func (_q *APIKeyQuery) OnlyIDX(ctx context.Context) int64 { id, err := _q.OnlyID(ctx) if err != nil { panic(err) @@ -231,18 +231,18 @@ func (_q *ApiKeyQuery) OnlyIDX(ctx context.Context) int64 { return id } -// All executes the query and returns a list of ApiKeys. -func (_q *ApiKeyQuery) All(ctx context.Context) ([]*ApiKey, error) { +// All executes the query and returns a list of APIKeys. +func (_q *APIKeyQuery) All(ctx context.Context) ([]*APIKey, error) { ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) if err := _q.prepareQuery(ctx); err != nil { return nil, err } - qr := querierAll[[]*ApiKey, *ApiKeyQuery]() - return withInterceptors[[]*ApiKey](ctx, _q, qr, _q.inters) + qr := querierAll[[]*APIKey, *APIKeyQuery]() + return withInterceptors[[]*APIKey](ctx, _q, qr, _q.inters) } // AllX is like All, but panics if an error occurs. -func (_q *ApiKeyQuery) AllX(ctx context.Context) []*ApiKey { +func (_q *APIKeyQuery) AllX(ctx context.Context) []*APIKey { nodes, err := _q.All(ctx) if err != nil { panic(err) @@ -250,8 +250,8 @@ func (_q *ApiKeyQuery) AllX(ctx context.Context) []*ApiKey { return nodes } -// IDs executes the query and returns a list of ApiKey IDs. -func (_q *ApiKeyQuery) IDs(ctx context.Context) (ids []int64, err error) { +// IDs executes the query and returns a list of APIKey IDs. +func (_q *APIKeyQuery) IDs(ctx context.Context) (ids []int64, err error) { if _q.ctx.Unique == nil && _q.path != nil { _q.Unique(true) } @@ -263,7 +263,7 @@ func (_q *ApiKeyQuery) IDs(ctx context.Context) (ids []int64, err error) { } // IDsX is like IDs, but panics if an error occurs. -func (_q *ApiKeyQuery) IDsX(ctx context.Context) []int64 { +func (_q *APIKeyQuery) IDsX(ctx context.Context) []int64 { ids, err := _q.IDs(ctx) if err != nil { panic(err) @@ -272,16 +272,16 @@ func (_q *ApiKeyQuery) IDsX(ctx context.Context) []int64 { } // Count returns the count of the given query. -func (_q *ApiKeyQuery) Count(ctx context.Context) (int, error) { +func (_q *APIKeyQuery) Count(ctx context.Context) (int, error) { ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) if err := _q.prepareQuery(ctx); err != nil { return 0, err } - return withInterceptors[int](ctx, _q, querierCount[*ApiKeyQuery](), _q.inters) + return withInterceptors[int](ctx, _q, querierCount[*APIKeyQuery](), _q.inters) } // CountX is like Count, but panics if an error occurs. -func (_q *ApiKeyQuery) CountX(ctx context.Context) int { +func (_q *APIKeyQuery) CountX(ctx context.Context) int { count, err := _q.Count(ctx) if err != nil { panic(err) @@ -290,7 +290,7 @@ func (_q *ApiKeyQuery) CountX(ctx context.Context) int { } // Exist returns true if the query has elements in the graph. -func (_q *ApiKeyQuery) Exist(ctx context.Context) (bool, error) { +func (_q *APIKeyQuery) Exist(ctx context.Context) (bool, error) { ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) switch _, err := _q.FirstID(ctx); { case IsNotFound(err): @@ -303,7 +303,7 @@ func (_q *ApiKeyQuery) Exist(ctx context.Context) (bool, error) { } // ExistX is like Exist, but panics if an error occurs. -func (_q *ApiKeyQuery) ExistX(ctx context.Context) bool { +func (_q *APIKeyQuery) ExistX(ctx context.Context) bool { exist, err := _q.Exist(ctx) if err != nil { panic(err) @@ -311,18 +311,18 @@ func (_q *ApiKeyQuery) ExistX(ctx context.Context) bool { return exist } -// Clone returns a duplicate of the ApiKeyQuery builder, including all associated steps. It can be +// Clone returns a duplicate of the APIKeyQuery builder, including all associated steps. It can be // used to prepare common query builders and use them differently after the clone is made. -func (_q *ApiKeyQuery) Clone() *ApiKeyQuery { +func (_q *APIKeyQuery) Clone() *APIKeyQuery { if _q == nil { return nil } - return &ApiKeyQuery{ + return &APIKeyQuery{ config: _q.config, ctx: _q.ctx.Clone(), order: append([]apikey.OrderOption{}, _q.order...), inters: append([]Interceptor{}, _q.inters...), - predicates: append([]predicate.ApiKey{}, _q.predicates...), + predicates: append([]predicate.APIKey{}, _q.predicates...), withUser: _q.withUser.Clone(), withGroup: _q.withGroup.Clone(), withUsageLogs: _q.withUsageLogs.Clone(), @@ -334,7 +334,7 @@ func (_q *ApiKeyQuery) Clone() *ApiKeyQuery { // WithUser tells the query-builder to eager-load the nodes that are connected to // the "user" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *ApiKeyQuery) WithUser(opts ...func(*UserQuery)) *ApiKeyQuery { +func (_q *APIKeyQuery) WithUser(opts ...func(*UserQuery)) *APIKeyQuery { query := (&UserClient{config: _q.config}).Query() for _, opt := range opts { opt(query) @@ -345,7 +345,7 @@ func (_q *ApiKeyQuery) WithUser(opts ...func(*UserQuery)) *ApiKeyQuery { // WithGroup tells the query-builder to eager-load the nodes that are connected to // the "group" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *ApiKeyQuery) WithGroup(opts ...func(*GroupQuery)) *ApiKeyQuery { +func (_q *APIKeyQuery) WithGroup(opts ...func(*GroupQuery)) *APIKeyQuery { query := (&GroupClient{config: _q.config}).Query() for _, opt := range opts { opt(query) @@ -356,7 +356,7 @@ func (_q *ApiKeyQuery) WithGroup(opts ...func(*GroupQuery)) *ApiKeyQuery { // WithUsageLogs tells the query-builder to eager-load the nodes that are connected to // the "usage_logs" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *ApiKeyQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *ApiKeyQuery { +func (_q *APIKeyQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *APIKeyQuery { query := (&UsageLogClient{config: _q.config}).Query() for _, opt := range opts { opt(query) @@ -375,13 +375,13 @@ func (_q *ApiKeyQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *ApiKeyQuery // Count int `json:"count,omitempty"` // } // -// client.ApiKey.Query(). +// client.APIKey.Query(). // GroupBy(apikey.FieldCreatedAt). // Aggregate(ent.Count()). // Scan(ctx, &v) -func (_q *ApiKeyQuery) GroupBy(field string, fields ...string) *ApiKeyGroupBy { +func (_q *APIKeyQuery) GroupBy(field string, fields ...string) *APIKeyGroupBy { _q.ctx.Fields = append([]string{field}, fields...) - grbuild := &ApiKeyGroupBy{build: _q} + grbuild := &APIKeyGroupBy{build: _q} grbuild.flds = &_q.ctx.Fields grbuild.label = apikey.Label grbuild.scan = grbuild.Scan @@ -397,23 +397,23 @@ func (_q *ApiKeyQuery) GroupBy(field string, fields ...string) *ApiKeyGroupBy { // CreatedAt time.Time `json:"created_at,omitempty"` // } // -// client.ApiKey.Query(). +// client.APIKey.Query(). // Select(apikey.FieldCreatedAt). // Scan(ctx, &v) -func (_q *ApiKeyQuery) Select(fields ...string) *ApiKeySelect { +func (_q *APIKeyQuery) Select(fields ...string) *APIKeySelect { _q.ctx.Fields = append(_q.ctx.Fields, fields...) - sbuild := &ApiKeySelect{ApiKeyQuery: _q} + sbuild := &APIKeySelect{APIKeyQuery: _q} sbuild.label = apikey.Label sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan return sbuild } -// Aggregate returns a ApiKeySelect configured with the given aggregations. -func (_q *ApiKeyQuery) Aggregate(fns ...AggregateFunc) *ApiKeySelect { +// Aggregate returns a APIKeySelect configured with the given aggregations. +func (_q *APIKeyQuery) Aggregate(fns ...AggregateFunc) *APIKeySelect { return _q.Select().Aggregate(fns...) } -func (_q *ApiKeyQuery) prepareQuery(ctx context.Context) error { +func (_q *APIKeyQuery) prepareQuery(ctx context.Context) error { for _, inter := range _q.inters { if inter == nil { return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") @@ -439,9 +439,9 @@ func (_q *ApiKeyQuery) prepareQuery(ctx context.Context) error { return nil } -func (_q *ApiKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*ApiKey, error) { +func (_q *APIKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*APIKey, error) { var ( - nodes = []*ApiKey{} + nodes = []*APIKey{} _spec = _q.querySpec() loadedTypes = [3]bool{ _q.withUser != nil, @@ -450,10 +450,10 @@ func (_q *ApiKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*ApiKe } ) _spec.ScanValues = func(columns []string) ([]any, error) { - return (*ApiKey).scanValues(nil, columns) + return (*APIKey).scanValues(nil, columns) } _spec.Assign = func(columns []string, values []any) error { - node := &ApiKey{config: _q.config} + node := &APIKey{config: _q.config} nodes = append(nodes, node) node.Edges.loadedTypes = loadedTypes return node.assignValues(columns, values) @@ -469,29 +469,29 @@ func (_q *ApiKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*ApiKe } if query := _q.withUser; query != nil { if err := _q.loadUser(ctx, query, nodes, nil, - func(n *ApiKey, e *User) { n.Edges.User = e }); err != nil { + func(n *APIKey, e *User) { n.Edges.User = e }); err != nil { return nil, err } } if query := _q.withGroup; query != nil { if err := _q.loadGroup(ctx, query, nodes, nil, - func(n *ApiKey, e *Group) { n.Edges.Group = e }); err != nil { + func(n *APIKey, e *Group) { n.Edges.Group = e }); err != nil { return nil, err } } if query := _q.withUsageLogs; query != nil { if err := _q.loadUsageLogs(ctx, query, nodes, - func(n *ApiKey) { n.Edges.UsageLogs = []*UsageLog{} }, - func(n *ApiKey, e *UsageLog) { n.Edges.UsageLogs = append(n.Edges.UsageLogs, e) }); err != nil { + func(n *APIKey) { n.Edges.UsageLogs = []*UsageLog{} }, + func(n *APIKey, e *UsageLog) { n.Edges.UsageLogs = append(n.Edges.UsageLogs, e) }); err != nil { return nil, err } } return nodes, nil } -func (_q *ApiKeyQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*ApiKey, init func(*ApiKey), assign func(*ApiKey, *User)) error { +func (_q *APIKeyQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*APIKey, init func(*APIKey), assign func(*APIKey, *User)) error { ids := make([]int64, 0, len(nodes)) - nodeids := make(map[int64][]*ApiKey) + nodeids := make(map[int64][]*APIKey) for i := range nodes { fk := nodes[i].UserID if _, ok := nodeids[fk]; !ok { @@ -518,9 +518,9 @@ func (_q *ApiKeyQuery) loadUser(ctx context.Context, query *UserQuery, nodes []* } return nil } -func (_q *ApiKeyQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes []*ApiKey, init func(*ApiKey), assign func(*ApiKey, *Group)) error { +func (_q *APIKeyQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes []*APIKey, init func(*APIKey), assign func(*APIKey, *Group)) error { ids := make([]int64, 0, len(nodes)) - nodeids := make(map[int64][]*ApiKey) + nodeids := make(map[int64][]*APIKey) for i := range nodes { if nodes[i].GroupID == nil { continue @@ -550,9 +550,9 @@ func (_q *ApiKeyQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes [ } return nil } -func (_q *ApiKeyQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, nodes []*ApiKey, init func(*ApiKey), assign func(*ApiKey, *UsageLog)) error { +func (_q *APIKeyQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, nodes []*APIKey, init func(*APIKey), assign func(*APIKey, *UsageLog)) error { fks := make([]driver.Value, 0, len(nodes)) - nodeids := make(map[int64]*ApiKey) + nodeids := make(map[int64]*APIKey) for i := range nodes { fks = append(fks, nodes[i].ID) nodeids[nodes[i].ID] = nodes[i] @@ -581,7 +581,7 @@ func (_q *ApiKeyQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, return nil } -func (_q *ApiKeyQuery) sqlCount(ctx context.Context) (int, error) { +func (_q *APIKeyQuery) sqlCount(ctx context.Context) (int, error) { _spec := _q.querySpec() _spec.Node.Columns = _q.ctx.Fields if len(_q.ctx.Fields) > 0 { @@ -590,7 +590,7 @@ func (_q *ApiKeyQuery) sqlCount(ctx context.Context) (int, error) { return sqlgraph.CountNodes(ctx, _q.driver, _spec) } -func (_q *ApiKeyQuery) querySpec() *sqlgraph.QuerySpec { +func (_q *APIKeyQuery) querySpec() *sqlgraph.QuerySpec { _spec := sqlgraph.NewQuerySpec(apikey.Table, apikey.Columns, sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64)) _spec.From = _q.sql if unique := _q.ctx.Unique; unique != nil { @@ -636,7 +636,7 @@ func (_q *ApiKeyQuery) querySpec() *sqlgraph.QuerySpec { return _spec } -func (_q *ApiKeyQuery) sqlQuery(ctx context.Context) *sql.Selector { +func (_q *APIKeyQuery) sqlQuery(ctx context.Context) *sql.Selector { builder := sql.Dialect(_q.driver.Dialect()) t1 := builder.Table(apikey.Table) columns := _q.ctx.Fields @@ -668,28 +668,28 @@ func (_q *ApiKeyQuery) sqlQuery(ctx context.Context) *sql.Selector { return selector } -// ApiKeyGroupBy is the group-by builder for ApiKey entities. -type ApiKeyGroupBy struct { +// APIKeyGroupBy is the group-by builder for APIKey entities. +type APIKeyGroupBy struct { selector - build *ApiKeyQuery + build *APIKeyQuery } // Aggregate adds the given aggregation functions to the group-by query. -func (_g *ApiKeyGroupBy) Aggregate(fns ...AggregateFunc) *ApiKeyGroupBy { +func (_g *APIKeyGroupBy) Aggregate(fns ...AggregateFunc) *APIKeyGroupBy { _g.fns = append(_g.fns, fns...) return _g } // Scan applies the selector query and scans the result into the given value. -func (_g *ApiKeyGroupBy) Scan(ctx context.Context, v any) error { +func (_g *APIKeyGroupBy) Scan(ctx context.Context, v any) error { ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) if err := _g.build.prepareQuery(ctx); err != nil { return err } - return scanWithInterceptors[*ApiKeyQuery, *ApiKeyGroupBy](ctx, _g.build, _g, _g.build.inters, v) + return scanWithInterceptors[*APIKeyQuery, *APIKeyGroupBy](ctx, _g.build, _g, _g.build.inters, v) } -func (_g *ApiKeyGroupBy) sqlScan(ctx context.Context, root *ApiKeyQuery, v any) error { +func (_g *APIKeyGroupBy) sqlScan(ctx context.Context, root *APIKeyQuery, v any) error { selector := root.sqlQuery(ctx).Select() aggregation := make([]string, 0, len(_g.fns)) for _, fn := range _g.fns { @@ -716,28 +716,28 @@ func (_g *ApiKeyGroupBy) sqlScan(ctx context.Context, root *ApiKeyQuery, v any) return sql.ScanSlice(rows, v) } -// ApiKeySelect is the builder for selecting fields of ApiKey entities. -type ApiKeySelect struct { - *ApiKeyQuery +// APIKeySelect is the builder for selecting fields of APIKey entities. +type APIKeySelect struct { + *APIKeyQuery selector } // Aggregate adds the given aggregation functions to the selector query. -func (_s *ApiKeySelect) Aggregate(fns ...AggregateFunc) *ApiKeySelect { +func (_s *APIKeySelect) Aggregate(fns ...AggregateFunc) *APIKeySelect { _s.fns = append(_s.fns, fns...) return _s } // Scan applies the selector query and scans the result into the given value. -func (_s *ApiKeySelect) Scan(ctx context.Context, v any) error { +func (_s *APIKeySelect) Scan(ctx context.Context, v any) error { ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) if err := _s.prepareQuery(ctx); err != nil { return err } - return scanWithInterceptors[*ApiKeyQuery, *ApiKeySelect](ctx, _s.ApiKeyQuery, _s, _s.inters, v) + return scanWithInterceptors[*APIKeyQuery, *APIKeySelect](ctx, _s.APIKeyQuery, _s, _s.inters, v) } -func (_s *ApiKeySelect) sqlScan(ctx context.Context, root *ApiKeyQuery, v any) error { +func (_s *APIKeySelect) sqlScan(ctx context.Context, root *APIKeyQuery, v any) error { selector := root.sqlQuery(ctx) aggregation := make([]string, 0, len(_s.fns)) for _, fn := range _s.fns { diff --git a/backend/ent/apikey_update.go b/backend/ent/apikey_update.go index 3259bfd9..4a16369b 100644 --- a/backend/ent/apikey_update.go +++ b/backend/ent/apikey_update.go @@ -18,33 +18,33 @@ import ( "github.com/Wei-Shaw/sub2api/ent/user" ) -// ApiKeyUpdate is the builder for updating ApiKey entities. -type ApiKeyUpdate struct { +// APIKeyUpdate is the builder for updating APIKey entities. +type APIKeyUpdate struct { config hooks []Hook - mutation *ApiKeyMutation + mutation *APIKeyMutation } -// Where appends a list predicates to the ApiKeyUpdate builder. -func (_u *ApiKeyUpdate) Where(ps ...predicate.ApiKey) *ApiKeyUpdate { +// Where appends a list predicates to the APIKeyUpdate builder. +func (_u *APIKeyUpdate) Where(ps ...predicate.APIKey) *APIKeyUpdate { _u.mutation.Where(ps...) return _u } // SetUpdatedAt sets the "updated_at" field. -func (_u *ApiKeyUpdate) SetUpdatedAt(v time.Time) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetUpdatedAt(v time.Time) *APIKeyUpdate { _u.mutation.SetUpdatedAt(v) return _u } // SetDeletedAt sets the "deleted_at" field. -func (_u *ApiKeyUpdate) SetDeletedAt(v time.Time) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetDeletedAt(v time.Time) *APIKeyUpdate { _u.mutation.SetDeletedAt(v) return _u } // SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableDeletedAt(v *time.Time) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableDeletedAt(v *time.Time) *APIKeyUpdate { if v != nil { _u.SetDeletedAt(*v) } @@ -52,19 +52,19 @@ func (_u *ApiKeyUpdate) SetNillableDeletedAt(v *time.Time) *ApiKeyUpdate { } // ClearDeletedAt clears the value of the "deleted_at" field. -func (_u *ApiKeyUpdate) ClearDeletedAt() *ApiKeyUpdate { +func (_u *APIKeyUpdate) ClearDeletedAt() *APIKeyUpdate { _u.mutation.ClearDeletedAt() return _u } // SetUserID sets the "user_id" field. -func (_u *ApiKeyUpdate) SetUserID(v int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetUserID(v int64) *APIKeyUpdate { _u.mutation.SetUserID(v) return _u } // SetNillableUserID sets the "user_id" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableUserID(v *int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableUserID(v *int64) *APIKeyUpdate { if v != nil { _u.SetUserID(*v) } @@ -72,13 +72,13 @@ func (_u *ApiKeyUpdate) SetNillableUserID(v *int64) *ApiKeyUpdate { } // SetKey sets the "key" field. -func (_u *ApiKeyUpdate) SetKey(v string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetKey(v string) *APIKeyUpdate { _u.mutation.SetKey(v) return _u } // SetNillableKey sets the "key" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableKey(v *string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableKey(v *string) *APIKeyUpdate { if v != nil { _u.SetKey(*v) } @@ -86,13 +86,13 @@ func (_u *ApiKeyUpdate) SetNillableKey(v *string) *ApiKeyUpdate { } // SetName sets the "name" field. -func (_u *ApiKeyUpdate) SetName(v string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetName(v string) *APIKeyUpdate { _u.mutation.SetName(v) return _u } // SetNillableName sets the "name" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableName(v *string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableName(v *string) *APIKeyUpdate { if v != nil { _u.SetName(*v) } @@ -100,13 +100,13 @@ func (_u *ApiKeyUpdate) SetNillableName(v *string) *ApiKeyUpdate { } // SetGroupID sets the "group_id" field. -func (_u *ApiKeyUpdate) SetGroupID(v int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetGroupID(v int64) *APIKeyUpdate { _u.mutation.SetGroupID(v) return _u } // SetNillableGroupID sets the "group_id" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableGroupID(v *int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableGroupID(v *int64) *APIKeyUpdate { if v != nil { _u.SetGroupID(*v) } @@ -114,19 +114,19 @@ func (_u *ApiKeyUpdate) SetNillableGroupID(v *int64) *ApiKeyUpdate { } // ClearGroupID clears the value of the "group_id" field. -func (_u *ApiKeyUpdate) ClearGroupID() *ApiKeyUpdate { +func (_u *APIKeyUpdate) ClearGroupID() *APIKeyUpdate { _u.mutation.ClearGroupID() return _u } // SetStatus sets the "status" field. -func (_u *ApiKeyUpdate) SetStatus(v string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetStatus(v string) *APIKeyUpdate { _u.mutation.SetStatus(v) return _u } // SetNillableStatus sets the "status" field if the given value is not nil. -func (_u *ApiKeyUpdate) SetNillableStatus(v *string) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetNillableStatus(v *string) *APIKeyUpdate { if v != nil { _u.SetStatus(*v) } @@ -134,23 +134,23 @@ func (_u *ApiKeyUpdate) SetNillableStatus(v *string) *ApiKeyUpdate { } // SetUser sets the "user" edge to the User entity. -func (_u *ApiKeyUpdate) SetUser(v *User) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetUser(v *User) *APIKeyUpdate { return _u.SetUserID(v.ID) } // SetGroup sets the "group" edge to the Group entity. -func (_u *ApiKeyUpdate) SetGroup(v *Group) *ApiKeyUpdate { +func (_u *APIKeyUpdate) SetGroup(v *Group) *APIKeyUpdate { return _u.SetGroupID(v.ID) } // AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs. -func (_u *ApiKeyUpdate) AddUsageLogIDs(ids ...int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) AddUsageLogIDs(ids ...int64) *APIKeyUpdate { _u.mutation.AddUsageLogIDs(ids...) return _u } // AddUsageLogs adds the "usage_logs" edges to the UsageLog entity. -func (_u *ApiKeyUpdate) AddUsageLogs(v ...*UsageLog) *ApiKeyUpdate { +func (_u *APIKeyUpdate) AddUsageLogs(v ...*UsageLog) *APIKeyUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -158,37 +158,37 @@ func (_u *ApiKeyUpdate) AddUsageLogs(v ...*UsageLog) *ApiKeyUpdate { return _u.AddUsageLogIDs(ids...) } -// Mutation returns the ApiKeyMutation object of the builder. -func (_u *ApiKeyUpdate) Mutation() *ApiKeyMutation { +// Mutation returns the APIKeyMutation object of the builder. +func (_u *APIKeyUpdate) Mutation() *APIKeyMutation { return _u.mutation } // ClearUser clears the "user" edge to the User entity. -func (_u *ApiKeyUpdate) ClearUser() *ApiKeyUpdate { +func (_u *APIKeyUpdate) ClearUser() *APIKeyUpdate { _u.mutation.ClearUser() return _u } // ClearGroup clears the "group" edge to the Group entity. -func (_u *ApiKeyUpdate) ClearGroup() *ApiKeyUpdate { +func (_u *APIKeyUpdate) ClearGroup() *APIKeyUpdate { _u.mutation.ClearGroup() return _u } // ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity. -func (_u *ApiKeyUpdate) ClearUsageLogs() *ApiKeyUpdate { +func (_u *APIKeyUpdate) ClearUsageLogs() *APIKeyUpdate { _u.mutation.ClearUsageLogs() return _u } // RemoveUsageLogIDs removes the "usage_logs" edge to UsageLog entities by IDs. -func (_u *ApiKeyUpdate) RemoveUsageLogIDs(ids ...int64) *ApiKeyUpdate { +func (_u *APIKeyUpdate) RemoveUsageLogIDs(ids ...int64) *APIKeyUpdate { _u.mutation.RemoveUsageLogIDs(ids...) return _u } // RemoveUsageLogs removes "usage_logs" edges to UsageLog entities. -func (_u *ApiKeyUpdate) RemoveUsageLogs(v ...*UsageLog) *ApiKeyUpdate { +func (_u *APIKeyUpdate) RemoveUsageLogs(v ...*UsageLog) *APIKeyUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -197,7 +197,7 @@ func (_u *ApiKeyUpdate) RemoveUsageLogs(v ...*UsageLog) *ApiKeyUpdate { } // Save executes the query and returns the number of nodes affected by the update operation. -func (_u *ApiKeyUpdate) Save(ctx context.Context) (int, error) { +func (_u *APIKeyUpdate) Save(ctx context.Context) (int, error) { if err := _u.defaults(); err != nil { return 0, err } @@ -205,7 +205,7 @@ func (_u *ApiKeyUpdate) Save(ctx context.Context) (int, error) { } // SaveX is like Save, but panics if an error occurs. -func (_u *ApiKeyUpdate) SaveX(ctx context.Context) int { +func (_u *APIKeyUpdate) SaveX(ctx context.Context) int { affected, err := _u.Save(ctx) if err != nil { panic(err) @@ -214,20 +214,20 @@ func (_u *ApiKeyUpdate) SaveX(ctx context.Context) int { } // Exec executes the query. -func (_u *ApiKeyUpdate) Exec(ctx context.Context) error { +func (_u *APIKeyUpdate) Exec(ctx context.Context) error { _, err := _u.Save(ctx) return err } // ExecX is like Exec, but panics if an error occurs. -func (_u *ApiKeyUpdate) ExecX(ctx context.Context) { +func (_u *APIKeyUpdate) ExecX(ctx context.Context) { if err := _u.Exec(ctx); err != nil { panic(err) } } // defaults sets the default values of the builder before save. -func (_u *ApiKeyUpdate) defaults() error { +func (_u *APIKeyUpdate) defaults() error { if _, ok := _u.mutation.UpdatedAt(); !ok { if apikey.UpdateDefaultUpdatedAt == nil { return fmt.Errorf("ent: uninitialized apikey.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)") @@ -239,29 +239,29 @@ func (_u *ApiKeyUpdate) defaults() error { } // check runs all checks and user-defined validators on the builder. -func (_u *ApiKeyUpdate) check() error { +func (_u *APIKeyUpdate) check() error { if v, ok := _u.mutation.Key(); ok { if err := apikey.KeyValidator(v); err != nil { - return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "ApiKey.key": %w`, err)} + return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)} } } if v, ok := _u.mutation.Name(); ok { if err := apikey.NameValidator(v); err != nil { - return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "ApiKey.name": %w`, err)} + return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)} } } if v, ok := _u.mutation.Status(); ok { if err := apikey.StatusValidator(v); err != nil { - return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ApiKey.status": %w`, err)} + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "APIKey.status": %w`, err)} } } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "ApiKey.user"`) + return errors.New(`ent: clearing a required unique edge "APIKey.user"`) } return nil } -func (_u *ApiKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { +func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { if err := _u.check(); err != nil { return _node, err } @@ -406,28 +406,28 @@ func (_u *ApiKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { return _node, nil } -// ApiKeyUpdateOne is the builder for updating a single ApiKey entity. -type ApiKeyUpdateOne struct { +// APIKeyUpdateOne is the builder for updating a single APIKey entity. +type APIKeyUpdateOne struct { config fields []string hooks []Hook - mutation *ApiKeyMutation + mutation *APIKeyMutation } // SetUpdatedAt sets the "updated_at" field. -func (_u *ApiKeyUpdateOne) SetUpdatedAt(v time.Time) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetUpdatedAt(v time.Time) *APIKeyUpdateOne { _u.mutation.SetUpdatedAt(v) return _u } // SetDeletedAt sets the "deleted_at" field. -func (_u *ApiKeyUpdateOne) SetDeletedAt(v time.Time) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetDeletedAt(v time.Time) *APIKeyUpdateOne { _u.mutation.SetDeletedAt(v) return _u } // SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableDeletedAt(v *time.Time) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableDeletedAt(v *time.Time) *APIKeyUpdateOne { if v != nil { _u.SetDeletedAt(*v) } @@ -435,19 +435,19 @@ func (_u *ApiKeyUpdateOne) SetNillableDeletedAt(v *time.Time) *ApiKeyUpdateOne { } // ClearDeletedAt clears the value of the "deleted_at" field. -func (_u *ApiKeyUpdateOne) ClearDeletedAt() *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) ClearDeletedAt() *APIKeyUpdateOne { _u.mutation.ClearDeletedAt() return _u } // SetUserID sets the "user_id" field. -func (_u *ApiKeyUpdateOne) SetUserID(v int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetUserID(v int64) *APIKeyUpdateOne { _u.mutation.SetUserID(v) return _u } // SetNillableUserID sets the "user_id" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableUserID(v *int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableUserID(v *int64) *APIKeyUpdateOne { if v != nil { _u.SetUserID(*v) } @@ -455,13 +455,13 @@ func (_u *ApiKeyUpdateOne) SetNillableUserID(v *int64) *ApiKeyUpdateOne { } // SetKey sets the "key" field. -func (_u *ApiKeyUpdateOne) SetKey(v string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetKey(v string) *APIKeyUpdateOne { _u.mutation.SetKey(v) return _u } // SetNillableKey sets the "key" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableKey(v *string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableKey(v *string) *APIKeyUpdateOne { if v != nil { _u.SetKey(*v) } @@ -469,13 +469,13 @@ func (_u *ApiKeyUpdateOne) SetNillableKey(v *string) *ApiKeyUpdateOne { } // SetName sets the "name" field. -func (_u *ApiKeyUpdateOne) SetName(v string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetName(v string) *APIKeyUpdateOne { _u.mutation.SetName(v) return _u } // SetNillableName sets the "name" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableName(v *string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableName(v *string) *APIKeyUpdateOne { if v != nil { _u.SetName(*v) } @@ -483,13 +483,13 @@ func (_u *ApiKeyUpdateOne) SetNillableName(v *string) *ApiKeyUpdateOne { } // SetGroupID sets the "group_id" field. -func (_u *ApiKeyUpdateOne) SetGroupID(v int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetGroupID(v int64) *APIKeyUpdateOne { _u.mutation.SetGroupID(v) return _u } // SetNillableGroupID sets the "group_id" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableGroupID(v *int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableGroupID(v *int64) *APIKeyUpdateOne { if v != nil { _u.SetGroupID(*v) } @@ -497,19 +497,19 @@ func (_u *ApiKeyUpdateOne) SetNillableGroupID(v *int64) *ApiKeyUpdateOne { } // ClearGroupID clears the value of the "group_id" field. -func (_u *ApiKeyUpdateOne) ClearGroupID() *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) ClearGroupID() *APIKeyUpdateOne { _u.mutation.ClearGroupID() return _u } // SetStatus sets the "status" field. -func (_u *ApiKeyUpdateOne) SetStatus(v string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetStatus(v string) *APIKeyUpdateOne { _u.mutation.SetStatus(v) return _u } // SetNillableStatus sets the "status" field if the given value is not nil. -func (_u *ApiKeyUpdateOne) SetNillableStatus(v *string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetNillableStatus(v *string) *APIKeyUpdateOne { if v != nil { _u.SetStatus(*v) } @@ -517,23 +517,23 @@ func (_u *ApiKeyUpdateOne) SetNillableStatus(v *string) *ApiKeyUpdateOne { } // SetUser sets the "user" edge to the User entity. -func (_u *ApiKeyUpdateOne) SetUser(v *User) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetUser(v *User) *APIKeyUpdateOne { return _u.SetUserID(v.ID) } // SetGroup sets the "group" edge to the Group entity. -func (_u *ApiKeyUpdateOne) SetGroup(v *Group) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) SetGroup(v *Group) *APIKeyUpdateOne { return _u.SetGroupID(v.ID) } // AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs. -func (_u *ApiKeyUpdateOne) AddUsageLogIDs(ids ...int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) AddUsageLogIDs(ids ...int64) *APIKeyUpdateOne { _u.mutation.AddUsageLogIDs(ids...) return _u } // AddUsageLogs adds the "usage_logs" edges to the UsageLog entity. -func (_u *ApiKeyUpdateOne) AddUsageLogs(v ...*UsageLog) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) AddUsageLogs(v ...*UsageLog) *APIKeyUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -541,37 +541,37 @@ func (_u *ApiKeyUpdateOne) AddUsageLogs(v ...*UsageLog) *ApiKeyUpdateOne { return _u.AddUsageLogIDs(ids...) } -// Mutation returns the ApiKeyMutation object of the builder. -func (_u *ApiKeyUpdateOne) Mutation() *ApiKeyMutation { +// Mutation returns the APIKeyMutation object of the builder. +func (_u *APIKeyUpdateOne) Mutation() *APIKeyMutation { return _u.mutation } // ClearUser clears the "user" edge to the User entity. -func (_u *ApiKeyUpdateOne) ClearUser() *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) ClearUser() *APIKeyUpdateOne { _u.mutation.ClearUser() return _u } // ClearGroup clears the "group" edge to the Group entity. -func (_u *ApiKeyUpdateOne) ClearGroup() *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) ClearGroup() *APIKeyUpdateOne { _u.mutation.ClearGroup() return _u } // ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity. -func (_u *ApiKeyUpdateOne) ClearUsageLogs() *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) ClearUsageLogs() *APIKeyUpdateOne { _u.mutation.ClearUsageLogs() return _u } // RemoveUsageLogIDs removes the "usage_logs" edge to UsageLog entities by IDs. -func (_u *ApiKeyUpdateOne) RemoveUsageLogIDs(ids ...int64) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) RemoveUsageLogIDs(ids ...int64) *APIKeyUpdateOne { _u.mutation.RemoveUsageLogIDs(ids...) return _u } // RemoveUsageLogs removes "usage_logs" edges to UsageLog entities. -func (_u *ApiKeyUpdateOne) RemoveUsageLogs(v ...*UsageLog) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) RemoveUsageLogs(v ...*UsageLog) *APIKeyUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -579,21 +579,21 @@ func (_u *ApiKeyUpdateOne) RemoveUsageLogs(v ...*UsageLog) *ApiKeyUpdateOne { return _u.RemoveUsageLogIDs(ids...) } -// Where appends a list predicates to the ApiKeyUpdate builder. -func (_u *ApiKeyUpdateOne) Where(ps ...predicate.ApiKey) *ApiKeyUpdateOne { +// Where appends a list predicates to the APIKeyUpdate builder. +func (_u *APIKeyUpdateOne) Where(ps ...predicate.APIKey) *APIKeyUpdateOne { _u.mutation.Where(ps...) return _u } // Select allows selecting one or more fields (columns) of the returned entity. // The default is selecting all fields defined in the entity schema. -func (_u *ApiKeyUpdateOne) Select(field string, fields ...string) *ApiKeyUpdateOne { +func (_u *APIKeyUpdateOne) Select(field string, fields ...string) *APIKeyUpdateOne { _u.fields = append([]string{field}, fields...) return _u } -// Save executes the query and returns the updated ApiKey entity. -func (_u *ApiKeyUpdateOne) Save(ctx context.Context) (*ApiKey, error) { +// Save executes the query and returns the updated APIKey entity. +func (_u *APIKeyUpdateOne) Save(ctx context.Context) (*APIKey, error) { if err := _u.defaults(); err != nil { return nil, err } @@ -601,7 +601,7 @@ func (_u *ApiKeyUpdateOne) Save(ctx context.Context) (*ApiKey, error) { } // SaveX is like Save, but panics if an error occurs. -func (_u *ApiKeyUpdateOne) SaveX(ctx context.Context) *ApiKey { +func (_u *APIKeyUpdateOne) SaveX(ctx context.Context) *APIKey { node, err := _u.Save(ctx) if err != nil { panic(err) @@ -610,20 +610,20 @@ func (_u *ApiKeyUpdateOne) SaveX(ctx context.Context) *ApiKey { } // Exec executes the query on the entity. -func (_u *ApiKeyUpdateOne) Exec(ctx context.Context) error { +func (_u *APIKeyUpdateOne) Exec(ctx context.Context) error { _, err := _u.Save(ctx) return err } // ExecX is like Exec, but panics if an error occurs. -func (_u *ApiKeyUpdateOne) ExecX(ctx context.Context) { +func (_u *APIKeyUpdateOne) ExecX(ctx context.Context) { if err := _u.Exec(ctx); err != nil { panic(err) } } // defaults sets the default values of the builder before save. -func (_u *ApiKeyUpdateOne) defaults() error { +func (_u *APIKeyUpdateOne) defaults() error { if _, ok := _u.mutation.UpdatedAt(); !ok { if apikey.UpdateDefaultUpdatedAt == nil { return fmt.Errorf("ent: uninitialized apikey.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)") @@ -635,36 +635,36 @@ func (_u *ApiKeyUpdateOne) defaults() error { } // check runs all checks and user-defined validators on the builder. -func (_u *ApiKeyUpdateOne) check() error { +func (_u *APIKeyUpdateOne) check() error { if v, ok := _u.mutation.Key(); ok { if err := apikey.KeyValidator(v); err != nil { - return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "ApiKey.key": %w`, err)} + return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)} } } if v, ok := _u.mutation.Name(); ok { if err := apikey.NameValidator(v); err != nil { - return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "ApiKey.name": %w`, err)} + return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)} } } if v, ok := _u.mutation.Status(); ok { if err := apikey.StatusValidator(v); err != nil { - return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ApiKey.status": %w`, err)} + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "APIKey.status": %w`, err)} } } if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "ApiKey.user"`) + return errors.New(`ent: clearing a required unique edge "APIKey.user"`) } return nil } -func (_u *ApiKeyUpdateOne) sqlSave(ctx context.Context) (_node *ApiKey, err error) { +func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err error) { if err := _u.check(); err != nil { return _node, err } _spec := sqlgraph.NewUpdateSpec(apikey.Table, apikey.Columns, sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64)) id, ok := _u.mutation.ID() if !ok { - return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "ApiKey.id" for update`)} + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "APIKey.id" for update`)} } _spec.Node.ID.Value = id if fields := _u.fields; len(fields) > 0 { @@ -807,7 +807,7 @@ func (_u *ApiKeyUpdateOne) sqlSave(ctx context.Context) (_node *ApiKey, err erro } _spec.Edges.Add = append(_spec.Edges.Add, edge) } - _node = &ApiKey{config: _u.config} + _node = &APIKey{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { diff --git a/backend/ent/client.go b/backend/ent/client.go index fab70489..33832277 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -37,12 +37,12 @@ type Client struct { config // Schema is the client for creating, migrating and dropping schema. Schema *migrate.Schema + // APIKey is the client for interacting with the APIKey builders. + APIKey *APIKeyClient // Account is the client for interacting with the Account builders. Account *AccountClient // AccountGroup is the client for interacting with the AccountGroup builders. AccountGroup *AccountGroupClient - // ApiKey is the client for interacting with the ApiKey builders. - ApiKey *ApiKeyClient // Group is the client for interacting with the Group builders. Group *GroupClient // Proxy is the client for interacting with the Proxy builders. @@ -74,9 +74,9 @@ func NewClient(opts ...Option) *Client { func (c *Client) init() { c.Schema = migrate.NewSchema(c.driver) + c.APIKey = NewAPIKeyClient(c.config) c.Account = NewAccountClient(c.config) c.AccountGroup = NewAccountGroupClient(c.config) - c.ApiKey = NewApiKeyClient(c.config) c.Group = NewGroupClient(c.config) c.Proxy = NewProxyClient(c.config) c.RedeemCode = NewRedeemCodeClient(c.config) @@ -179,9 +179,9 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { return &Tx{ ctx: ctx, config: cfg, + APIKey: NewAPIKeyClient(cfg), Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), - ApiKey: NewApiKeyClient(cfg), Group: NewGroupClient(cfg), Proxy: NewProxyClient(cfg), RedeemCode: NewRedeemCodeClient(cfg), @@ -211,9 +211,9 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) return &Tx{ ctx: ctx, config: cfg, + APIKey: NewAPIKeyClient(cfg), Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), - ApiKey: NewApiKeyClient(cfg), Group: NewGroupClient(cfg), Proxy: NewProxyClient(cfg), RedeemCode: NewRedeemCodeClient(cfg), @@ -230,7 +230,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) // Debug returns a new debug-client. It's used to get verbose logging on specific operations. // // client.Debug(). -// Account. +// APIKey. // Query(). // Count(ctx) func (c *Client) Debug() *Client { @@ -253,9 +253,9 @@ func (c *Client) Close() error { // In order to add hooks to a specific client, call: `client.Node.Use(...)`. func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ - c.Account, c.AccountGroup, c.ApiKey, c.Group, c.Proxy, c.RedeemCode, c.Setting, - c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, - c.UserAttributeValue, c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Group, c.Proxy, c.RedeemCode, c.Setting, + c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.UserSubscription, } { n.Use(hooks...) } @@ -265,9 +265,9 @@ func (c *Client) Use(hooks ...Hook) { // In order to add interceptors to a specific client, call: `client.Node.Intercept(...)`. func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ - c.Account, c.AccountGroup, c.ApiKey, c.Group, c.Proxy, c.RedeemCode, c.Setting, - c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, - c.UserAttributeValue, c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Group, c.Proxy, c.RedeemCode, c.Setting, + c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.UserSubscription, } { n.Intercept(interceptors...) } @@ -276,12 +276,12 @@ func (c *Client) Intercept(interceptors ...Interceptor) { // Mutate implements the ent.Mutator interface. func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { switch m := m.(type) { + case *APIKeyMutation: + return c.APIKey.mutate(ctx, m) case *AccountMutation: return c.Account.mutate(ctx, m) case *AccountGroupMutation: return c.AccountGroup.mutate(ctx, m) - case *ApiKeyMutation: - return c.ApiKey.mutate(ctx, m) case *GroupMutation: return c.Group.mutate(ctx, m) case *ProxyMutation: @@ -307,6 +307,189 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { } } +// APIKeyClient is a client for the APIKey schema. +type APIKeyClient struct { + config +} + +// NewAPIKeyClient returns a client for the APIKey from the given config. +func NewAPIKeyClient(c config) *APIKeyClient { + return &APIKeyClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `apikey.Hooks(f(g(h())))`. +func (c *APIKeyClient) Use(hooks ...Hook) { + c.hooks.APIKey = append(c.hooks.APIKey, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `apikey.Intercept(f(g(h())))`. +func (c *APIKeyClient) Intercept(interceptors ...Interceptor) { + c.inters.APIKey = append(c.inters.APIKey, interceptors...) +} + +// Create returns a builder for creating a APIKey entity. +func (c *APIKeyClient) Create() *APIKeyCreate { + mutation := newAPIKeyMutation(c.config, OpCreate) + return &APIKeyCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of APIKey entities. +func (c *APIKeyClient) CreateBulk(builders ...*APIKeyCreate) *APIKeyCreateBulk { + return &APIKeyCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *APIKeyClient) MapCreateBulk(slice any, setFunc func(*APIKeyCreate, int)) *APIKeyCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &APIKeyCreateBulk{err: fmt.Errorf("calling to APIKeyClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*APIKeyCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &APIKeyCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for APIKey. +func (c *APIKeyClient) Update() *APIKeyUpdate { + mutation := newAPIKeyMutation(c.config, OpUpdate) + return &APIKeyUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *APIKeyClient) UpdateOne(_m *APIKey) *APIKeyUpdateOne { + mutation := newAPIKeyMutation(c.config, OpUpdateOne, withAPIKey(_m)) + return &APIKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *APIKeyClient) UpdateOneID(id int64) *APIKeyUpdateOne { + mutation := newAPIKeyMutation(c.config, OpUpdateOne, withAPIKeyID(id)) + return &APIKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for APIKey. +func (c *APIKeyClient) Delete() *APIKeyDelete { + mutation := newAPIKeyMutation(c.config, OpDelete) + return &APIKeyDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *APIKeyClient) DeleteOne(_m *APIKey) *APIKeyDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *APIKeyClient) DeleteOneID(id int64) *APIKeyDeleteOne { + builder := c.Delete().Where(apikey.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &APIKeyDeleteOne{builder} +} + +// Query returns a query builder for APIKey. +func (c *APIKeyClient) Query() *APIKeyQuery { + return &APIKeyQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeAPIKey}, + inters: c.Interceptors(), + } +} + +// Get returns a APIKey entity by its id. +func (c *APIKeyClient) Get(ctx context.Context, id int64) (*APIKey, error) { + return c.Query().Where(apikey.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *APIKeyClient) GetX(ctx context.Context, id int64) *APIKey { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryUser queries the user edge of a APIKey. +func (c *APIKeyClient) QueryUser(_m *APIKey) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(apikey.Table, apikey.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, apikey.UserTable, apikey.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryGroup queries the group edge of a APIKey. +func (c *APIKeyClient) QueryGroup(_m *APIKey) *GroupQuery { + query := (&GroupClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(apikey.Table, apikey.FieldID, id), + sqlgraph.To(group.Table, group.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, apikey.GroupTable, apikey.GroupColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryUsageLogs queries the usage_logs edge of a APIKey. +func (c *APIKeyClient) QueryUsageLogs(_m *APIKey) *UsageLogQuery { + query := (&UsageLogClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(apikey.Table, apikey.FieldID, id), + sqlgraph.To(usagelog.Table, usagelog.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, apikey.UsageLogsTable, apikey.UsageLogsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *APIKeyClient) Hooks() []Hook { + hooks := c.hooks.APIKey + return append(hooks[:len(hooks):len(hooks)], apikey.Hooks[:]...) +} + +// Interceptors returns the client interceptors. +func (c *APIKeyClient) Interceptors() []Interceptor { + inters := c.inters.APIKey + return append(inters[:len(inters):len(inters)], apikey.Interceptors[:]...) +} + +func (c *APIKeyClient) mutate(ctx context.Context, m *APIKeyMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&APIKeyCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&APIKeyUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&APIKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&APIKeyDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown APIKey mutation op: %q", m.Op()) + } +} + // AccountClient is a client for the Account schema. type AccountClient struct { config @@ -622,189 +805,6 @@ func (c *AccountGroupClient) mutate(ctx context.Context, m *AccountGroupMutation } } -// ApiKeyClient is a client for the ApiKey schema. -type ApiKeyClient struct { - config -} - -// NewApiKeyClient returns a client for the ApiKey from the given config. -func NewApiKeyClient(c config) *ApiKeyClient { - return &ApiKeyClient{config: c} -} - -// Use adds a list of mutation hooks to the hooks stack. -// A call to `Use(f, g, h)` equals to `apikey.Hooks(f(g(h())))`. -func (c *ApiKeyClient) Use(hooks ...Hook) { - c.hooks.ApiKey = append(c.hooks.ApiKey, hooks...) -} - -// Intercept adds a list of query interceptors to the interceptors stack. -// A call to `Intercept(f, g, h)` equals to `apikey.Intercept(f(g(h())))`. -func (c *ApiKeyClient) Intercept(interceptors ...Interceptor) { - c.inters.ApiKey = append(c.inters.ApiKey, interceptors...) -} - -// Create returns a builder for creating a ApiKey entity. -func (c *ApiKeyClient) Create() *ApiKeyCreate { - mutation := newApiKeyMutation(c.config, OpCreate) - return &ApiKeyCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// CreateBulk returns a builder for creating a bulk of ApiKey entities. -func (c *ApiKeyClient) CreateBulk(builders ...*ApiKeyCreate) *ApiKeyCreateBulk { - return &ApiKeyCreateBulk{config: c.config, builders: builders} -} - -// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates -// a builder and applies setFunc on it. -func (c *ApiKeyClient) MapCreateBulk(slice any, setFunc func(*ApiKeyCreate, int)) *ApiKeyCreateBulk { - rv := reflect.ValueOf(slice) - if rv.Kind() != reflect.Slice { - return &ApiKeyCreateBulk{err: fmt.Errorf("calling to ApiKeyClient.MapCreateBulk with wrong type %T, need slice", slice)} - } - builders := make([]*ApiKeyCreate, rv.Len()) - for i := 0; i < rv.Len(); i++ { - builders[i] = c.Create() - setFunc(builders[i], i) - } - return &ApiKeyCreateBulk{config: c.config, builders: builders} -} - -// Update returns an update builder for ApiKey. -func (c *ApiKeyClient) Update() *ApiKeyUpdate { - mutation := newApiKeyMutation(c.config, OpUpdate) - return &ApiKeyUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// UpdateOne returns an update builder for the given entity. -func (c *ApiKeyClient) UpdateOne(_m *ApiKey) *ApiKeyUpdateOne { - mutation := newApiKeyMutation(c.config, OpUpdateOne, withApiKey(_m)) - return &ApiKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// UpdateOneID returns an update builder for the given id. -func (c *ApiKeyClient) UpdateOneID(id int64) *ApiKeyUpdateOne { - mutation := newApiKeyMutation(c.config, OpUpdateOne, withApiKeyID(id)) - return &ApiKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// Delete returns a delete builder for ApiKey. -func (c *ApiKeyClient) Delete() *ApiKeyDelete { - mutation := newApiKeyMutation(c.config, OpDelete) - return &ApiKeyDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} -} - -// DeleteOne returns a builder for deleting the given entity. -func (c *ApiKeyClient) DeleteOne(_m *ApiKey) *ApiKeyDeleteOne { - return c.DeleteOneID(_m.ID) -} - -// DeleteOneID returns a builder for deleting the given entity by its id. -func (c *ApiKeyClient) DeleteOneID(id int64) *ApiKeyDeleteOne { - builder := c.Delete().Where(apikey.ID(id)) - builder.mutation.id = &id - builder.mutation.op = OpDeleteOne - return &ApiKeyDeleteOne{builder} -} - -// Query returns a query builder for ApiKey. -func (c *ApiKeyClient) Query() *ApiKeyQuery { - return &ApiKeyQuery{ - config: c.config, - ctx: &QueryContext{Type: TypeApiKey}, - inters: c.Interceptors(), - } -} - -// Get returns a ApiKey entity by its id. -func (c *ApiKeyClient) Get(ctx context.Context, id int64) (*ApiKey, error) { - return c.Query().Where(apikey.ID(id)).Only(ctx) -} - -// GetX is like Get, but panics if an error occurs. -func (c *ApiKeyClient) GetX(ctx context.Context, id int64) *ApiKey { - obj, err := c.Get(ctx, id) - if err != nil { - panic(err) - } - return obj -} - -// QueryUser queries the user edge of a ApiKey. -func (c *ApiKeyClient) QueryUser(_m *ApiKey) *UserQuery { - query := (&UserClient{config: c.config}).Query() - query.path = func(context.Context) (fromV *sql.Selector, _ error) { - id := _m.ID - step := sqlgraph.NewStep( - sqlgraph.From(apikey.Table, apikey.FieldID, id), - sqlgraph.To(user.Table, user.FieldID), - sqlgraph.Edge(sqlgraph.M2O, true, apikey.UserTable, apikey.UserColumn), - ) - fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) - return fromV, nil - } - return query -} - -// QueryGroup queries the group edge of a ApiKey. -func (c *ApiKeyClient) QueryGroup(_m *ApiKey) *GroupQuery { - query := (&GroupClient{config: c.config}).Query() - query.path = func(context.Context) (fromV *sql.Selector, _ error) { - id := _m.ID - step := sqlgraph.NewStep( - sqlgraph.From(apikey.Table, apikey.FieldID, id), - sqlgraph.To(group.Table, group.FieldID), - sqlgraph.Edge(sqlgraph.M2O, true, apikey.GroupTable, apikey.GroupColumn), - ) - fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) - return fromV, nil - } - return query -} - -// QueryUsageLogs queries the usage_logs edge of a ApiKey. -func (c *ApiKeyClient) QueryUsageLogs(_m *ApiKey) *UsageLogQuery { - query := (&UsageLogClient{config: c.config}).Query() - query.path = func(context.Context) (fromV *sql.Selector, _ error) { - id := _m.ID - step := sqlgraph.NewStep( - sqlgraph.From(apikey.Table, apikey.FieldID, id), - sqlgraph.To(usagelog.Table, usagelog.FieldID), - sqlgraph.Edge(sqlgraph.O2M, false, apikey.UsageLogsTable, apikey.UsageLogsColumn), - ) - fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) - return fromV, nil - } - return query -} - -// Hooks returns the client hooks. -func (c *ApiKeyClient) Hooks() []Hook { - hooks := c.hooks.ApiKey - return append(hooks[:len(hooks):len(hooks)], apikey.Hooks[:]...) -} - -// Interceptors returns the client interceptors. -func (c *ApiKeyClient) Interceptors() []Interceptor { - inters := c.inters.ApiKey - return append(inters[:len(inters):len(inters)], apikey.Interceptors[:]...) -} - -func (c *ApiKeyClient) mutate(ctx context.Context, m *ApiKeyMutation) (Value, error) { - switch m.Op() { - case OpCreate: - return (&ApiKeyCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpUpdate: - return (&ApiKeyUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpUpdateOne: - return (&ApiKeyUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) - case OpDelete, OpDeleteOne: - return (&ApiKeyDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) - default: - return nil, fmt.Errorf("ent: unknown ApiKey mutation op: %q", m.Op()) - } -} - // GroupClient is a client for the Group schema. type GroupClient struct { config @@ -914,8 +914,8 @@ func (c *GroupClient) GetX(ctx context.Context, id int64) *Group { } // QueryAPIKeys queries the api_keys edge of a Group. -func (c *GroupClient) QueryAPIKeys(_m *Group) *ApiKeyQuery { - query := (&ApiKeyClient{config: c.config}).Query() +func (c *GroupClient) QueryAPIKeys(_m *Group) *APIKeyQuery { + query := (&APIKeyClient{config: c.config}).Query() query.path = func(context.Context) (fromV *sql.Selector, _ error) { id := _m.ID step := sqlgraph.NewStep( @@ -1642,8 +1642,8 @@ func (c *UsageLogClient) QueryUser(_m *UsageLog) *UserQuery { } // QueryAPIKey queries the api_key edge of a UsageLog. -func (c *UsageLogClient) QueryAPIKey(_m *UsageLog) *ApiKeyQuery { - query := (&ApiKeyClient{config: c.config}).Query() +func (c *UsageLogClient) QueryAPIKey(_m *UsageLog) *APIKeyQuery { + query := (&APIKeyClient{config: c.config}).Query() query.path = func(context.Context) (fromV *sql.Selector, _ error) { id := _m.ID step := sqlgraph.NewStep( @@ -1839,8 +1839,8 @@ func (c *UserClient) GetX(ctx context.Context, id int64) *User { } // QueryAPIKeys queries the api_keys edge of a User. -func (c *UserClient) QueryAPIKeys(_m *User) *ApiKeyQuery { - query := (&ApiKeyClient{config: c.config}).Query() +func (c *UserClient) QueryAPIKeys(_m *User) *APIKeyQuery { + query := (&APIKeyClient{config: c.config}).Query() query.path = func(context.Context) (fromV *sql.Selector, _ error) { id := _m.ID step := sqlgraph.NewStep( @@ -2627,12 +2627,12 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription // hooks and interceptors per client, for fast access. type ( hooks struct { - Account, AccountGroup, ApiKey, Group, Proxy, RedeemCode, Setting, UsageLog, + APIKey, Account, AccountGroup, Group, Proxy, RedeemCode, Setting, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook } inters struct { - Account, AccountGroup, ApiKey, Group, Proxy, RedeemCode, Setting, UsageLog, + APIKey, Account, AccountGroup, Group, Proxy, RedeemCode, Setting, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor } diff --git a/backend/ent/driver_access.go b/backend/ent/driver_access.go index b0693572..05bb6872 100644 --- a/backend/ent/driver_access.go +++ b/backend/ent/driver_access.go @@ -1,3 +1,4 @@ +// Package ent provides database entity definitions and operations. package ent import "entgo.io/ent/dialect" diff --git a/backend/ent/group.go b/backend/ent/group.go index 9b1e8604..e8687224 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -54,7 +54,7 @@ type Group struct { // GroupEdges holds the relations/edges for other nodes in the graph. type GroupEdges struct { // APIKeys holds the value of the api_keys edge. - APIKeys []*ApiKey `json:"api_keys,omitempty"` + APIKeys []*APIKey `json:"api_keys,omitempty"` // RedeemCodes holds the value of the redeem_codes edge. RedeemCodes []*RedeemCode `json:"redeem_codes,omitempty"` // Subscriptions holds the value of the subscriptions edge. @@ -76,7 +76,7 @@ type GroupEdges struct { // APIKeysOrErr returns the APIKeys value or an error if the edge // was not loaded in eager-loading. -func (e GroupEdges) APIKeysOrErr() ([]*ApiKey, error) { +func (e GroupEdges) APIKeysOrErr() ([]*APIKey, error) { if e.loadedTypes[0] { return e.APIKeys, nil } @@ -285,7 +285,7 @@ func (_m *Group) Value(name string) (ent.Value, error) { } // QueryAPIKeys queries the "api_keys" edge of the Group entity. -func (_m *Group) QueryAPIKeys() *ApiKeyQuery { +func (_m *Group) QueryAPIKeys() *APIKeyQuery { return NewGroupClient(_m.config).QueryAPIKeys(_m) } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 8dc53c49..1934b17b 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -63,7 +63,7 @@ const ( Table = "groups" // APIKeysTable is the table that holds the api_keys relation/edge. APIKeysTable = "api_keys" - // APIKeysInverseTable is the table name for the ApiKey entity. + // APIKeysInverseTable is the table name for the APIKey entity. // It exists in this package in order to avoid circular dependency with the "apikey" package. APIKeysInverseTable = "api_keys" // APIKeysColumn is the table column denoting the api_keys relation/edge. diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index ac18a418..cb553242 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -842,7 +842,7 @@ func HasAPIKeys() predicate.Group { } // HasAPIKeysWith applies the HasEdge predicate on the "api_keys" edge with a given conditions (other predicates). -func HasAPIKeysWith(preds ...predicate.ApiKey) predicate.Group { +func HasAPIKeysWith(preds ...predicate.APIKey) predicate.Group { return predicate.Group(func(s *sql.Selector) { step := newAPIKeysStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 383a1352..0613c78e 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -216,14 +216,14 @@ func (_c *GroupCreate) SetNillableDefaultValidityDays(v *int) *GroupCreate { return _c } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) return _c } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_c *GroupCreate) AddAPIKeys(v ...*ApiKey) *GroupCreate { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_c *GroupCreate) AddAPIKeys(v ...*APIKey) *GroupCreate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID diff --git a/backend/ent/group_query.go b/backend/ent/group_query.go index 93a8d8c2..3cc976cb 100644 --- a/backend/ent/group_query.go +++ b/backend/ent/group_query.go @@ -31,7 +31,7 @@ type GroupQuery struct { order []group.OrderOption inters []Interceptor predicates []predicate.Group - withAPIKeys *ApiKeyQuery + withAPIKeys *APIKeyQuery withRedeemCodes *RedeemCodeQuery withSubscriptions *UserSubscriptionQuery withUsageLogs *UsageLogQuery @@ -76,8 +76,8 @@ func (_q *GroupQuery) Order(o ...group.OrderOption) *GroupQuery { } // QueryAPIKeys chains the current query on the "api_keys" edge. -func (_q *GroupQuery) QueryAPIKeys() *ApiKeyQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *GroupQuery) QueryAPIKeys() *APIKeyQuery { + query := (&APIKeyClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { return nil, err @@ -459,8 +459,8 @@ func (_q *GroupQuery) Clone() *GroupQuery { // WithAPIKeys tells the query-builder to eager-load the nodes that are connected to // the "api_keys" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *GroupQuery) WithAPIKeys(opts ...func(*ApiKeyQuery)) *GroupQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *GroupQuery) WithAPIKeys(opts ...func(*APIKeyQuery)) *GroupQuery { + query := (&APIKeyClient{config: _q.config}).Query() for _, opt := range opts { opt(query) } @@ -654,8 +654,8 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group, } if query := _q.withAPIKeys; query != nil { if err := _q.loadAPIKeys(ctx, query, nodes, - func(n *Group) { n.Edges.APIKeys = []*ApiKey{} }, - func(n *Group, e *ApiKey) { n.Edges.APIKeys = append(n.Edges.APIKeys, e) }); err != nil { + func(n *Group) { n.Edges.APIKeys = []*APIKey{} }, + func(n *Group, e *APIKey) { n.Edges.APIKeys = append(n.Edges.APIKeys, e) }); err != nil { return nil, err } } @@ -711,7 +711,7 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group, return nodes, nil } -func (_q *GroupQuery) loadAPIKeys(ctx context.Context, query *ApiKeyQuery, nodes []*Group, init func(*Group), assign func(*Group, *ApiKey)) error { +func (_q *GroupQuery) loadAPIKeys(ctx context.Context, query *APIKeyQuery, nodes []*Group, init func(*Group), assign func(*Group, *APIKey)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[int64]*Group) for i := range nodes { @@ -724,7 +724,7 @@ func (_q *GroupQuery) loadAPIKeys(ctx context.Context, query *ApiKeyQuery, nodes if len(query.ctx.Fields) > 0 { query.ctx.AppendFieldOnce(apikey.FieldGroupID) } - query.Where(predicate.ApiKey(func(s *sql.Selector) { + query.Where(predicate.APIKey(func(s *sql.Selector) { s.Where(sql.InValues(s.C(group.APIKeysColumn), fks...)) })) neighbors, err := query.All(ctx) diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 1825a892..43dcf319 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -273,14 +273,14 @@ func (_u *GroupUpdate) AddDefaultValidityDays(v int) *GroupUpdate { return _u } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) return _u } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_u *GroupUpdate) AddAPIKeys(v ...*ApiKey) *GroupUpdate { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_u *GroupUpdate) AddAPIKeys(v ...*APIKey) *GroupUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -368,20 +368,20 @@ func (_u *GroupUpdate) Mutation() *GroupMutation { return _u.mutation } -// ClearAPIKeys clears all "api_keys" edges to the ApiKey entity. +// ClearAPIKeys clears all "api_keys" edges to the APIKey entity. func (_u *GroupUpdate) ClearAPIKeys() *GroupUpdate { _u.mutation.ClearAPIKeys() return _u } -// RemoveAPIKeyIDs removes the "api_keys" edge to ApiKey entities by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to APIKey entities by IDs. func (_u *GroupUpdate) RemoveAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.RemoveAPIKeyIDs(ids...) return _u } -// RemoveAPIKeys removes "api_keys" edges to ApiKey entities. -func (_u *GroupUpdate) RemoveAPIKeys(v ...*ApiKey) *GroupUpdate { +// RemoveAPIKeys removes "api_keys" edges to APIKey entities. +func (_u *GroupUpdate) RemoveAPIKeys(v ...*APIKey) *GroupUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -1195,14 +1195,14 @@ func (_u *GroupUpdateOne) AddDefaultValidityDays(v int) *GroupUpdateOne { return _u } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) return _u } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_u *GroupUpdateOne) AddAPIKeys(v ...*ApiKey) *GroupUpdateOne { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_u *GroupUpdateOne) AddAPIKeys(v ...*APIKey) *GroupUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -1290,20 +1290,20 @@ func (_u *GroupUpdateOne) Mutation() *GroupMutation { return _u.mutation } -// ClearAPIKeys clears all "api_keys" edges to the ApiKey entity. +// ClearAPIKeys clears all "api_keys" edges to the APIKey entity. func (_u *GroupUpdateOne) ClearAPIKeys() *GroupUpdateOne { _u.mutation.ClearAPIKeys() return _u } -// RemoveAPIKeyIDs removes the "api_keys" edge to ApiKey entities by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to APIKey entities by IDs. func (_u *GroupUpdateOne) RemoveAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.RemoveAPIKeyIDs(ids...) return _u } -// RemoveAPIKeys removes "api_keys" edges to ApiKey entities. -func (_u *GroupUpdateOne) RemoveAPIKeys(v ...*ApiKey) *GroupUpdateOne { +// RemoveAPIKeys removes "api_keys" edges to APIKey entities. +func (_u *GroupUpdateOne) RemoveAPIKeys(v ...*APIKey) *GroupUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID diff --git a/backend/ent/hook/hook.go b/backend/ent/hook/hook.go index 3aa5d186..e82b00f9 100644 --- a/backend/ent/hook/hook.go +++ b/backend/ent/hook/hook.go @@ -9,6 +9,18 @@ import ( "github.com/Wei-Shaw/sub2api/ent" ) +// The APIKeyFunc type is an adapter to allow the use of ordinary +// function as APIKey mutator. +type APIKeyFunc func(context.Context, *ent.APIKeyMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f APIKeyFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.APIKeyMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.APIKeyMutation", m) +} + // The AccountFunc type is an adapter to allow the use of ordinary // function as Account mutator. type AccountFunc func(context.Context, *ent.AccountMutation) (ent.Value, error) @@ -33,18 +45,6 @@ func (f AccountGroupFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.AccountGroupMutation", m) } -// The ApiKeyFunc type is an adapter to allow the use of ordinary -// function as ApiKey mutator. -type ApiKeyFunc func(context.Context, *ent.ApiKeyMutation) (ent.Value, error) - -// Mutate calls f(ctx, m). -func (f ApiKeyFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { - if mv, ok := m.(*ent.ApiKeyMutation); ok { - return f(ctx, mv) - } - return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ApiKeyMutation", m) -} - // The GroupFunc type is an adapter to allow the use of ordinary // function as Group mutator. type GroupFunc func(context.Context, *ent.GroupMutation) (ent.Value, error) diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go index 9f694d67..6add6fed 100644 --- a/backend/ent/intercept/intercept.go +++ b/backend/ent/intercept/intercept.go @@ -80,6 +80,33 @@ func (f TraverseFunc) Traverse(ctx context.Context, q ent.Query) error { return f(ctx, query) } +// The APIKeyFunc type is an adapter to allow the use of ordinary function as a Querier. +type APIKeyFunc func(context.Context, *ent.APIKeyQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f APIKeyFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.APIKeyQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.APIKeyQuery", q) +} + +// The TraverseAPIKey type is an adapter to allow the use of ordinary function as Traverser. +type TraverseAPIKey func(context.Context, *ent.APIKeyQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseAPIKey) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseAPIKey) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.APIKeyQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.APIKeyQuery", q) +} + // The AccountFunc type is an adapter to allow the use of ordinary function as a Querier. type AccountFunc func(context.Context, *ent.AccountQuery) (ent.Value, error) @@ -134,33 +161,6 @@ func (f TraverseAccountGroup) Traverse(ctx context.Context, q ent.Query) error { return fmt.Errorf("unexpected query type %T. expect *ent.AccountGroupQuery", q) } -// The ApiKeyFunc type is an adapter to allow the use of ordinary function as a Querier. -type ApiKeyFunc func(context.Context, *ent.ApiKeyQuery) (ent.Value, error) - -// Query calls f(ctx, q). -func (f ApiKeyFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { - if q, ok := q.(*ent.ApiKeyQuery); ok { - return f(ctx, q) - } - return nil, fmt.Errorf("unexpected query type %T. expect *ent.ApiKeyQuery", q) -} - -// The TraverseApiKey type is an adapter to allow the use of ordinary function as Traverser. -type TraverseApiKey func(context.Context, *ent.ApiKeyQuery) error - -// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. -func (f TraverseApiKey) Intercept(next ent.Querier) ent.Querier { - return next -} - -// Traverse calls f(ctx, q). -func (f TraverseApiKey) Traverse(ctx context.Context, q ent.Query) error { - if q, ok := q.(*ent.ApiKeyQuery); ok { - return f(ctx, q) - } - return fmt.Errorf("unexpected query type %T. expect *ent.ApiKeyQuery", q) -} - // The GroupFunc type is an adapter to allow the use of ordinary function as a Querier. type GroupFunc func(context.Context, *ent.GroupQuery) (ent.Value, error) @@ -434,12 +434,12 @@ func (f TraverseUserSubscription) Traverse(ctx context.Context, q ent.Query) err // NewQuery returns the generic Query interface for the given typed query. func NewQuery(q ent.Query) (Query, error) { switch q := q.(type) { + case *ent.APIKeyQuery: + return &query[*ent.APIKeyQuery, predicate.APIKey, apikey.OrderOption]{typ: ent.TypeAPIKey, tq: q}, nil case *ent.AccountQuery: return &query[*ent.AccountQuery, predicate.Account, account.OrderOption]{typ: ent.TypeAccount, tq: q}, nil case *ent.AccountGroupQuery: return &query[*ent.AccountGroupQuery, predicate.AccountGroup, accountgroup.OrderOption]{typ: ent.TypeAccountGroup, tq: q}, nil - case *ent.ApiKeyQuery: - return &query[*ent.ApiKeyQuery, predicate.ApiKey, apikey.OrderOption]{typ: ent.TypeApiKey, tq: q}, nil case *ent.GroupQuery: return &query[*ent.GroupQuery, predicate.Group, group.OrderOption]{typ: ent.TypeGroup, tq: q}, nil case *ent.ProxyQuery: diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d532b34b..b85630ea 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -9,6 +9,60 @@ import ( ) var ( + // APIKeysColumns holds the columns for the "api_keys" table. + APIKeysColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "key", Type: field.TypeString, Unique: true, Size: 128}, + {Name: "name", Type: field.TypeString, Size: 100}, + {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "group_id", Type: field.TypeInt64, Nullable: true}, + {Name: "user_id", Type: field.TypeInt64}, + } + // APIKeysTable holds the schema information for the "api_keys" table. + APIKeysTable = &schema.Table{ + Name: "api_keys", + Columns: APIKeysColumns, + PrimaryKey: []*schema.Column{APIKeysColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "api_keys_groups_api_keys", + Columns: []*schema.Column{APIKeysColumns[7]}, + RefColumns: []*schema.Column{GroupsColumns[0]}, + OnDelete: schema.SetNull, + }, + { + Symbol: "api_keys_users_api_keys", + Columns: []*schema.Column{APIKeysColumns[8]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "apikey_user_id", + Unique: false, + Columns: []*schema.Column{APIKeysColumns[8]}, + }, + { + Name: "apikey_group_id", + Unique: false, + Columns: []*schema.Column{APIKeysColumns[7]}, + }, + { + Name: "apikey_status", + Unique: false, + Columns: []*schema.Column{APIKeysColumns[6]}, + }, + { + Name: "apikey_deleted_at", + Unique: false, + Columns: []*schema.Column{APIKeysColumns[3]}, + }, + }, + } // AccountsColumns holds the columns for the "accounts" table. AccountsColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt64, Increment: true}, @@ -144,60 +198,6 @@ var ( }, }, } - // APIKeysColumns holds the columns for the "api_keys" table. - APIKeysColumns = []*schema.Column{ - {Name: "id", Type: field.TypeInt64, Increment: true}, - {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "key", Type: field.TypeString, Unique: true, Size: 128}, - {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, - {Name: "group_id", Type: field.TypeInt64, Nullable: true}, - {Name: "user_id", Type: field.TypeInt64}, - } - // APIKeysTable holds the schema information for the "api_keys" table. - APIKeysTable = &schema.Table{ - Name: "api_keys", - Columns: APIKeysColumns, - PrimaryKey: []*schema.Column{APIKeysColumns[0]}, - ForeignKeys: []*schema.ForeignKey{ - { - Symbol: "api_keys_groups_api_keys", - Columns: []*schema.Column{APIKeysColumns[7]}, - RefColumns: []*schema.Column{GroupsColumns[0]}, - OnDelete: schema.SetNull, - }, - { - Symbol: "api_keys_users_api_keys", - Columns: []*schema.Column{APIKeysColumns[8]}, - RefColumns: []*schema.Column{UsersColumns[0]}, - OnDelete: schema.NoAction, - }, - }, - Indexes: []*schema.Index{ - { - Name: "apikey_user_id", - Unique: false, - Columns: []*schema.Column{APIKeysColumns[8]}, - }, - { - Name: "apikey_group_id", - Unique: false, - Columns: []*schema.Column{APIKeysColumns[7]}, - }, - { - Name: "apikey_status", - Unique: false, - Columns: []*schema.Column{APIKeysColumns[6]}, - }, - { - Name: "apikey_deleted_at", - Unique: false, - Columns: []*schema.Column{APIKeysColumns[3]}, - }, - }, - } // GroupsColumns holds the columns for the "groups" table. GroupsColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt64, Increment: true}, @@ -368,8 +368,8 @@ var ( {Name: "duration_ms", Type: field.TypeInt, Nullable: true}, {Name: "first_token_ms", Type: field.TypeInt, Nullable: true}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, - {Name: "account_id", Type: field.TypeInt64}, {Name: "api_key_id", Type: field.TypeInt64}, + {Name: "account_id", Type: field.TypeInt64}, {Name: "group_id", Type: field.TypeInt64, Nullable: true}, {Name: "user_id", Type: field.TypeInt64}, {Name: "subscription_id", Type: field.TypeInt64, Nullable: true}, @@ -381,15 +381,15 @@ var ( PrimaryKey: []*schema.Column{UsageLogsColumns[0]}, ForeignKeys: []*schema.ForeignKey{ { - Symbol: "usage_logs_accounts_usage_logs", + Symbol: "usage_logs_api_keys_usage_logs", Columns: []*schema.Column{UsageLogsColumns[21]}, - RefColumns: []*schema.Column{AccountsColumns[0]}, + RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { - Symbol: "usage_logs_api_keys_usage_logs", + Symbol: "usage_logs_accounts_usage_logs", Columns: []*schema.Column{UsageLogsColumns[22]}, - RefColumns: []*schema.Column{APIKeysColumns[0]}, + RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { @@ -420,12 +420,12 @@ var ( { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[22]}, + Columns: []*schema.Column{UsageLogsColumns[21]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[21]}, + Columns: []*schema.Column{UsageLogsColumns[22]}, }, { Name: "usagelog_group_id", @@ -460,7 +460,7 @@ var ( { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[22], UsageLogsColumns[20]}, + Columns: []*schema.Column{UsageLogsColumns[21], UsageLogsColumns[20]}, }, }, } @@ -702,9 +702,9 @@ var ( } // Tables holds all the tables in the schema. Tables = []*schema.Table{ + APIKeysTable, AccountsTable, AccountGroupsTable, - APIKeysTable, GroupsTable, ProxiesTable, RedeemCodesTable, @@ -719,6 +719,11 @@ var ( ) func init() { + APIKeysTable.ForeignKeys[0].RefTable = GroupsTable + APIKeysTable.ForeignKeys[1].RefTable = UsersTable + APIKeysTable.Annotation = &entsql.Annotation{ + Table: "api_keys", + } AccountsTable.ForeignKeys[0].RefTable = ProxiesTable AccountsTable.Annotation = &entsql.Annotation{ Table: "accounts", @@ -728,11 +733,6 @@ func init() { AccountGroupsTable.Annotation = &entsql.Annotation{ Table: "account_groups", } - APIKeysTable.ForeignKeys[0].RefTable = GroupsTable - APIKeysTable.ForeignKeys[1].RefTable = UsersTable - APIKeysTable.Annotation = &entsql.Annotation{ - Table: "api_keys", - } GroupsTable.Annotation = &entsql.Annotation{ Table: "groups", } @@ -747,8 +747,8 @@ func init() { SettingsTable.Annotation = &entsql.Annotation{ Table: "settings", } - UsageLogsTable.ForeignKeys[0].RefTable = AccountsTable - UsageLogsTable.ForeignKeys[1].RefTable = APIKeysTable + UsageLogsTable.ForeignKeys[0].RefTable = APIKeysTable + UsageLogsTable.ForeignKeys[1].RefTable = AccountsTable UsageLogsTable.ForeignKeys[2].RefTable = GroupsTable UsageLogsTable.ForeignKeys[3].RefTable = UsersTable UsageLogsTable.ForeignKeys[4].RefTable = UserSubscriptionsTable diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 7d5fd2ad..90ee37b6 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -36,21 +36,954 @@ const ( OpUpdateOne = ent.OpUpdateOne // Node types. - TypeAccount = "Account" - TypeAccountGroup = "AccountGroup" - TypeApiKey = "ApiKey" - TypeGroup = "Group" - TypeProxy = "Proxy" - TypeRedeemCode = "RedeemCode" - TypeSetting = "Setting" - TypeUsageLog = "UsageLog" - TypeUser = "User" - TypeUserAllowedGroup = "UserAllowedGroup" + TypeAPIKey = "APIKey" + TypeAccount = "Account" + TypeAccountGroup = "AccountGroup" + TypeGroup = "Group" + TypeProxy = "Proxy" + TypeRedeemCode = "RedeemCode" + TypeSetting = "Setting" + TypeUsageLog = "UsageLog" + TypeUser = "User" + TypeUserAllowedGroup = "UserAllowedGroup" TypeUserAttributeDefinition = "UserAttributeDefinition" TypeUserAttributeValue = "UserAttributeValue" - TypeUserSubscription = "UserSubscription" + TypeUserSubscription = "UserSubscription" ) +// APIKeyMutation represents an operation that mutates the APIKey nodes in the graph. +type APIKeyMutation struct { + config + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + deleted_at *time.Time + key *string + name *string + status *string + clearedFields map[string]struct{} + user *int64 + cleareduser bool + group *int64 + clearedgroup bool + usage_logs map[int64]struct{} + removedusage_logs map[int64]struct{} + clearedusage_logs bool + done bool + oldValue func(context.Context) (*APIKey, error) + predicates []predicate.APIKey +} + +var _ ent.Mutation = (*APIKeyMutation)(nil) + +// apikeyOption allows management of the mutation configuration using functional options. +type apikeyOption func(*APIKeyMutation) + +// newAPIKeyMutation creates new mutation for the APIKey entity. +func newAPIKeyMutation(c config, op Op, opts ...apikeyOption) *APIKeyMutation { + m := &APIKeyMutation{ + config: c, + op: op, + typ: TypeAPIKey, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withAPIKeyID sets the ID field of the mutation. +func withAPIKeyID(id int64) apikeyOption { + return func(m *APIKeyMutation) { + var ( + err error + once sync.Once + value *APIKey + ) + m.oldValue = func(ctx context.Context) (*APIKey, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().APIKey.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withAPIKey sets the old APIKey of the mutation. +func withAPIKey(node *APIKey) apikeyOption { + return func(m *APIKeyMutation) { + m.oldValue = func(context.Context) (*APIKey, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m APIKeyMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m APIKeyMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *APIKeyMutation) ID() (id int64, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *APIKeyMutation) IDs(ctx context.Context) ([]int64, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []int64{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().APIKey.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetCreatedAt sets the "created_at" field. +func (m *APIKeyMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *APIKeyMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *APIKeyMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *APIKeyMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *APIKeyMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *APIKeyMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// SetDeletedAt sets the "deleted_at" field. +func (m *APIKeyMutation) SetDeletedAt(t time.Time) { + m.deleted_at = &t +} + +// DeletedAt returns the value of the "deleted_at" field in the mutation. +func (m *APIKeyMutation) DeletedAt() (r time.Time, exists bool) { + v := m.deleted_at + if v == nil { + return + } + return *v, true +} + +// OldDeletedAt returns the old "deleted_at" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldDeletedAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDeletedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDeletedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDeletedAt: %w", err) + } + return oldValue.DeletedAt, nil +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (m *APIKeyMutation) ClearDeletedAt() { + m.deleted_at = nil + m.clearedFields[apikey.FieldDeletedAt] = struct{}{} +} + +// DeletedAtCleared returns if the "deleted_at" field was cleared in this mutation. +func (m *APIKeyMutation) DeletedAtCleared() bool { + _, ok := m.clearedFields[apikey.FieldDeletedAt] + return ok +} + +// ResetDeletedAt resets all changes to the "deleted_at" field. +func (m *APIKeyMutation) ResetDeletedAt() { + m.deleted_at = nil + delete(m.clearedFields, apikey.FieldDeletedAt) +} + +// SetUserID sets the "user_id" field. +func (m *APIKeyMutation) SetUserID(i int64) { + m.user = &i +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *APIKeyMutation) UserID() (r int64, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldUserID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *APIKeyMutation) ResetUserID() { + m.user = nil +} + +// SetKey sets the "key" field. +func (m *APIKeyMutation) SetKey(s string) { + m.key = &s +} + +// Key returns the value of the "key" field in the mutation. +func (m *APIKeyMutation) Key() (r string, exists bool) { + v := m.key + if v == nil { + return + } + return *v, true +} + +// OldKey returns the old "key" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldKey(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldKey is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldKey requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldKey: %w", err) + } + return oldValue.Key, nil +} + +// ResetKey resets all changes to the "key" field. +func (m *APIKeyMutation) ResetKey() { + m.key = nil +} + +// SetName sets the "name" field. +func (m *APIKeyMutation) SetName(s string) { + m.name = &s +} + +// Name returns the value of the "name" field in the mutation. +func (m *APIKeyMutation) Name() (r string, exists bool) { + v := m.name + if v == nil { + return + } + return *v, true +} + +// OldName returns the old "name" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldName(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldName is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldName requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldName: %w", err) + } + return oldValue.Name, nil +} + +// ResetName resets all changes to the "name" field. +func (m *APIKeyMutation) ResetName() { + m.name = nil +} + +// SetGroupID sets the "group_id" field. +func (m *APIKeyMutation) SetGroupID(i int64) { + m.group = &i +} + +// GroupID returns the value of the "group_id" field in the mutation. +func (m *APIKeyMutation) GroupID() (r int64, exists bool) { + v := m.group + if v == nil { + return + } + return *v, true +} + +// OldGroupID returns the old "group_id" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldGroupID(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldGroupID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldGroupID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldGroupID: %w", err) + } + return oldValue.GroupID, nil +} + +// ClearGroupID clears the value of the "group_id" field. +func (m *APIKeyMutation) ClearGroupID() { + m.group = nil + m.clearedFields[apikey.FieldGroupID] = struct{}{} +} + +// GroupIDCleared returns if the "group_id" field was cleared in this mutation. +func (m *APIKeyMutation) GroupIDCleared() bool { + _, ok := m.clearedFields[apikey.FieldGroupID] + return ok +} + +// ResetGroupID resets all changes to the "group_id" field. +func (m *APIKeyMutation) ResetGroupID() { + m.group = nil + delete(m.clearedFields, apikey.FieldGroupID) +} + +// SetStatus sets the "status" field. +func (m *APIKeyMutation) SetStatus(s string) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *APIKeyMutation) Status() (r string, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldStatus(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *APIKeyMutation) ResetStatus() { + m.status = nil +} + +// ClearUser clears the "user" edge to the User entity. +func (m *APIKeyMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[apikey.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *APIKeyMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *APIKeyMutation) UserIDs() (ids []int64) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *APIKeyMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// ClearGroup clears the "group" edge to the Group entity. +func (m *APIKeyMutation) ClearGroup() { + m.clearedgroup = true + m.clearedFields[apikey.FieldGroupID] = struct{}{} +} + +// GroupCleared reports if the "group" edge to the Group entity was cleared. +func (m *APIKeyMutation) GroupCleared() bool { + return m.GroupIDCleared() || m.clearedgroup +} + +// GroupIDs returns the "group" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// GroupID instead. It exists only for internal usage by the builders. +func (m *APIKeyMutation) GroupIDs() (ids []int64) { + if id := m.group; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetGroup resets all changes to the "group" edge. +func (m *APIKeyMutation) ResetGroup() { + m.group = nil + m.clearedgroup = false +} + +// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by ids. +func (m *APIKeyMutation) AddUsageLogIDs(ids ...int64) { + if m.usage_logs == nil { + m.usage_logs = make(map[int64]struct{}) + } + for i := range ids { + m.usage_logs[ids[i]] = struct{}{} + } +} + +// ClearUsageLogs clears the "usage_logs" edge to the UsageLog entity. +func (m *APIKeyMutation) ClearUsageLogs() { + m.clearedusage_logs = true +} + +// UsageLogsCleared reports if the "usage_logs" edge to the UsageLog entity was cleared. +func (m *APIKeyMutation) UsageLogsCleared() bool { + return m.clearedusage_logs +} + +// RemoveUsageLogIDs removes the "usage_logs" edge to the UsageLog entity by IDs. +func (m *APIKeyMutation) RemoveUsageLogIDs(ids ...int64) { + if m.removedusage_logs == nil { + m.removedusage_logs = make(map[int64]struct{}) + } + for i := range ids { + delete(m.usage_logs, ids[i]) + m.removedusage_logs[ids[i]] = struct{}{} + } +} + +// RemovedUsageLogs returns the removed IDs of the "usage_logs" edge to the UsageLog entity. +func (m *APIKeyMutation) RemovedUsageLogsIDs() (ids []int64) { + for id := range m.removedusage_logs { + ids = append(ids, id) + } + return +} + +// UsageLogsIDs returns the "usage_logs" edge IDs in the mutation. +func (m *APIKeyMutation) UsageLogsIDs() (ids []int64) { + for id := range m.usage_logs { + ids = append(ids, id) + } + return +} + +// ResetUsageLogs resets all changes to the "usage_logs" edge. +func (m *APIKeyMutation) ResetUsageLogs() { + m.usage_logs = nil + m.clearedusage_logs = false + m.removedusage_logs = nil +} + +// Where appends a list predicates to the APIKeyMutation builder. +func (m *APIKeyMutation) Where(ps ...predicate.APIKey) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the APIKeyMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *APIKeyMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.APIKey, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *APIKeyMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *APIKeyMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (APIKey). +func (m *APIKeyMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *APIKeyMutation) Fields() []string { + fields := make([]string, 0, 8) + if m.created_at != nil { + fields = append(fields, apikey.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, apikey.FieldUpdatedAt) + } + if m.deleted_at != nil { + fields = append(fields, apikey.FieldDeletedAt) + } + if m.user != nil { + fields = append(fields, apikey.FieldUserID) + } + if m.key != nil { + fields = append(fields, apikey.FieldKey) + } + if m.name != nil { + fields = append(fields, apikey.FieldName) + } + if m.group != nil { + fields = append(fields, apikey.FieldGroupID) + } + if m.status != nil { + fields = append(fields, apikey.FieldStatus) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *APIKeyMutation) Field(name string) (ent.Value, bool) { + switch name { + case apikey.FieldCreatedAt: + return m.CreatedAt() + case apikey.FieldUpdatedAt: + return m.UpdatedAt() + case apikey.FieldDeletedAt: + return m.DeletedAt() + case apikey.FieldUserID: + return m.UserID() + case apikey.FieldKey: + return m.Key() + case apikey.FieldName: + return m.Name() + case apikey.FieldGroupID: + return m.GroupID() + case apikey.FieldStatus: + return m.Status() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case apikey.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case apikey.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + case apikey.FieldDeletedAt: + return m.OldDeletedAt(ctx) + case apikey.FieldUserID: + return m.OldUserID(ctx) + case apikey.FieldKey: + return m.OldKey(ctx) + case apikey.FieldName: + return m.OldName(ctx) + case apikey.FieldGroupID: + return m.OldGroupID(ctx) + case apikey.FieldStatus: + return m.OldStatus(ctx) + } + return nil, fmt.Errorf("unknown APIKey field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *APIKeyMutation) SetField(name string, value ent.Value) error { + switch name { + case apikey.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case apikey.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + case apikey.FieldDeletedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDeletedAt(v) + return nil + case apikey.FieldUserID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case apikey.FieldKey: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetKey(v) + return nil + case apikey.FieldName: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetName(v) + return nil + case apikey.FieldGroupID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetGroupID(v) + return nil + case apikey.FieldStatus: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil + } + return fmt.Errorf("unknown APIKey field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *APIKeyMutation) AddedFields() []string { + var fields []string + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *APIKeyMutation) AddedField(name string) (ent.Value, bool) { + switch name { + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *APIKeyMutation) AddField(name string, value ent.Value) error { + switch name { + } + return fmt.Errorf("unknown APIKey numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *APIKeyMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(apikey.FieldDeletedAt) { + fields = append(fields, apikey.FieldDeletedAt) + } + if m.FieldCleared(apikey.FieldGroupID) { + fields = append(fields, apikey.FieldGroupID) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *APIKeyMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *APIKeyMutation) ClearField(name string) error { + switch name { + case apikey.FieldDeletedAt: + m.ClearDeletedAt() + return nil + case apikey.FieldGroupID: + m.ClearGroupID() + return nil + } + return fmt.Errorf("unknown APIKey nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *APIKeyMutation) ResetField(name string) error { + switch name { + case apikey.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case apikey.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + case apikey.FieldDeletedAt: + m.ResetDeletedAt() + return nil + case apikey.FieldUserID: + m.ResetUserID() + return nil + case apikey.FieldKey: + m.ResetKey() + return nil + case apikey.FieldName: + m.ResetName() + return nil + case apikey.FieldGroupID: + m.ResetGroupID() + return nil + case apikey.FieldStatus: + m.ResetStatus() + return nil + } + return fmt.Errorf("unknown APIKey field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *APIKeyMutation) AddedEdges() []string { + edges := make([]string, 0, 3) + if m.user != nil { + edges = append(edges, apikey.EdgeUser) + } + if m.group != nil { + edges = append(edges, apikey.EdgeGroup) + } + if m.usage_logs != nil { + edges = append(edges, apikey.EdgeUsageLogs) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *APIKeyMutation) AddedIDs(name string) []ent.Value { + switch name { + case apikey.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + case apikey.EdgeGroup: + if id := m.group; id != nil { + return []ent.Value{*id} + } + case apikey.EdgeUsageLogs: + ids := make([]ent.Value, 0, len(m.usage_logs)) + for id := range m.usage_logs { + ids = append(ids, id) + } + return ids + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *APIKeyMutation) RemovedEdges() []string { + edges := make([]string, 0, 3) + if m.removedusage_logs != nil { + edges = append(edges, apikey.EdgeUsageLogs) + } + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *APIKeyMutation) RemovedIDs(name string) []ent.Value { + switch name { + case apikey.EdgeUsageLogs: + ids := make([]ent.Value, 0, len(m.removedusage_logs)) + for id := range m.removedusage_logs { + ids = append(ids, id) + } + return ids + } + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *APIKeyMutation) ClearedEdges() []string { + edges := make([]string, 0, 3) + if m.cleareduser { + edges = append(edges, apikey.EdgeUser) + } + if m.clearedgroup { + edges = append(edges, apikey.EdgeGroup) + } + if m.clearedusage_logs { + edges = append(edges, apikey.EdgeUsageLogs) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *APIKeyMutation) EdgeCleared(name string) bool { + switch name { + case apikey.EdgeUser: + return m.cleareduser + case apikey.EdgeGroup: + return m.clearedgroup + case apikey.EdgeUsageLogs: + return m.clearedusage_logs + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *APIKeyMutation) ClearEdge(name string) error { + switch name { + case apikey.EdgeUser: + m.ClearUser() + return nil + case apikey.EdgeGroup: + m.ClearGroup() + return nil + } + return fmt.Errorf("unknown APIKey unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *APIKeyMutation) ResetEdge(name string) error { + switch name { + case apikey.EdgeUser: + m.ResetUser() + return nil + case apikey.EdgeGroup: + m.ResetGroup() + return nil + case apikey.EdgeUsageLogs: + m.ResetUsageLogs() + return nil + } + return fmt.Errorf("unknown APIKey edge %s", name) +} + // AccountMutation represents an operation that mutates the Account nodes in the graph. type AccountMutation struct { config @@ -2426,939 +3359,6 @@ func (m *AccountGroupMutation) ResetEdge(name string) error { return fmt.Errorf("unknown AccountGroup edge %s", name) } -// ApiKeyMutation represents an operation that mutates the ApiKey nodes in the graph. -type ApiKeyMutation struct { - config - op Op - typ string - id *int64 - created_at *time.Time - updated_at *time.Time - deleted_at *time.Time - key *string - name *string - status *string - clearedFields map[string]struct{} - user *int64 - cleareduser bool - group *int64 - clearedgroup bool - usage_logs map[int64]struct{} - removedusage_logs map[int64]struct{} - clearedusage_logs bool - done bool - oldValue func(context.Context) (*ApiKey, error) - predicates []predicate.ApiKey -} - -var _ ent.Mutation = (*ApiKeyMutation)(nil) - -// apikeyOption allows management of the mutation configuration using functional options. -type apikeyOption func(*ApiKeyMutation) - -// newApiKeyMutation creates new mutation for the ApiKey entity. -func newApiKeyMutation(c config, op Op, opts ...apikeyOption) *ApiKeyMutation { - m := &ApiKeyMutation{ - config: c, - op: op, - typ: TypeApiKey, - clearedFields: make(map[string]struct{}), - } - for _, opt := range opts { - opt(m) - } - return m -} - -// withApiKeyID sets the ID field of the mutation. -func withApiKeyID(id int64) apikeyOption { - return func(m *ApiKeyMutation) { - var ( - err error - once sync.Once - value *ApiKey - ) - m.oldValue = func(ctx context.Context) (*ApiKey, error) { - once.Do(func() { - if m.done { - err = errors.New("querying old values post mutation is not allowed") - } else { - value, err = m.Client().ApiKey.Get(ctx, id) - } - }) - return value, err - } - m.id = &id - } -} - -// withApiKey sets the old ApiKey of the mutation. -func withApiKey(node *ApiKey) apikeyOption { - return func(m *ApiKeyMutation) { - m.oldValue = func(context.Context) (*ApiKey, error) { - return node, nil - } - m.id = &node.ID - } -} - -// Client returns a new `ent.Client` from the mutation. If the mutation was -// executed in a transaction (ent.Tx), a transactional client is returned. -func (m ApiKeyMutation) Client() *Client { - client := &Client{config: m.config} - client.init() - return client -} - -// Tx returns an `ent.Tx` for mutations that were executed in transactions; -// it returns an error otherwise. -func (m ApiKeyMutation) Tx() (*Tx, error) { - if _, ok := m.driver.(*txDriver); !ok { - return nil, errors.New("ent: mutation is not running in a transaction") - } - tx := &Tx{config: m.config} - tx.init() - return tx, nil -} - -// ID returns the ID value in the mutation. Note that the ID is only available -// if it was provided to the builder or after it was returned from the database. -func (m *ApiKeyMutation) ID() (id int64, exists bool) { - if m.id == nil { - return - } - return *m.id, true -} - -// IDs queries the database and returns the entity ids that match the mutation's predicate. -// That means, if the mutation is applied within a transaction with an isolation level such -// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated -// or updated by the mutation. -func (m *ApiKeyMutation) IDs(ctx context.Context) ([]int64, error) { - switch { - case m.op.Is(OpUpdateOne | OpDeleteOne): - id, exists := m.ID() - if exists { - return []int64{id}, nil - } - fallthrough - case m.op.Is(OpUpdate | OpDelete): - return m.Client().ApiKey.Query().Where(m.predicates...).IDs(ctx) - default: - return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) - } -} - -// SetCreatedAt sets the "created_at" field. -func (m *ApiKeyMutation) SetCreatedAt(t time.Time) { - m.created_at = &t -} - -// CreatedAt returns the value of the "created_at" field in the mutation. -func (m *ApiKeyMutation) CreatedAt() (r time.Time, exists bool) { - v := m.created_at - if v == nil { - return - } - return *v, true -} - -// OldCreatedAt returns the old "created_at" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldCreatedAt requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) - } - return oldValue.CreatedAt, nil -} - -// ResetCreatedAt resets all changes to the "created_at" field. -func (m *ApiKeyMutation) ResetCreatedAt() { - m.created_at = nil -} - -// SetUpdatedAt sets the "updated_at" field. -func (m *ApiKeyMutation) SetUpdatedAt(t time.Time) { - m.updated_at = &t -} - -// UpdatedAt returns the value of the "updated_at" field in the mutation. -func (m *ApiKeyMutation) UpdatedAt() (r time.Time, exists bool) { - v := m.updated_at - if v == nil { - return - } - return *v, true -} - -// OldUpdatedAt returns the old "updated_at" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldUpdatedAt requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) - } - return oldValue.UpdatedAt, nil -} - -// ResetUpdatedAt resets all changes to the "updated_at" field. -func (m *ApiKeyMutation) ResetUpdatedAt() { - m.updated_at = nil -} - -// SetDeletedAt sets the "deleted_at" field. -func (m *ApiKeyMutation) SetDeletedAt(t time.Time) { - m.deleted_at = &t -} - -// DeletedAt returns the value of the "deleted_at" field in the mutation. -func (m *ApiKeyMutation) DeletedAt() (r time.Time, exists bool) { - v := m.deleted_at - if v == nil { - return - } - return *v, true -} - -// OldDeletedAt returns the old "deleted_at" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldDeletedAt(ctx context.Context) (v *time.Time, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldDeletedAt is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldDeletedAt requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldDeletedAt: %w", err) - } - return oldValue.DeletedAt, nil -} - -// ClearDeletedAt clears the value of the "deleted_at" field. -func (m *ApiKeyMutation) ClearDeletedAt() { - m.deleted_at = nil - m.clearedFields[apikey.FieldDeletedAt] = struct{}{} -} - -// DeletedAtCleared returns if the "deleted_at" field was cleared in this mutation. -func (m *ApiKeyMutation) DeletedAtCleared() bool { - _, ok := m.clearedFields[apikey.FieldDeletedAt] - return ok -} - -// ResetDeletedAt resets all changes to the "deleted_at" field. -func (m *ApiKeyMutation) ResetDeletedAt() { - m.deleted_at = nil - delete(m.clearedFields, apikey.FieldDeletedAt) -} - -// SetUserID sets the "user_id" field. -func (m *ApiKeyMutation) SetUserID(i int64) { - m.user = &i -} - -// UserID returns the value of the "user_id" field in the mutation. -func (m *ApiKeyMutation) UserID() (r int64, exists bool) { - v := m.user - if v == nil { - return - } - return *v, true -} - -// OldUserID returns the old "user_id" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldUserID(ctx context.Context) (v int64, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldUserID is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldUserID requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldUserID: %w", err) - } - return oldValue.UserID, nil -} - -// ResetUserID resets all changes to the "user_id" field. -func (m *ApiKeyMutation) ResetUserID() { - m.user = nil -} - -// SetKey sets the "key" field. -func (m *ApiKeyMutation) SetKey(s string) { - m.key = &s -} - -// Key returns the value of the "key" field in the mutation. -func (m *ApiKeyMutation) Key() (r string, exists bool) { - v := m.key - if v == nil { - return - } - return *v, true -} - -// OldKey returns the old "key" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldKey(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldKey is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldKey requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldKey: %w", err) - } - return oldValue.Key, nil -} - -// ResetKey resets all changes to the "key" field. -func (m *ApiKeyMutation) ResetKey() { - m.key = nil -} - -// SetName sets the "name" field. -func (m *ApiKeyMutation) SetName(s string) { - m.name = &s -} - -// Name returns the value of the "name" field in the mutation. -func (m *ApiKeyMutation) Name() (r string, exists bool) { - v := m.name - if v == nil { - return - } - return *v, true -} - -// OldName returns the old "name" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldName(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldName is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldName requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldName: %w", err) - } - return oldValue.Name, nil -} - -// ResetName resets all changes to the "name" field. -func (m *ApiKeyMutation) ResetName() { - m.name = nil -} - -// SetGroupID sets the "group_id" field. -func (m *ApiKeyMutation) SetGroupID(i int64) { - m.group = &i -} - -// GroupID returns the value of the "group_id" field in the mutation. -func (m *ApiKeyMutation) GroupID() (r int64, exists bool) { - v := m.group - if v == nil { - return - } - return *v, true -} - -// OldGroupID returns the old "group_id" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldGroupID(ctx context.Context) (v *int64, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldGroupID is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldGroupID requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldGroupID: %w", err) - } - return oldValue.GroupID, nil -} - -// ClearGroupID clears the value of the "group_id" field. -func (m *ApiKeyMutation) ClearGroupID() { - m.group = nil - m.clearedFields[apikey.FieldGroupID] = struct{}{} -} - -// GroupIDCleared returns if the "group_id" field was cleared in this mutation. -func (m *ApiKeyMutation) GroupIDCleared() bool { - _, ok := m.clearedFields[apikey.FieldGroupID] - return ok -} - -// ResetGroupID resets all changes to the "group_id" field. -func (m *ApiKeyMutation) ResetGroupID() { - m.group = nil - delete(m.clearedFields, apikey.FieldGroupID) -} - -// SetStatus sets the "status" field. -func (m *ApiKeyMutation) SetStatus(s string) { - m.status = &s -} - -// Status returns the value of the "status" field in the mutation. -func (m *ApiKeyMutation) Status() (r string, exists bool) { - v := m.status - if v == nil { - return - } - return *v, true -} - -// OldStatus returns the old "status" field's value of the ApiKey entity. -// If the ApiKey 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 *ApiKeyMutation) OldStatus(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldStatus is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldStatus requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldStatus: %w", err) - } - return oldValue.Status, nil -} - -// ResetStatus resets all changes to the "status" field. -func (m *ApiKeyMutation) ResetStatus() { - m.status = nil -} - -// ClearUser clears the "user" edge to the User entity. -func (m *ApiKeyMutation) ClearUser() { - m.cleareduser = true - m.clearedFields[apikey.FieldUserID] = struct{}{} -} - -// UserCleared reports if the "user" edge to the User entity was cleared. -func (m *ApiKeyMutation) UserCleared() bool { - return m.cleareduser -} - -// UserIDs returns the "user" edge IDs in the mutation. -// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use -// UserID instead. It exists only for internal usage by the builders. -func (m *ApiKeyMutation) UserIDs() (ids []int64) { - if id := m.user; id != nil { - ids = append(ids, *id) - } - return -} - -// ResetUser resets all changes to the "user" edge. -func (m *ApiKeyMutation) ResetUser() { - m.user = nil - m.cleareduser = false -} - -// ClearGroup clears the "group" edge to the Group entity. -func (m *ApiKeyMutation) ClearGroup() { - m.clearedgroup = true - m.clearedFields[apikey.FieldGroupID] = struct{}{} -} - -// GroupCleared reports if the "group" edge to the Group entity was cleared. -func (m *ApiKeyMutation) GroupCleared() bool { - return m.GroupIDCleared() || m.clearedgroup -} - -// GroupIDs returns the "group" edge IDs in the mutation. -// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use -// GroupID instead. It exists only for internal usage by the builders. -func (m *ApiKeyMutation) GroupIDs() (ids []int64) { - if id := m.group; id != nil { - ids = append(ids, *id) - } - return -} - -// ResetGroup resets all changes to the "group" edge. -func (m *ApiKeyMutation) ResetGroup() { - m.group = nil - m.clearedgroup = false -} - -// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by ids. -func (m *ApiKeyMutation) AddUsageLogIDs(ids ...int64) { - if m.usage_logs == nil { - m.usage_logs = make(map[int64]struct{}) - } - for i := range ids { - m.usage_logs[ids[i]] = struct{}{} - } -} - -// ClearUsageLogs clears the "usage_logs" edge to the UsageLog entity. -func (m *ApiKeyMutation) ClearUsageLogs() { - m.clearedusage_logs = true -} - -// UsageLogsCleared reports if the "usage_logs" edge to the UsageLog entity was cleared. -func (m *ApiKeyMutation) UsageLogsCleared() bool { - return m.clearedusage_logs -} - -// RemoveUsageLogIDs removes the "usage_logs" edge to the UsageLog entity by IDs. -func (m *ApiKeyMutation) RemoveUsageLogIDs(ids ...int64) { - if m.removedusage_logs == nil { - m.removedusage_logs = make(map[int64]struct{}) - } - for i := range ids { - delete(m.usage_logs, ids[i]) - m.removedusage_logs[ids[i]] = struct{}{} - } -} - -// RemovedUsageLogs returns the removed IDs of the "usage_logs" edge to the UsageLog entity. -func (m *ApiKeyMutation) RemovedUsageLogsIDs() (ids []int64) { - for id := range m.removedusage_logs { - ids = append(ids, id) - } - return -} - -// UsageLogsIDs returns the "usage_logs" edge IDs in the mutation. -func (m *ApiKeyMutation) UsageLogsIDs() (ids []int64) { - for id := range m.usage_logs { - ids = append(ids, id) - } - return -} - -// ResetUsageLogs resets all changes to the "usage_logs" edge. -func (m *ApiKeyMutation) ResetUsageLogs() { - m.usage_logs = nil - m.clearedusage_logs = false - m.removedusage_logs = nil -} - -// Where appends a list predicates to the ApiKeyMutation builder. -func (m *ApiKeyMutation) Where(ps ...predicate.ApiKey) { - m.predicates = append(m.predicates, ps...) -} - -// WhereP appends storage-level predicates to the ApiKeyMutation builder. Using this method, -// users can use type-assertion to append predicates that do not depend on any generated package. -func (m *ApiKeyMutation) WhereP(ps ...func(*sql.Selector)) { - p := make([]predicate.ApiKey, len(ps)) - for i := range ps { - p[i] = ps[i] - } - m.Where(p...) -} - -// Op returns the operation name. -func (m *ApiKeyMutation) Op() Op { - return m.op -} - -// SetOp allows setting the mutation operation. -func (m *ApiKeyMutation) SetOp(op Op) { - m.op = op -} - -// Type returns the node type of this mutation (ApiKey). -func (m *ApiKeyMutation) Type() string { - return m.typ -} - -// Fields returns all fields that were changed during this mutation. Note that in -// order to get all numeric fields that were incremented/decremented, call -// AddedFields(). -func (m *ApiKeyMutation) Fields() []string { - fields := make([]string, 0, 8) - if m.created_at != nil { - fields = append(fields, apikey.FieldCreatedAt) - } - if m.updated_at != nil { - fields = append(fields, apikey.FieldUpdatedAt) - } - if m.deleted_at != nil { - fields = append(fields, apikey.FieldDeletedAt) - } - if m.user != nil { - fields = append(fields, apikey.FieldUserID) - } - if m.key != nil { - fields = append(fields, apikey.FieldKey) - } - if m.name != nil { - fields = append(fields, apikey.FieldName) - } - if m.group != nil { - fields = append(fields, apikey.FieldGroupID) - } - if m.status != nil { - fields = append(fields, apikey.FieldStatus) - } - return fields -} - -// Field returns the value of a field with the given name. The second boolean -// return value indicates that this field was not set, or was not defined in the -// schema. -func (m *ApiKeyMutation) Field(name string) (ent.Value, bool) { - switch name { - case apikey.FieldCreatedAt: - return m.CreatedAt() - case apikey.FieldUpdatedAt: - return m.UpdatedAt() - case apikey.FieldDeletedAt: - return m.DeletedAt() - case apikey.FieldUserID: - return m.UserID() - case apikey.FieldKey: - return m.Key() - case apikey.FieldName: - return m.Name() - case apikey.FieldGroupID: - return m.GroupID() - case apikey.FieldStatus: - return m.Status() - } - return nil, false -} - -// OldField returns the old value of the field from the database. An error is -// returned if the mutation operation is not UpdateOne, or the query to the -// database failed. -func (m *ApiKeyMutation) OldField(ctx context.Context, name string) (ent.Value, error) { - switch name { - case apikey.FieldCreatedAt: - return m.OldCreatedAt(ctx) - case apikey.FieldUpdatedAt: - return m.OldUpdatedAt(ctx) - case apikey.FieldDeletedAt: - return m.OldDeletedAt(ctx) - case apikey.FieldUserID: - return m.OldUserID(ctx) - case apikey.FieldKey: - return m.OldKey(ctx) - case apikey.FieldName: - return m.OldName(ctx) - case apikey.FieldGroupID: - return m.OldGroupID(ctx) - case apikey.FieldStatus: - return m.OldStatus(ctx) - } - return nil, fmt.Errorf("unknown ApiKey field %s", name) -} - -// SetField sets the value of a field with the given name. It returns an error if -// the field is not defined in the schema, or if the type mismatched the field -// type. -func (m *ApiKeyMutation) SetField(name string, value ent.Value) error { - switch name { - case apikey.FieldCreatedAt: - v, ok := value.(time.Time) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetCreatedAt(v) - return nil - case apikey.FieldUpdatedAt: - v, ok := value.(time.Time) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetUpdatedAt(v) - return nil - case apikey.FieldDeletedAt: - v, ok := value.(time.Time) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetDeletedAt(v) - return nil - case apikey.FieldUserID: - v, ok := value.(int64) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetUserID(v) - return nil - case apikey.FieldKey: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetKey(v) - return nil - case apikey.FieldName: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetName(v) - return nil - case apikey.FieldGroupID: - v, ok := value.(int64) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetGroupID(v) - return nil - case apikey.FieldStatus: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetStatus(v) - return nil - } - return fmt.Errorf("unknown ApiKey field %s", name) -} - -// AddedFields returns all numeric fields that were incremented/decremented during -// this mutation. -func (m *ApiKeyMutation) AddedFields() []string { - var fields []string - return fields -} - -// AddedField returns the numeric value that was incremented/decremented on a field -// with the given name. The second boolean return value indicates that this field -// was not set, or was not defined in the schema. -func (m *ApiKeyMutation) AddedField(name string) (ent.Value, bool) { - switch name { - } - return nil, false -} - -// AddField adds the value to the field with the given name. It returns an error if -// the field is not defined in the schema, or if the type mismatched the field -// type. -func (m *ApiKeyMutation) AddField(name string, value ent.Value) error { - switch name { - } - return fmt.Errorf("unknown ApiKey numeric field %s", name) -} - -// ClearedFields returns all nullable fields that were cleared during this -// mutation. -func (m *ApiKeyMutation) ClearedFields() []string { - var fields []string - if m.FieldCleared(apikey.FieldDeletedAt) { - fields = append(fields, apikey.FieldDeletedAt) - } - if m.FieldCleared(apikey.FieldGroupID) { - fields = append(fields, apikey.FieldGroupID) - } - return fields -} - -// FieldCleared returns a boolean indicating if a field with the given name was -// cleared in this mutation. -func (m *ApiKeyMutation) FieldCleared(name string) bool { - _, ok := m.clearedFields[name] - return ok -} - -// ClearField clears the value of the field with the given name. It returns an -// error if the field is not defined in the schema. -func (m *ApiKeyMutation) ClearField(name string) error { - switch name { - case apikey.FieldDeletedAt: - m.ClearDeletedAt() - return nil - case apikey.FieldGroupID: - m.ClearGroupID() - return nil - } - return fmt.Errorf("unknown ApiKey nullable field %s", name) -} - -// ResetField resets all changes in the mutation for the field with the given name. -// It returns an error if the field is not defined in the schema. -func (m *ApiKeyMutation) ResetField(name string) error { - switch name { - case apikey.FieldCreatedAt: - m.ResetCreatedAt() - return nil - case apikey.FieldUpdatedAt: - m.ResetUpdatedAt() - return nil - case apikey.FieldDeletedAt: - m.ResetDeletedAt() - return nil - case apikey.FieldUserID: - m.ResetUserID() - return nil - case apikey.FieldKey: - m.ResetKey() - return nil - case apikey.FieldName: - m.ResetName() - return nil - case apikey.FieldGroupID: - m.ResetGroupID() - return nil - case apikey.FieldStatus: - m.ResetStatus() - return nil - } - return fmt.Errorf("unknown ApiKey field %s", name) -} - -// AddedEdges returns all edge names that were set/added in this mutation. -func (m *ApiKeyMutation) AddedEdges() []string { - edges := make([]string, 0, 3) - if m.user != nil { - edges = append(edges, apikey.EdgeUser) - } - if m.group != nil { - edges = append(edges, apikey.EdgeGroup) - } - if m.usage_logs != nil { - edges = append(edges, apikey.EdgeUsageLogs) - } - return edges -} - -// AddedIDs returns all IDs (to other nodes) that were added for the given edge -// name in this mutation. -func (m *ApiKeyMutation) AddedIDs(name string) []ent.Value { - switch name { - case apikey.EdgeUser: - if id := m.user; id != nil { - return []ent.Value{*id} - } - case apikey.EdgeGroup: - if id := m.group; id != nil { - return []ent.Value{*id} - } - case apikey.EdgeUsageLogs: - ids := make([]ent.Value, 0, len(m.usage_logs)) - for id := range m.usage_logs { - ids = append(ids, id) - } - return ids - } - return nil -} - -// RemovedEdges returns all edge names that were removed in this mutation. -func (m *ApiKeyMutation) RemovedEdges() []string { - edges := make([]string, 0, 3) - if m.removedusage_logs != nil { - edges = append(edges, apikey.EdgeUsageLogs) - } - return edges -} - -// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with -// the given name in this mutation. -func (m *ApiKeyMutation) RemovedIDs(name string) []ent.Value { - switch name { - case apikey.EdgeUsageLogs: - ids := make([]ent.Value, 0, len(m.removedusage_logs)) - for id := range m.removedusage_logs { - ids = append(ids, id) - } - return ids - } - return nil -} - -// ClearedEdges returns all edge names that were cleared in this mutation. -func (m *ApiKeyMutation) ClearedEdges() []string { - edges := make([]string, 0, 3) - if m.cleareduser { - edges = append(edges, apikey.EdgeUser) - } - if m.clearedgroup { - edges = append(edges, apikey.EdgeGroup) - } - if m.clearedusage_logs { - edges = append(edges, apikey.EdgeUsageLogs) - } - return edges -} - -// EdgeCleared returns a boolean which indicates if the edge with the given name -// was cleared in this mutation. -func (m *ApiKeyMutation) EdgeCleared(name string) bool { - switch name { - case apikey.EdgeUser: - return m.cleareduser - case apikey.EdgeGroup: - return m.clearedgroup - case apikey.EdgeUsageLogs: - return m.clearedusage_logs - } - return false -} - -// ClearEdge clears the value of the edge with the given name. It returns an error -// if that edge is not defined in the schema. -func (m *ApiKeyMutation) ClearEdge(name string) error { - switch name { - case apikey.EdgeUser: - m.ClearUser() - return nil - case apikey.EdgeGroup: - m.ClearGroup() - return nil - } - return fmt.Errorf("unknown ApiKey unique edge %s", name) -} - -// ResetEdge resets all changes to the edge with the given name in this mutation. -// It returns an error if the edge is not defined in the schema. -func (m *ApiKeyMutation) ResetEdge(name string) error { - switch name { - case apikey.EdgeUser: - m.ResetUser() - return nil - case apikey.EdgeGroup: - m.ResetGroup() - return nil - case apikey.EdgeUsageLogs: - m.ResetUsageLogs() - return nil - } - return fmt.Errorf("unknown ApiKey edge %s", name) -} - // GroupMutation represents an operation that mutates the Group nodes in the graph. type GroupMutation struct { config @@ -4178,7 +4178,7 @@ func (m *GroupMutation) ResetDefaultValidityDays() { m.adddefault_validity_days = nil } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by ids. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { m.api_keys = make(map[int64]struct{}) @@ -4188,17 +4188,17 @@ func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { } } -// ClearAPIKeys clears the "api_keys" edge to the ApiKey entity. +// ClearAPIKeys clears the "api_keys" edge to the APIKey entity. func (m *GroupMutation) ClearAPIKeys() { m.clearedapi_keys = true } -// APIKeysCleared reports if the "api_keys" edge to the ApiKey entity was cleared. +// APIKeysCleared reports if the "api_keys" edge to the APIKey entity was cleared. func (m *GroupMutation) APIKeysCleared() bool { return m.clearedapi_keys } -// RemoveAPIKeyIDs removes the "api_keys" edge to the ApiKey entity by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to the APIKey entity by IDs. func (m *GroupMutation) RemoveAPIKeyIDs(ids ...int64) { if m.removedapi_keys == nil { m.removedapi_keys = make(map[int64]struct{}) @@ -4209,7 +4209,7 @@ func (m *GroupMutation) RemoveAPIKeyIDs(ids ...int64) { } } -// RemovedAPIKeys returns the removed IDs of the "api_keys" edge to the ApiKey entity. +// RemovedAPIKeys returns the removed IDs of the "api_keys" edge to the APIKey entity. func (m *GroupMutation) RemovedAPIKeysIDs() (ids []int64) { for id := range m.removedapi_keys { ids = append(ids, id) @@ -9129,13 +9129,13 @@ func (m *UsageLogMutation) ResetUser() { m.cleareduser = false } -// ClearAPIKey clears the "api_key" edge to the ApiKey entity. +// ClearAPIKey clears the "api_key" edge to the APIKey entity. func (m *UsageLogMutation) ClearAPIKey() { m.clearedapi_key = true m.clearedFields[usagelog.FieldAPIKeyID] = struct{}{} } -// APIKeyCleared reports if the "api_key" edge to the ApiKey entity was cleared. +// APIKeyCleared reports if the "api_key" edge to the APIKey entity was cleared. func (m *UsageLogMutation) APIKeyCleared() bool { return m.clearedapi_key } @@ -10737,7 +10737,7 @@ func (m *UserMutation) ResetNotes() { m.notes = nil } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by ids. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *UserMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { m.api_keys = make(map[int64]struct{}) @@ -10747,17 +10747,17 @@ func (m *UserMutation) AddAPIKeyIDs(ids ...int64) { } } -// ClearAPIKeys clears the "api_keys" edge to the ApiKey entity. +// ClearAPIKeys clears the "api_keys" edge to the APIKey entity. func (m *UserMutation) ClearAPIKeys() { m.clearedapi_keys = true } -// APIKeysCleared reports if the "api_keys" edge to the ApiKey entity was cleared. +// APIKeysCleared reports if the "api_keys" edge to the APIKey entity was cleared. func (m *UserMutation) APIKeysCleared() bool { return m.clearedapi_keys } -// RemoveAPIKeyIDs removes the "api_keys" edge to the ApiKey entity by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to the APIKey entity by IDs. func (m *UserMutation) RemoveAPIKeyIDs(ids ...int64) { if m.removedapi_keys == nil { m.removedapi_keys = make(map[int64]struct{}) @@ -10768,7 +10768,7 @@ func (m *UserMutation) RemoveAPIKeyIDs(ids ...int64) { } } -// RemovedAPIKeys returns the removed IDs of the "api_keys" edge to the ApiKey entity. +// RemovedAPIKeys returns the removed IDs of the "api_keys" edge to the APIKey entity. func (m *UserMutation) RemovedAPIKeysIDs() (ids []int64) { for id := range m.removedapi_keys { ids = append(ids, id) diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go index ae1bf007..87c56902 100644 --- a/backend/ent/predicate/predicate.go +++ b/backend/ent/predicate/predicate.go @@ -6,15 +6,15 @@ import ( "entgo.io/ent/dialect/sql" ) +// APIKey is the predicate function for apikey builders. +type APIKey func(*sql.Selector) + // Account is the predicate function for account builders. type Account func(*sql.Selector) // AccountGroup is the predicate function for accountgroup builders. type AccountGroup func(*sql.Selector) -// ApiKey is the predicate function for apikey builders. -type ApiKey func(*sql.Selector) - // Group is the predicate function for group builders. type Group func(*sql.Selector) diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 12c3e7e3..517e7195 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -25,6 +25,67 @@ import ( // (default values, validators, hooks and policies) and stitches it // to their package variables. func init() { + apikeyMixin := schema.APIKey{}.Mixin() + apikeyMixinHooks1 := apikeyMixin[1].Hooks() + apikey.Hooks[0] = apikeyMixinHooks1[0] + apikeyMixinInters1 := apikeyMixin[1].Interceptors() + apikey.Interceptors[0] = apikeyMixinInters1[0] + apikeyMixinFields0 := apikeyMixin[0].Fields() + _ = apikeyMixinFields0 + apikeyFields := schema.APIKey{}.Fields() + _ = apikeyFields + // apikeyDescCreatedAt is the schema descriptor for created_at field. + apikeyDescCreatedAt := apikeyMixinFields0[0].Descriptor() + // apikey.DefaultCreatedAt holds the default value on creation for the created_at field. + apikey.DefaultCreatedAt = apikeyDescCreatedAt.Default.(func() time.Time) + // apikeyDescUpdatedAt is the schema descriptor for updated_at field. + apikeyDescUpdatedAt := apikeyMixinFields0[1].Descriptor() + // apikey.DefaultUpdatedAt holds the default value on creation for the updated_at field. + apikey.DefaultUpdatedAt = apikeyDescUpdatedAt.Default.(func() time.Time) + // apikey.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + apikey.UpdateDefaultUpdatedAt = apikeyDescUpdatedAt.UpdateDefault.(func() time.Time) + // apikeyDescKey is the schema descriptor for key field. + apikeyDescKey := apikeyFields[1].Descriptor() + // apikey.KeyValidator is a validator for the "key" field. It is called by the builders before save. + apikey.KeyValidator = func() func(string) error { + validators := apikeyDescKey.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(key string) error { + for _, fn := range fns { + if err := fn(key); err != nil { + return err + } + } + return nil + } + }() + // apikeyDescName is the schema descriptor for name field. + apikeyDescName := apikeyFields[2].Descriptor() + // apikey.NameValidator is a validator for the "name" field. It is called by the builders before save. + apikey.NameValidator = func() func(string) error { + validators := apikeyDescName.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(name string) error { + for _, fn := range fns { + if err := fn(name); err != nil { + return err + } + } + return nil + } + }() + // apikeyDescStatus is the schema descriptor for status field. + apikeyDescStatus := apikeyFields[4].Descriptor() + // apikey.DefaultStatus holds the default value on creation for the status field. + apikey.DefaultStatus = apikeyDescStatus.Default.(string) + // apikey.StatusValidator is a validator for the "status" field. It is called by the builders before save. + apikey.StatusValidator = apikeyDescStatus.Validators[0].(func(string) error) accountMixin := schema.Account{}.Mixin() accountMixinHooks1 := accountMixin[1].Hooks() account.Hooks[0] = accountMixinHooks1[0] @@ -138,67 +199,6 @@ func init() { accountgroupDescCreatedAt := accountgroupFields[3].Descriptor() // accountgroup.DefaultCreatedAt holds the default value on creation for the created_at field. accountgroup.DefaultCreatedAt = accountgroupDescCreatedAt.Default.(func() time.Time) - apikeyMixin := schema.ApiKey{}.Mixin() - apikeyMixinHooks1 := apikeyMixin[1].Hooks() - apikey.Hooks[0] = apikeyMixinHooks1[0] - apikeyMixinInters1 := apikeyMixin[1].Interceptors() - apikey.Interceptors[0] = apikeyMixinInters1[0] - apikeyMixinFields0 := apikeyMixin[0].Fields() - _ = apikeyMixinFields0 - apikeyFields := schema.ApiKey{}.Fields() - _ = apikeyFields - // apikeyDescCreatedAt is the schema descriptor for created_at field. - apikeyDescCreatedAt := apikeyMixinFields0[0].Descriptor() - // apikey.DefaultCreatedAt holds the default value on creation for the created_at field. - apikey.DefaultCreatedAt = apikeyDescCreatedAt.Default.(func() time.Time) - // apikeyDescUpdatedAt is the schema descriptor for updated_at field. - apikeyDescUpdatedAt := apikeyMixinFields0[1].Descriptor() - // apikey.DefaultUpdatedAt holds the default value on creation for the updated_at field. - apikey.DefaultUpdatedAt = apikeyDescUpdatedAt.Default.(func() time.Time) - // apikey.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. - apikey.UpdateDefaultUpdatedAt = apikeyDescUpdatedAt.UpdateDefault.(func() time.Time) - // apikeyDescKey is the schema descriptor for key field. - apikeyDescKey := apikeyFields[1].Descriptor() - // apikey.KeyValidator is a validator for the "key" field. It is called by the builders before save. - apikey.KeyValidator = func() func(string) error { - validators := apikeyDescKey.Validators - fns := [...]func(string) error{ - validators[0].(func(string) error), - validators[1].(func(string) error), - } - return func(key string) error { - for _, fn := range fns { - if err := fn(key); err != nil { - return err - } - } - return nil - } - }() - // apikeyDescName is the schema descriptor for name field. - apikeyDescName := apikeyFields[2].Descriptor() - // apikey.NameValidator is a validator for the "name" field. It is called by the builders before save. - apikey.NameValidator = func() func(string) error { - validators := apikeyDescName.Validators - fns := [...]func(string) error{ - validators[0].(func(string) error), - validators[1].(func(string) error), - } - return func(name string) error { - for _, fn := range fns { - if err := fn(name); err != nil { - return err - } - } - return nil - } - }() - // apikeyDescStatus is the schema descriptor for status field. - apikeyDescStatus := apikeyFields[4].Descriptor() - // apikey.DefaultStatus holds the default value on creation for the status field. - apikey.DefaultStatus = apikeyDescStatus.Default.(string) - // apikey.StatusValidator is a validator for the "status" field. It is called by the builders before save. - apikey.StatusValidator = apikeyDescStatus.Validators[0].(func(string) error) groupMixin := schema.Group{}.Mixin() groupMixinHooks1 := groupMixin[1].Hooks() group.Hooks[0] = groupMixinHooks1[0] diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go index f9ece05e..94e572c5 100644 --- a/backend/ent/schema/api_key.go +++ b/backend/ent/schema/api_key.go @@ -12,25 +12,25 @@ import ( "entgo.io/ent/schema/index" ) -// ApiKey holds the schema definition for the ApiKey entity. -type ApiKey struct { +// APIKey holds the schema definition for the APIKey entity. +type APIKey struct { ent.Schema } -func (ApiKey) Annotations() []schema.Annotation { +func (APIKey) Annotations() []schema.Annotation { return []schema.Annotation{ entsql.Annotation{Table: "api_keys"}, } } -func (ApiKey) Mixin() []ent.Mixin { +func (APIKey) Mixin() []ent.Mixin { return []ent.Mixin{ mixins.TimeMixin{}, mixins.SoftDeleteMixin{}, } } -func (ApiKey) Fields() []ent.Field { +func (APIKey) Fields() []ent.Field { return []ent.Field{ field.Int64("user_id"), field.String("key"). @@ -49,7 +49,7 @@ func (ApiKey) Fields() []ent.Field { } } -func (ApiKey) Edges() []ent.Edge { +func (APIKey) Edges() []ent.Edge { return []ent.Edge{ edge.From("user", User.Type). Ref("api_keys"). @@ -64,7 +64,7 @@ func (ApiKey) Edges() []ent.Edge { } } -func (ApiKey) Indexes() []ent.Index { +func (APIKey) Indexes() []ent.Index { return []ent.Index{ // key 字段已在 Fields() 中声明 Unique(),无需重复索引 index.Fields("user_id"), diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 7a8a5345..93dab1ab 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -77,7 +77,7 @@ func (Group) Fields() []ent.Field { func (Group) Edges() []ent.Edge { return []ent.Edge{ - edge.To("api_keys", ApiKey.Type), + edge.To("api_keys", APIKey.Type), edge.To("redeem_codes", RedeemCode.Type), edge.To("subscriptions", UserSubscription.Type), edge.To("usage_logs", UsageLog.Type), diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index 6f78e8a9..81effa46 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -113,7 +113,7 @@ func (UsageLog) Edges() []ent.Edge { Field("user_id"). Required(). Unique(), - edge.From("api_key", ApiKey.Type). + edge.From("api_key", APIKey.Type). Ref("usage_logs"). Field("api_key_id"). Required(). diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index f29b6123..11fecdfd 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -66,7 +66,7 @@ func (User) Fields() []ent.Field { func (User) Edges() []ent.Edge { return []ent.Edge{ - edge.To("api_keys", ApiKey.Type), + edge.To("api_keys", APIKey.Type), edge.To("redeem_codes", RedeemCode.Type), edge.To("subscriptions", UserSubscription.Type), edge.To("assigned_subscriptions", UserSubscription.Type), diff --git a/backend/ent/tx.go b/backend/ent/tx.go index b1bbdfc5..e45204c0 100644 --- a/backend/ent/tx.go +++ b/backend/ent/tx.go @@ -14,12 +14,12 @@ import ( // Tx is a transactional client that is created by calling Client.Tx(). type Tx struct { config + // APIKey is the client for interacting with the APIKey builders. + APIKey *APIKeyClient // Account is the client for interacting with the Account builders. Account *AccountClient // AccountGroup is the client for interacting with the AccountGroup builders. AccountGroup *AccountGroupClient - // ApiKey is the client for interacting with the ApiKey builders. - ApiKey *ApiKeyClient // Group is the client for interacting with the Group builders. Group *GroupClient // Proxy is the client for interacting with the Proxy builders. @@ -171,9 +171,9 @@ func (tx *Tx) Client() *Client { } func (tx *Tx) init() { + tx.APIKey = NewAPIKeyClient(tx.config) tx.Account = NewAccountClient(tx.config) tx.AccountGroup = NewAccountGroupClient(tx.config) - tx.ApiKey = NewApiKeyClient(tx.config) tx.Group = NewGroupClient(tx.config) tx.Proxy = NewProxyClient(tx.config) tx.RedeemCode = NewRedeemCodeClient(tx.config) @@ -193,7 +193,7 @@ func (tx *Tx) init() { // of them in order to commit or rollback the transaction. // // If a closed transaction is embedded in one of the generated entities, and the entity -// applies a query, for example: Account.QueryXXX(), the query will be executed +// applies a query, for example: APIKey.QueryXXX(), the query will be executed // through the driver which created this transaction. // // Note that txDriver is not goroutine safe. diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index e01780fe..75e3173d 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -83,7 +83,7 @@ type UsageLogEdges struct { // User holds the value of the user edge. User *User `json:"user,omitempty"` // APIKey holds the value of the api_key edge. - APIKey *ApiKey `json:"api_key,omitempty"` + APIKey *APIKey `json:"api_key,omitempty"` // Account holds the value of the account edge. Account *Account `json:"account,omitempty"` // Group holds the value of the group edge. @@ -108,7 +108,7 @@ func (e UsageLogEdges) UserOrErr() (*User, error) { // APIKeyOrErr returns the APIKey value or an error if the edge // was not loaded in eager-loading, or loaded but was not found. -func (e UsageLogEdges) APIKeyOrErr() (*ApiKey, error) { +func (e UsageLogEdges) APIKeyOrErr() (*APIKey, error) { if e.APIKey != nil { return e.APIKey, nil } else if e.loadedTypes[1] { @@ -359,7 +359,7 @@ func (_m *UsageLog) QueryUser() *UserQuery { } // QueryAPIKey queries the "api_key" edge of the UsageLog entity. -func (_m *UsageLog) QueryAPIKey() *ApiKeyQuery { +func (_m *UsageLog) QueryAPIKey() *APIKeyQuery { return NewUsageLogClient(_m.config).QueryAPIKey(_m) } diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index bdc6f7e6..139721c4 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -85,7 +85,7 @@ const ( UserColumn = "user_id" // APIKeyTable is the table that holds the api_key relation/edge. APIKeyTable = "usage_logs" - // APIKeyInverseTable is the table name for the ApiKey entity. + // APIKeyInverseTable is the table name for the APIKey entity. // It exists in this package in order to avoid circular dependency with the "apikey" package. APIKeyInverseTable = "api_keys" // APIKeyColumn is the table column denoting the api_key relation/edge. diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index 9c260433..9db01140 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -1175,7 +1175,7 @@ func HasAPIKey() predicate.UsageLog { } // HasAPIKeyWith applies the HasEdge predicate on the "api_key" edge with a given conditions (other predicates). -func HasAPIKeyWith(preds ...predicate.ApiKey) predicate.UsageLog { +func HasAPIKeyWith(preds ...predicate.APIKey) predicate.UsageLog { return predicate.UsageLog(func(s *sql.Selector) { step := newAPIKeyStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index bcba64b1..36f3d277 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -342,8 +342,8 @@ func (_c *UsageLogCreate) SetUser(v *User) *UsageLogCreate { return _c.SetUserID(v.ID) } -// SetAPIKey sets the "api_key" edge to the ApiKey entity. -func (_c *UsageLogCreate) SetAPIKey(v *ApiKey) *UsageLogCreate { +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_c *UsageLogCreate) SetAPIKey(v *APIKey) *UsageLogCreate { return _c.SetAPIKeyID(v.ID) } diff --git a/backend/ent/usagelog_query.go b/backend/ent/usagelog_query.go index 8e5013cc..de64171a 100644 --- a/backend/ent/usagelog_query.go +++ b/backend/ent/usagelog_query.go @@ -28,7 +28,7 @@ type UsageLogQuery struct { inters []Interceptor predicates []predicate.UsageLog withUser *UserQuery - withAPIKey *ApiKeyQuery + withAPIKey *APIKeyQuery withAccount *AccountQuery withGroup *GroupQuery withSubscription *UserSubscriptionQuery @@ -91,8 +91,8 @@ func (_q *UsageLogQuery) QueryUser() *UserQuery { } // QueryAPIKey chains the current query on the "api_key" edge. -func (_q *UsageLogQuery) QueryAPIKey() *ApiKeyQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *UsageLogQuery) QueryAPIKey() *APIKeyQuery { + query := (&APIKeyClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { return nil, err @@ -394,8 +394,8 @@ func (_q *UsageLogQuery) WithUser(opts ...func(*UserQuery)) *UsageLogQuery { // WithAPIKey tells the query-builder to eager-load the nodes that are connected to // the "api_key" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *UsageLogQuery) WithAPIKey(opts ...func(*ApiKeyQuery)) *UsageLogQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *UsageLogQuery) WithAPIKey(opts ...func(*APIKeyQuery)) *UsageLogQuery { + query := (&APIKeyClient{config: _q.config}).Query() for _, opt := range opts { opt(query) } @@ -548,7 +548,7 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa } if query := _q.withAPIKey; query != nil { if err := _q.loadAPIKey(ctx, query, nodes, nil, - func(n *UsageLog, e *ApiKey) { n.Edges.APIKey = e }); err != nil { + func(n *UsageLog, e *APIKey) { n.Edges.APIKey = e }); err != nil { return nil, err } } @@ -602,7 +602,7 @@ func (_q *UsageLogQuery) loadUser(ctx context.Context, query *UserQuery, nodes [ } return nil } -func (_q *UsageLogQuery) loadAPIKey(ctx context.Context, query *ApiKeyQuery, nodes []*UsageLog, init func(*UsageLog), assign func(*UsageLog, *ApiKey)) error { +func (_q *UsageLogQuery) loadAPIKey(ctx context.Context, query *APIKeyQuery, nodes []*UsageLog, init func(*UsageLog), assign func(*UsageLog, *APIKey)) error { ids := make([]int64, 0, len(nodes)) nodeids := make(map[int64][]*UsageLog) for i := range nodes { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 55b8e234..45ad2e2a 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -509,8 +509,8 @@ func (_u *UsageLogUpdate) SetUser(v *User) *UsageLogUpdate { return _u.SetUserID(v.ID) } -// SetAPIKey sets the "api_key" edge to the ApiKey entity. -func (_u *UsageLogUpdate) SetAPIKey(v *ApiKey) *UsageLogUpdate { +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_u *UsageLogUpdate) SetAPIKey(v *APIKey) *UsageLogUpdate { return _u.SetAPIKeyID(v.ID) } @@ -540,7 +540,7 @@ func (_u *UsageLogUpdate) ClearUser() *UsageLogUpdate { return _u } -// ClearAPIKey clears the "api_key" edge to the ApiKey entity. +// ClearAPIKey clears the "api_key" edge to the APIKey entity. func (_u *UsageLogUpdate) ClearAPIKey() *UsageLogUpdate { _u.mutation.ClearAPIKey() return _u @@ -1380,8 +1380,8 @@ func (_u *UsageLogUpdateOne) SetUser(v *User) *UsageLogUpdateOne { return _u.SetUserID(v.ID) } -// SetAPIKey sets the "api_key" edge to the ApiKey entity. -func (_u *UsageLogUpdateOne) SetAPIKey(v *ApiKey) *UsageLogUpdateOne { +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_u *UsageLogUpdateOne) SetAPIKey(v *APIKey) *UsageLogUpdateOne { return _u.SetAPIKeyID(v.ID) } @@ -1411,7 +1411,7 @@ func (_u *UsageLogUpdateOne) ClearUser() *UsageLogUpdateOne { return _u } -// ClearAPIKey clears the "api_key" edge to the ApiKey entity. +// ClearAPIKey clears the "api_key" edge to the APIKey entity. func (_u *UsageLogUpdateOne) ClearAPIKey() *UsageLogUpdateOne { _u.mutation.ClearAPIKey() return _u diff --git a/backend/ent/user.go b/backend/ent/user.go index d7e1668d..20036475 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -48,7 +48,7 @@ type User struct { // UserEdges holds the relations/edges for other nodes in the graph. type UserEdges struct { // APIKeys holds the value of the api_keys edge. - APIKeys []*ApiKey `json:"api_keys,omitempty"` + APIKeys []*APIKey `json:"api_keys,omitempty"` // RedeemCodes holds the value of the redeem_codes edge. RedeemCodes []*RedeemCode `json:"redeem_codes,omitempty"` // Subscriptions holds the value of the subscriptions edge. @@ -70,7 +70,7 @@ type UserEdges struct { // APIKeysOrErr returns the APIKeys value or an error if the edge // was not loaded in eager-loading. -func (e UserEdges) APIKeysOrErr() ([]*ApiKey, error) { +func (e UserEdges) APIKeysOrErr() ([]*APIKey, error) { if e.loadedTypes[0] { return e.APIKeys, nil } @@ -255,7 +255,7 @@ func (_m *User) Value(name string) (ent.Value, error) { } // QueryAPIKeys queries the "api_keys" edge of the User entity. -func (_m *User) QueryAPIKeys() *ApiKeyQuery { +func (_m *User) QueryAPIKeys() *APIKeyQuery { return NewUserClient(_m.config).QueryAPIKeys(_m) } diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index 9c40ab09..a6871c5d 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -57,7 +57,7 @@ const ( Table = "users" // APIKeysTable is the table that holds the api_keys relation/edge. APIKeysTable = "api_keys" - // APIKeysInverseTable is the table name for the ApiKey entity. + // APIKeysInverseTable is the table name for the APIKey entity. // It exists in this package in order to avoid circular dependency with the "apikey" package. APIKeysInverseTable = "api_keys" // APIKeysColumn is the table column denoting the api_keys relation/edge. diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index c3db075e..38812770 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -722,7 +722,7 @@ func HasAPIKeys() predicate.User { } // HasAPIKeysWith applies the HasEdge predicate on the "api_keys" edge with a given conditions (other predicates). -func HasAPIKeysWith(preds ...predicate.ApiKey) predicate.User { +func HasAPIKeysWith(preds ...predicate.APIKey) predicate.User { return predicate.User(func(s *sql.Selector) { step := newAPIKeysStep() sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index 6313db5f..4ce48d4b 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -166,14 +166,14 @@ func (_c *UserCreate) SetNillableNotes(v *string) *UserCreate { return _c } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate { _c.mutation.AddAPIKeyIDs(ids...) return _c } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_c *UserCreate) AddAPIKeys(v ...*ApiKey) *UserCreate { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_c *UserCreate) AddAPIKeys(v ...*APIKey) *UserCreate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go index 80b182c1..0d65a2dd 100644 --- a/backend/ent/user_query.go +++ b/backend/ent/user_query.go @@ -30,7 +30,7 @@ type UserQuery struct { order []user.OrderOption inters []Interceptor predicates []predicate.User - withAPIKeys *ApiKeyQuery + withAPIKeys *APIKeyQuery withRedeemCodes *RedeemCodeQuery withSubscriptions *UserSubscriptionQuery withAssignedSubscriptions *UserSubscriptionQuery @@ -75,8 +75,8 @@ func (_q *UserQuery) Order(o ...user.OrderOption) *UserQuery { } // QueryAPIKeys chains the current query on the "api_keys" edge. -func (_q *UserQuery) QueryAPIKeys() *ApiKeyQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *UserQuery) QueryAPIKeys() *APIKeyQuery { + query := (&APIKeyClient{config: _q.config}).Query() query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { if err := _q.prepareQuery(ctx); err != nil { return nil, err @@ -458,8 +458,8 @@ func (_q *UserQuery) Clone() *UserQuery { // WithAPIKeys tells the query-builder to eager-load the nodes that are connected to // the "api_keys" edge. The optional arguments are used to configure the query builder of the edge. -func (_q *UserQuery) WithAPIKeys(opts ...func(*ApiKeyQuery)) *UserQuery { - query := (&ApiKeyClient{config: _q.config}).Query() +func (_q *UserQuery) WithAPIKeys(opts ...func(*APIKeyQuery)) *UserQuery { + query := (&APIKeyClient{config: _q.config}).Query() for _, opt := range opts { opt(query) } @@ -653,8 +653,8 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e } if query := _q.withAPIKeys; query != nil { if err := _q.loadAPIKeys(ctx, query, nodes, - func(n *User) { n.Edges.APIKeys = []*ApiKey{} }, - func(n *User, e *ApiKey) { n.Edges.APIKeys = append(n.Edges.APIKeys, e) }); err != nil { + func(n *User) { n.Edges.APIKeys = []*APIKey{} }, + func(n *User, e *APIKey) { n.Edges.APIKeys = append(n.Edges.APIKeys, e) }); err != nil { return nil, err } } @@ -712,7 +712,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nodes, nil } -func (_q *UserQuery) loadAPIKeys(ctx context.Context, query *ApiKeyQuery, nodes []*User, init func(*User), assign func(*User, *ApiKey)) error { +func (_q *UserQuery) loadAPIKeys(ctx context.Context, query *APIKeyQuery, nodes []*User, init func(*User), assign func(*User, *APIKey)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[int64]*User) for i := range nodes { @@ -725,7 +725,7 @@ func (_q *UserQuery) loadAPIKeys(ctx context.Context, query *ApiKeyQuery, nodes if len(query.ctx.Fields) > 0 { query.ctx.AppendFieldOnce(apikey.FieldUserID) } - query.Where(predicate.ApiKey(func(s *sql.Selector) { + query.Where(predicate.APIKey(func(s *sql.Selector) { s.Where(sql.InValues(s.C(user.APIKeysColumn), fks...)) })) neighbors, err := query.All(ctx) diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index ed5d3a76..49ddf493 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -186,14 +186,14 @@ func (_u *UserUpdate) SetNillableNotes(v *string) *UserUpdate { return _u } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate { _u.mutation.AddAPIKeyIDs(ids...) return _u } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_u *UserUpdate) AddAPIKeys(v ...*ApiKey) *UserUpdate { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_u *UserUpdate) AddAPIKeys(v ...*APIKey) *UserUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -296,20 +296,20 @@ func (_u *UserUpdate) Mutation() *UserMutation { return _u.mutation } -// ClearAPIKeys clears all "api_keys" edges to the ApiKey entity. +// ClearAPIKeys clears all "api_keys" edges to the APIKey entity. func (_u *UserUpdate) ClearAPIKeys() *UserUpdate { _u.mutation.ClearAPIKeys() return _u } -// RemoveAPIKeyIDs removes the "api_keys" edge to ApiKey entities by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to APIKey entities by IDs. func (_u *UserUpdate) RemoveAPIKeyIDs(ids ...int64) *UserUpdate { _u.mutation.RemoveAPIKeyIDs(ids...) return _u } -// RemoveAPIKeys removes "api_keys" edges to ApiKey entities. -func (_u *UserUpdate) RemoveAPIKeys(v ...*ApiKey) *UserUpdate { +// RemoveAPIKeys removes "api_keys" edges to APIKey entities. +func (_u *UserUpdate) RemoveAPIKeys(v ...*APIKey) *UserUpdate { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -1065,14 +1065,14 @@ func (_u *UserUpdateOne) SetNillableNotes(v *string) *UserUpdateOne { return _u } -// AddAPIKeyIDs adds the "api_keys" edge to the ApiKey entity by IDs. +// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) return _u } -// AddAPIKeys adds the "api_keys" edges to the ApiKey entity. -func (_u *UserUpdateOne) AddAPIKeys(v ...*ApiKey) *UserUpdateOne { +// AddAPIKeys adds the "api_keys" edges to the APIKey entity. +func (_u *UserUpdateOne) AddAPIKeys(v ...*APIKey) *UserUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID @@ -1175,20 +1175,20 @@ func (_u *UserUpdateOne) Mutation() *UserMutation { return _u.mutation } -// ClearAPIKeys clears all "api_keys" edges to the ApiKey entity. +// ClearAPIKeys clears all "api_keys" edges to the APIKey entity. func (_u *UserUpdateOne) ClearAPIKeys() *UserUpdateOne { _u.mutation.ClearAPIKeys() return _u } -// RemoveAPIKeyIDs removes the "api_keys" edge to ApiKey entities by IDs. +// RemoveAPIKeyIDs removes the "api_keys" edge to APIKey entities by IDs. func (_u *UserUpdateOne) RemoveAPIKeyIDs(ids ...int64) *UserUpdateOne { _u.mutation.RemoveAPIKeyIDs(ids...) return _u } -// RemoveAPIKeys removes "api_keys" edges to ApiKey entities. -func (_u *UserUpdateOne) RemoveAPIKeys(v ...*ApiKey) *UserUpdateOne { +// RemoveAPIKeys removes "api_keys" edges to APIKey entities. +func (_u *UserUpdateOne) RemoveAPIKeys(v ...*APIKey) *UserUpdateOne { ids := make([]int64, len(v)) for i := range v { ids[i] = v[i].ID diff --git a/backend/go.mod b/backend/go.mod index 73bbf95c..4c00bd2a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -69,6 +69,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/subcommands v1.2.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8272855e..ee3c61e9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -118,6 +118,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f22539eb..b91e9d7c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config provides application configuration management. package config import ( @@ -139,7 +140,7 @@ type GatewayConfig struct { LogUpstreamErrorBodyMaxBytes int `mapstructure:"log_upstream_error_body_max_bytes"` // API-key 账号在客户端未提供 anthropic-beta 时,是否按需自动补齐(默认关闭以保持兼容) - InjectBetaForApiKey bool `mapstructure:"inject_beta_for_apikey"` + InjectBetaForAPIKey bool `mapstructure:"inject_beta_for_apikey"` // 是否允许对部分 400 错误触发 failover(默认关闭以避免改变语义) FailoverOn400 bool `mapstructure:"failover_on_400"` @@ -241,7 +242,7 @@ type DefaultConfig struct { AdminPassword string `mapstructure:"admin_password"` UserConcurrency int `mapstructure:"user_concurrency"` UserBalance float64 `mapstructure:"user_balance"` - ApiKeyPrefix string `mapstructure:"api_key_prefix"` + APIKeyPrefix string `mapstructure:"api_key_prefix"` RateMultiplier float64 `mapstructure:"rate_multiplier"` } diff --git a/backend/internal/config/wire.go b/backend/internal/config/wire.go index ec26c401..60ee3d3b 100644 --- a/backend/internal/config/wire.go +++ b/backend/internal/config/wire.go @@ -1,3 +1,4 @@ +// Package config provides application configuration management. package config import "github.com/google/wire" diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index a7dc6c4e..2c8eb23a 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -1,3 +1,5 @@ +// Package admin provides HTTP handlers for administrative operations including +// dashboard statistics, user management, API key management, and account management. package admin import ( @@ -75,8 +77,8 @@ func (h *DashboardHandler) GetStats(c *gin.Context) { "active_users": stats.ActiveUsers, // API Key 统计 - "total_api_keys": stats.TotalApiKeys, - "active_api_keys": stats.ActiveApiKeys, + "total_api_keys": stats.TotalAPIKeys, + "active_api_keys": stats.ActiveAPIKeys, // 账户统计 "total_accounts": stats.TotalAccounts, @@ -193,10 +195,10 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { }) } -// GetApiKeyUsageTrend handles getting API key usage trend data +// GetAPIKeyUsageTrend handles getting API key usage trend data // GET /api/v1/admin/dashboard/api-keys-trend // Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), limit (default 5) -func (h *DashboardHandler) GetApiKeyUsageTrend(c *gin.Context) { +func (h *DashboardHandler) GetAPIKeyUsageTrend(c *gin.Context) { startTime, endTime := parseTimeRange(c) granularity := c.DefaultQuery("granularity", "day") limitStr := c.DefaultQuery("limit", "5") @@ -205,7 +207,7 @@ func (h *DashboardHandler) GetApiKeyUsageTrend(c *gin.Context) { limit = 5 } - trend, err := h.dashboardService.GetApiKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit) + trend, err := h.dashboardService.GetAPIKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit) if err != nil { response.Error(c, 500, "Failed to get API key usage trend") return @@ -273,26 +275,26 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) { response.Success(c, gin.H{"stats": stats}) } -// BatchApiKeysUsageRequest represents the request body for batch api key usage stats -type BatchApiKeysUsageRequest struct { - ApiKeyIDs []int64 `json:"api_key_ids" binding:"required"` +// BatchAPIKeysUsageRequest represents the request body for batch api key usage stats +type BatchAPIKeysUsageRequest struct { + APIKeyIDs []int64 `json:"api_key_ids" binding:"required"` } -// GetBatchApiKeysUsage handles getting usage stats for multiple API keys +// GetBatchAPIKeysUsage handles getting usage stats for multiple API keys // POST /api/v1/admin/dashboard/api-keys-usage -func (h *DashboardHandler) GetBatchApiKeysUsage(c *gin.Context) { - var req BatchApiKeysUsageRequest +func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) { + var req BatchAPIKeysUsageRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if len(req.ApiKeyIDs) == 0 { + if len(req.APIKeyIDs) == 0 { response.Success(c, gin.H{"stats": map[string]any{}}) return } - stats, err := h.dashboardService.GetBatchApiKeyUsageStats(c.Request.Context(), req.ApiKeyIDs) + stats, err := h.dashboardService.GetBatchAPIKeyUsageStats(c.Request.Context(), req.APIKeyIDs) if err != nil { response.Error(c, 500, "Failed to get API key usage stats") return diff --git a/backend/internal/handler/admin/gemini_oauth_handler.go b/backend/internal/handler/admin/gemini_oauth_handler.go index 037800e2..2f0597c6 100644 --- a/backend/internal/handler/admin/gemini_oauth_handler.go +++ b/backend/internal/handler/admin/gemini_oauth_handler.go @@ -18,6 +18,7 @@ func NewGeminiOAuthHandler(geminiOAuthService *service.GeminiOAuthService) *Gemi return &GeminiOAuthHandler{geminiOAuthService: geminiOAuthService} } +// GetCapabilities retrieves OAuth configuration capabilities. // GET /api/v1/admin/gemini/oauth/capabilities func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) { cfg := h.geminiOAuthService.GetOAuthConfig() diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 30225b76..1ca54aaf 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -237,9 +237,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { return } - outKeys := make([]dto.ApiKey, 0, len(keys)) + outKeys := make([]dto.APIKey, 0, len(keys)) for i := range keys { - outKeys = append(outKeys, *dto.ApiKeyFromService(&keys[i])) + outKeys = append(outKeys, *dto.APIKeyFromService(&keys[i])) } response.Paginated(c, outKeys, total, page, pageSize) } diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go new file mode 100644 index 00000000..0d1402fe --- /dev/null +++ b/backend/internal/handler/admin/ops_handler.go @@ -0,0 +1,402 @@ +package admin + +import ( + "math" + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// OpsHandler handles ops dashboard endpoints. +type OpsHandler struct { + opsService *service.OpsService +} + +// NewOpsHandler creates a new OpsHandler. +func NewOpsHandler(opsService *service.OpsService) *OpsHandler { + return &OpsHandler{opsService: opsService} +} + +// GetMetrics returns the latest ops metrics snapshot. +// GET /api/v1/admin/ops/metrics +func (h *OpsHandler) GetMetrics(c *gin.Context) { + metrics, err := h.opsService.GetLatestMetrics(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get ops metrics") + return + } + response.Success(c, metrics) +} + +// ListMetricsHistory returns a time-range slice of metrics for charts. +// GET /api/v1/admin/ops/metrics/history +// +// Query params: +// - window_minutes: int (default 1) +// - minutes: int (lookback; optional) +// - start_time/end_time: RFC3339 timestamps (optional; overrides minutes when provided) +// - limit: int (optional; max 100, default 300 for backward compatibility) +func (h *OpsHandler) ListMetricsHistory(c *gin.Context) { + windowMinutes := 1 + if v := c.Query("window_minutes"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + windowMinutes = parsed + } else { + response.BadRequest(c, "Invalid window_minutes") + return + } + } + + limit := 300 + limitProvided := false + if v := c.Query("limit"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed <= 0 || parsed > 5000 { + response.BadRequest(c, "Invalid limit (must be 1-5000)") + return + } + limit = parsed + limitProvided = true + } + + endTime := time.Now() + startTime := time.Time{} + + if startTimeStr := c.Query("start_time"); startTimeStr != "" { + parsed, err := time.Parse(time.RFC3339, startTimeStr) + if err != nil { + response.BadRequest(c, "Invalid start_time format (RFC3339)") + return + } + startTime = parsed + } + if endTimeStr := c.Query("end_time"); endTimeStr != "" { + parsed, err := time.Parse(time.RFC3339, endTimeStr) + if err != nil { + response.BadRequest(c, "Invalid end_time format (RFC3339)") + return + } + endTime = parsed + } + + // If explicit range not provided, use lookback minutes. + if startTime.IsZero() { + if v := c.Query("minutes"); v != "" { + minutes, err := strconv.Atoi(v) + if err != nil || minutes <= 0 { + response.BadRequest(c, "Invalid minutes") + return + } + if minutes > 60*24*7 { + minutes = 60 * 24 * 7 + } + startTime = endTime.Add(-time.Duration(minutes) * time.Minute) + } + } + + // Default time range: last 24 hours. + if startTime.IsZero() { + startTime = endTime.Add(-24 * time.Hour) + if !limitProvided { + // Metrics are collected at 1-minute cadence; 24h requires ~1440 points. + limit = 24 * 60 + } + } + + if startTime.After(endTime) { + response.BadRequest(c, "Invalid time range: start_time must be <= end_time") + return + } + + items, err := h.opsService.ListMetricsHistory(c.Request.Context(), windowMinutes, startTime, endTime, limit) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to list ops metrics history") + return + } + response.Success(c, gin.H{"items": items}) +} + +// ListErrorLogs lists recent error logs with optional filters. +// GET /api/v1/admin/ops/error-logs +// +// Query params: +// - start_time/end_time: RFC3339 timestamps (optional) +// - platform: string (optional) +// - phase: string (optional) +// - severity: string (optional) +// - q: string (optional; fuzzy match) +// - limit: int (optional; default 100; max 500) +func (h *OpsHandler) ListErrorLogs(c *gin.Context) { + var filters service.OpsErrorLogFilters + + if startTimeStr := c.Query("start_time"); startTimeStr != "" { + startTime, err := time.Parse(time.RFC3339, startTimeStr) + if err != nil { + response.BadRequest(c, "Invalid start_time format (RFC3339)") + return + } + filters.StartTime = &startTime + } + if endTimeStr := c.Query("end_time"); endTimeStr != "" { + endTime, err := time.Parse(time.RFC3339, endTimeStr) + if err != nil { + response.BadRequest(c, "Invalid end_time format (RFC3339)") + return + } + filters.EndTime = &endTime + } + + if filters.StartTime != nil && filters.EndTime != nil && filters.StartTime.After(*filters.EndTime) { + response.BadRequest(c, "Invalid time range: start_time must be <= end_time") + return + } + + filters.Platform = c.Query("platform") + filters.Phase = c.Query("phase") + filters.Severity = c.Query("severity") + filters.Query = c.Query("q") + + filters.Limit = 100 + if limitStr := c.Query("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 || limit > 500 { + response.BadRequest(c, "Invalid limit (must be 1-500)") + return + } + filters.Limit = limit + } + + items, total, err := h.opsService.ListErrorLogs(c.Request.Context(), filters) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to list error logs") + return + } + + response.Success(c, gin.H{ + "items": items, + "total": total, + }) +} + +// GetDashboardOverview returns realtime ops dashboard overview. +// GET /api/v1/admin/ops/dashboard/overview +// +// Query params: +// - time_range: string (optional; default "1h") one of: 5m, 30m, 1h, 6h, 24h +func (h *OpsHandler) GetDashboardOverview(c *gin.Context) { + timeRange := c.Query("time_range") + if timeRange == "" { + timeRange = "1h" + } + + switch timeRange { + case "5m", "30m", "1h", "6h", "24h": + default: + response.BadRequest(c, "Invalid time_range (supported: 5m, 30m, 1h, 6h, 24h)") + return + } + + data, err := h.opsService.GetDashboardOverview(c.Request.Context(), timeRange) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get dashboard overview") + return + } + response.Success(c, data) +} + +// GetProviderHealth returns upstream provider health comparison data. +// GET /api/v1/admin/ops/dashboard/providers +// +// Query params: +// - time_range: string (optional; default "1h") one of: 5m, 30m, 1h, 6h, 24h +func (h *OpsHandler) GetProviderHealth(c *gin.Context) { + timeRange := c.Query("time_range") + if timeRange == "" { + timeRange = "1h" + } + + switch timeRange { + case "5m", "30m", "1h", "6h", "24h": + default: + response.BadRequest(c, "Invalid time_range (supported: 5m, 30m, 1h, 6h, 24h)") + return + } + + providers, err := h.opsService.GetProviderHealth(c.Request.Context(), timeRange) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get provider health") + return + } + + var totalRequests int64 + var weightedSuccess float64 + var bestProvider string + var worstProvider string + var bestRate float64 + var worstRate float64 + hasRate := false + + for _, p := range providers { + if p == nil { + continue + } + totalRequests += p.RequestCount + weightedSuccess += (p.SuccessRate / 100) * float64(p.RequestCount) + + if p.RequestCount <= 0 { + continue + } + if !hasRate { + bestProvider = p.Name + worstProvider = p.Name + bestRate = p.SuccessRate + worstRate = p.SuccessRate + hasRate = true + continue + } + + if p.SuccessRate > bestRate { + bestProvider = p.Name + bestRate = p.SuccessRate + } + if p.SuccessRate < worstRate { + worstProvider = p.Name + worstRate = p.SuccessRate + } + } + + avgSuccessRate := 0.0 + if totalRequests > 0 { + avgSuccessRate = (weightedSuccess / float64(totalRequests)) * 100 + avgSuccessRate = math.Round(avgSuccessRate*100) / 100 + } + + response.Success(c, gin.H{ + "providers": providers, + "summary": gin.H{ + "total_requests": totalRequests, + "avg_success_rate": avgSuccessRate, + "best_provider": bestProvider, + "worst_provider": worstProvider, + }, + }) +} + +// GetErrorLogs returns a paginated error log list with multi-dimensional filters. +// GET /api/v1/admin/ops/errors +func (h *OpsHandler) GetErrorLogs(c *gin.Context) { + page, pageSize := response.ParsePagination(c) + + filter := &service.ErrorLogFilter{ + Page: page, + PageSize: pageSize, + } + + if startTimeStr := c.Query("start_time"); startTimeStr != "" { + startTime, err := time.Parse(time.RFC3339, startTimeStr) + if err != nil { + response.BadRequest(c, "Invalid start_time format (RFC3339)") + return + } + filter.StartTime = &startTime + } + if endTimeStr := c.Query("end_time"); endTimeStr != "" { + endTime, err := time.Parse(time.RFC3339, endTimeStr) + if err != nil { + response.BadRequest(c, "Invalid end_time format (RFC3339)") + return + } + filter.EndTime = &endTime + } + + if filter.StartTime != nil && filter.EndTime != nil && filter.StartTime.After(*filter.EndTime) { + response.BadRequest(c, "Invalid time range: start_time must be <= end_time") + return + } + + if errorCodeStr := c.Query("error_code"); errorCodeStr != "" { + code, err := strconv.Atoi(errorCodeStr) + if err != nil || code < 0 { + response.BadRequest(c, "Invalid error_code") + return + } + filter.ErrorCode = &code + } + + // Keep both parameter names for compatibility: provider (docs) and platform (legacy). + filter.Provider = c.Query("provider") + if filter.Provider == "" { + filter.Provider = c.Query("platform") + } + + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + accountID, err := strconv.ParseInt(accountIDStr, 10, 64) + if err != nil || accountID <= 0 { + response.BadRequest(c, "Invalid account_id") + return + } + filter.AccountID = &accountID + } + + out, err := h.opsService.GetErrorLogs(c.Request.Context(), filter) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get error logs") + return + } + + response.Success(c, gin.H{ + "errors": out.Errors, + "total": out.Total, + "page": out.Page, + "page_size": out.PageSize, + }) +} + +// GetLatencyHistogram returns the latency distribution histogram. +// GET /api/v1/admin/ops/dashboard/latency-histogram +func (h *OpsHandler) GetLatencyHistogram(c *gin.Context) { + timeRange := c.Query("time_range") + if timeRange == "" { + timeRange = "1h" + } + + buckets, err := h.opsService.GetLatencyHistogram(c.Request.Context(), timeRange) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get latency histogram") + return + } + + totalRequests := int64(0) + for _, b := range buckets { + totalRequests += b.Count + } + + response.Success(c, gin.H{ + "buckets": buckets, + "total_requests": totalRequests, + "slow_request_threshold": 1000, + }) +} + +// GetErrorDistribution returns the error distribution. +// GET /api/v1/admin/ops/dashboard/errors/distribution +func (h *OpsHandler) GetErrorDistribution(c *gin.Context) { + timeRange := c.Query("time_range") + if timeRange == "" { + timeRange = "1h" + } + + items, err := h.opsService.GetErrorDistribution(c.Request.Context(), timeRange) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get error distribution") + return + } + + response.Success(c, gin.H{ + "items": items, + }) +} diff --git a/backend/internal/handler/admin/ops_ws_handler.go b/backend/internal/handler/admin/ops_ws_handler.go new file mode 100644 index 00000000..429f6ae4 --- /dev/null +++ b/backend/internal/handler/admin/ops_ws_handler.go @@ -0,0 +1,286 @@ +package admin + +import ( + "context" + "encoding/json" + "log" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type OpsWSProxyConfig struct { + TrustProxy bool + TrustedProxies []netip.Prefix + OriginPolicy string +} + +const ( + envOpsWSTrustProxy = "OPS_WS_TRUST_PROXY" + envOpsWSTrustedProxies = "OPS_WS_TRUSTED_PROXIES" + envOpsWSOriginPolicy = "OPS_WS_ORIGIN_POLICY" +) + +const ( + OriginPolicyStrict = "strict" + OriginPolicyPermissive = "permissive" +) + +var opsWSProxyConfig = loadOpsWSProxyConfigFromEnv() + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return isAllowedOpsWSOrigin(r) + }, +} + +// QPSWSHandler handles realtime QPS push via WebSocket. +// GET /api/v1/admin/ops/ws/qps +func (h *OpsHandler) QPSWSHandler(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("[OpsWS] upgrade failed: %v", err) + return + } + defer func() { _ = conn.Close() }() + + // Set pong handler + if err := conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil { + log.Printf("[OpsWS] set read deadline failed: %v", err) + return + } + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + }) + + // Push QPS data every 2 seconds + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + // Heartbeat ping every 30 seconds + pingTicker := time.NewTicker(30 * time.Second) + defer pingTicker.Stop() + + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + for { + select { + case <-ticker.C: + // Fetch 1m window stats for current QPS + data, err := h.opsService.GetDashboardOverview(ctx, "5m") + if err != nil { + log.Printf("[OpsWS] get overview failed: %v", err) + continue + } + + payload := gin.H{ + "type": "qps_update", + "timestamp": time.Now().Format(time.RFC3339), + "data": gin.H{ + "qps": data.QPS.Current, + "tps": data.TPS.Current, + "request_count": data.Errors.TotalCount + int64(data.QPS.Avg1h*60), // Rough estimate + }, + } + + msg, _ := json.Marshal(payload) + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + log.Printf("[OpsWS] write failed: %v", err) + return + } + case <-pingTicker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Printf("[OpsWS] ping failed: %v", err) + return + } + case <-ctx.Done(): + return + } + } +} + +func isAllowedOpsWSOrigin(r *http.Request) bool { + if r == nil { + return false + } + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + switch strings.ToLower(strings.TrimSpace(opsWSProxyConfig.OriginPolicy)) { + case OriginPolicyStrict: + return false + case OriginPolicyPermissive, "": + return true + default: + return true + } + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Hostname() == "" { + return false + } + originHost := strings.ToLower(parsed.Hostname()) + + trustProxyHeaders := shouldTrustOpsWSProxyHeaders(r) + reqHost := hostWithoutPort(r.Host) + if trustProxyHeaders { + xfHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if xfHost != "" { + xfHost = strings.TrimSpace(strings.Split(xfHost, ",")[0]) + if xfHost != "" { + reqHost = hostWithoutPort(xfHost) + } + } + } + reqHost = strings.ToLower(reqHost) + if reqHost == "" { + return false + } + return originHost == reqHost +} + +func shouldTrustOpsWSProxyHeaders(r *http.Request) bool { + if r == nil { + return false + } + if !opsWSProxyConfig.TrustProxy { + return false + } + peerIP, ok := requestPeerIP(r) + if !ok { + return false + } + return isAddrInTrustedProxies(peerIP, opsWSProxyConfig.TrustedProxies) +} + +func requestPeerIP(r *http.Request) (netip.Addr, bool) { + if r == nil { + return netip.Addr{}, false + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + host = strings.TrimSpace(r.RemoteAddr) + } + host = strings.TrimPrefix(host, "[") + host = strings.TrimSuffix(host, "]") + if host == "" { + return netip.Addr{}, false + } + addr, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + return addr.Unmap(), true +} + +func isAddrInTrustedProxies(addr netip.Addr, trusted []netip.Prefix) bool { + if !addr.IsValid() { + return false + } + for _, p := range trusted { + if p.Contains(addr) { + return true + } + } + return false +} + +func loadOpsWSProxyConfigFromEnv() OpsWSProxyConfig { + cfg := OpsWSProxyConfig{ + TrustProxy: true, + TrustedProxies: defaultTrustedProxies(), + OriginPolicy: OriginPolicyPermissive, + } + + if v := strings.TrimSpace(os.Getenv(envOpsWSTrustProxy)); v != "" { + if parsed, err := strconv.ParseBool(v); err == nil { + cfg.TrustProxy = parsed + } else { + log.Printf("[OpsWS] invalid %s=%q (expected bool); using default=%v", envOpsWSTrustProxy, v, cfg.TrustProxy) + } + } + + if raw := strings.TrimSpace(os.Getenv(envOpsWSTrustedProxies)); raw != "" { + prefixes, invalid := parseTrustedProxyList(raw) + if len(invalid) > 0 { + log.Printf("[OpsWS] invalid %s entries ignored: %s", envOpsWSTrustedProxies, strings.Join(invalid, ", ")) + } + cfg.TrustedProxies = prefixes + } + + if v := strings.TrimSpace(os.Getenv(envOpsWSOriginPolicy)); v != "" { + normalized := strings.ToLower(v) + switch normalized { + case OriginPolicyStrict, OriginPolicyPermissive: + cfg.OriginPolicy = normalized + default: + log.Printf("[OpsWS] invalid %s=%q (expected %q or %q); using default=%q", envOpsWSOriginPolicy, v, OriginPolicyStrict, OriginPolicyPermissive, cfg.OriginPolicy) + } + } + + return cfg +} + +func defaultTrustedProxies() []netip.Prefix { + prefixes, _ := parseTrustedProxyList("127.0.0.0/8,::1/128") + return prefixes +} + +func parseTrustedProxyList(raw string) (prefixes []netip.Prefix, invalid []string) { + for _, token := range strings.Split(raw, ",") { + item := strings.TrimSpace(token) + if item == "" { + continue + } + + var ( + p netip.Prefix + err error + ) + if strings.Contains(item, "/") { + p, err = netip.ParsePrefix(item) + } else { + var addr netip.Addr + addr, err = netip.ParseAddr(item) + if err == nil { + addr = addr.Unmap() + bits := 128 + if addr.Is4() { + bits = 32 + } + p = netip.PrefixFrom(addr, bits) + } + } + + if err != nil || !p.IsValid() { + invalid = append(invalid, item) + continue + } + + prefixes = append(prefixes, p.Masked()) + } + return prefixes, invalid +} + +func hostWithoutPort(hostport string) string { + hostport = strings.TrimSpace(hostport) + if hostport == "" { + return "" + } + if host, _, err := net.SplitHostPort(hostport); err == nil { + return host + } + if strings.HasPrefix(hostport, "[") && strings.HasSuffix(hostport, "]") { + return strings.Trim(hostport, "[]") + } + parts := strings.Split(hostport, ":") + return parts[0] +} diff --git a/backend/internal/handler/admin/ops_ws_handler_test.go b/backend/internal/handler/admin/ops_ws_handler_test.go new file mode 100644 index 00000000..b53a3723 --- /dev/null +++ b/backend/internal/handler/admin/ops_ws_handler_test.go @@ -0,0 +1,123 @@ +package admin + +import ( + "net/http" + "net/netip" + "testing" +) + +func TestIsAllowedOpsWSOrigin_AllowsEmptyOrigin(t *testing.T) { + original := opsWSProxyConfig + t.Cleanup(func() { opsWSProxyConfig = original }) + opsWSProxyConfig = OpsWSProxyConfig{OriginPolicy: OriginPolicyPermissive} + + req, err := http.NewRequest(http.MethodGet, "http://example.test", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + if !isAllowedOpsWSOrigin(req) { + t.Fatalf("expected empty Origin to be allowed") + } +} + +func TestIsAllowedOpsWSOrigin_RejectsEmptyOrigin_WhenStrict(t *testing.T) { + original := opsWSProxyConfig + t.Cleanup(func() { opsWSProxyConfig = original }) + opsWSProxyConfig = OpsWSProxyConfig{OriginPolicy: OriginPolicyStrict} + + req, err := http.NewRequest(http.MethodGet, "http://example.test", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + if isAllowedOpsWSOrigin(req) { + t.Fatalf("expected empty Origin to be rejected under strict policy") + } +} + +func TestIsAllowedOpsWSOrigin_UsesXForwardedHostOnlyFromTrustedProxy(t *testing.T) { + original := opsWSProxyConfig + t.Cleanup(func() { opsWSProxyConfig = original }) + + opsWSProxyConfig = OpsWSProxyConfig{ + TrustProxy: true, + TrustedProxies: []netip.Prefix{ + netip.MustParsePrefix("127.0.0.0/8"), + }, + } + + // Untrusted peer: ignore X-Forwarded-Host and compare against r.Host. + { + req, err := http.NewRequest(http.MethodGet, "http://internal.service.local", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.RemoteAddr = "192.0.2.1:12345" + req.Host = "internal.service.local" + req.Header.Set("Origin", "https://public.example.com") + req.Header.Set("X-Forwarded-Host", "public.example.com") + + if isAllowedOpsWSOrigin(req) { + t.Fatalf("expected Origin to be rejected when peer is not a trusted proxy") + } + } + + // Trusted peer: allow X-Forwarded-Host to participate in Origin validation. + { + req, err := http.NewRequest(http.MethodGet, "http://internal.service.local", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.RemoteAddr = "127.0.0.1:23456" + req.Host = "internal.service.local" + req.Header.Set("Origin", "https://public.example.com") + req.Header.Set("X-Forwarded-Host", "public.example.com") + + if !isAllowedOpsWSOrigin(req) { + t.Fatalf("expected Origin to be accepted when peer is a trusted proxy") + } + } +} + +func TestLoadOpsWSProxyConfigFromEnv_OriginPolicy(t *testing.T) { + t.Setenv(envOpsWSOriginPolicy, "STRICT") + cfg := loadOpsWSProxyConfigFromEnv() + if cfg.OriginPolicy != OriginPolicyStrict { + t.Fatalf("OriginPolicy=%q, want %q", cfg.OriginPolicy, OriginPolicyStrict) + } +} + +func TestLoadOpsWSProxyConfigFromEnv_OriginPolicyInvalidUsesDefault(t *testing.T) { + t.Setenv(envOpsWSOriginPolicy, "nope") + cfg := loadOpsWSProxyConfigFromEnv() + if cfg.OriginPolicy != OriginPolicyPermissive { + t.Fatalf("OriginPolicy=%q, want %q", cfg.OriginPolicy, OriginPolicyPermissive) + } +} + +func TestParseTrustedProxyList(t *testing.T) { + prefixes, invalid := parseTrustedProxyList("10.0.0.1, 10.0.0.0/8, bad, ::1/128") + if len(prefixes) != 3 { + t.Fatalf("prefixes=%d, want 3", len(prefixes)) + } + if len(invalid) != 1 || invalid[0] != "bad" { + t.Fatalf("invalid=%v, want [bad]", invalid) + } +} + +func TestRequestPeerIP_ParsesIPv6(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://example.test", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.RemoteAddr = "[::1]:1234" + + addr, ok := requestPeerIP(req) + if !ok { + t.Fatalf("expected IPv6 peer IP to parse") + } + if addr != netip.MustParseAddr("::1") { + t.Fatalf("addr=%s, want ::1", addr) + } +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index e533aef1..05e9d9d0 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -36,22 +36,22 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { response.Success(c, dto.SystemSettings{ RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, - SmtpHost: settings.SmtpHost, - SmtpPort: settings.SmtpPort, - SmtpUsername: settings.SmtpUsername, - SmtpPassword: settings.SmtpPassword, - SmtpFrom: settings.SmtpFrom, - SmtpFromName: settings.SmtpFromName, - SmtpUseTLS: settings.SmtpUseTLS, + SMTPHost: settings.SMTPHost, + SMTPPort: settings.SMTPPort, + SMTPUsername: settings.SMTPUsername, + SMTPPassword: settings.SMTPPassword, + SMTPFrom: settings.SMTPFrom, + SMTPFromName: settings.SMTPFromName, + SMTPUseTLS: settings.SMTPUseTLS, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKey: settings.TurnstileSecretKey, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, - ApiBaseUrl: settings.ApiBaseUrl, + APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, - DocUrl: settings.DocUrl, + DocURL: settings.DocURL, DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, }) @@ -64,13 +64,13 @@ type UpdateSettingsRequest struct { EmailVerifyEnabled bool `json:"email_verify_enabled"` // 邮件服务设置 - SmtpHost string `json:"smtp_host"` - SmtpPort int `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SmtpPassword string `json:"smtp_password"` - SmtpFrom string `json:"smtp_from_email"` - SmtpFromName string `json:"smtp_from_name"` - SmtpUseTLS bool `json:"smtp_use_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` // Cloudflare Turnstile 设置 TurnstileEnabled bool `json:"turnstile_enabled"` @@ -81,9 +81,9 @@ type UpdateSettingsRequest struct { SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` - ApiBaseUrl string `json:"api_base_url"` + APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` - DocUrl string `json:"doc_url"` + DocURL string `json:"doc_url"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -106,8 +106,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.DefaultBalance < 0 { req.DefaultBalance = 0 } - if req.SmtpPort <= 0 { - req.SmtpPort = 587 + if req.SMTPPort <= 0 { + req.SMTPPort = 587 } // Turnstile 参数验证 @@ -143,22 +143,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { settings := &service.SystemSettings{ RegistrationEnabled: req.RegistrationEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled, - SmtpHost: req.SmtpHost, - SmtpPort: req.SmtpPort, - SmtpUsername: req.SmtpUsername, - SmtpPassword: req.SmtpPassword, - SmtpFrom: req.SmtpFrom, - SmtpFromName: req.SmtpFromName, - SmtpUseTLS: req.SmtpUseTLS, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, TurnstileEnabled: req.TurnstileEnabled, TurnstileSiteKey: req.TurnstileSiteKey, TurnstileSecretKey: req.TurnstileSecretKey, SiteName: req.SiteName, SiteLogo: req.SiteLogo, SiteSubtitle: req.SiteSubtitle, - ApiBaseUrl: req.ApiBaseUrl, + APIBaseURL: req.APIBaseURL, ContactInfo: req.ContactInfo, - DocUrl: req.DocUrl, + DocURL: req.DocURL, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, } @@ -178,67 +178,67 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.Success(c, dto.SystemSettings{ RegistrationEnabled: updatedSettings.RegistrationEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, - SmtpHost: updatedSettings.SmtpHost, - SmtpPort: updatedSettings.SmtpPort, - SmtpUsername: updatedSettings.SmtpUsername, - SmtpPassword: updatedSettings.SmtpPassword, - SmtpFrom: updatedSettings.SmtpFrom, - SmtpFromName: updatedSettings.SmtpFromName, - SmtpUseTLS: updatedSettings.SmtpUseTLS, + SMTPHost: updatedSettings.SMTPHost, + SMTPPort: updatedSettings.SMTPPort, + SMTPUsername: updatedSettings.SMTPUsername, + SMTPPassword: updatedSettings.SMTPPassword, + SMTPFrom: updatedSettings.SMTPFrom, + SMTPFromName: updatedSettings.SMTPFromName, + SMTPUseTLS: updatedSettings.SMTPUseTLS, TurnstileEnabled: updatedSettings.TurnstileEnabled, TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKey: updatedSettings.TurnstileSecretKey, SiteName: updatedSettings.SiteName, SiteLogo: updatedSettings.SiteLogo, SiteSubtitle: updatedSettings.SiteSubtitle, - ApiBaseUrl: updatedSettings.ApiBaseUrl, + APIBaseURL: updatedSettings.APIBaseURL, ContactInfo: updatedSettings.ContactInfo, - DocUrl: updatedSettings.DocUrl, + DocURL: updatedSettings.DocURL, DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, }) } -// TestSmtpRequest 测试SMTP连接请求 -type TestSmtpRequest struct { - SmtpHost string `json:"smtp_host" binding:"required"` - SmtpPort int `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SmtpPassword string `json:"smtp_password"` - SmtpUseTLS bool `json:"smtp_use_tls"` +// TestSMTPRequest 测试SMTP连接请求 +type TestSMTPRequest struct { + SMTPHost string `json:"smtp_host" binding:"required"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPUseTLS bool `json:"smtp_use_tls"` } -// TestSmtpConnection 测试SMTP连接 +// TestSMTPConnection 测试SMTP连接 // POST /api/v1/admin/settings/test-smtp -func (h *SettingHandler) TestSmtpConnection(c *gin.Context) { - var req TestSmtpRequest +func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { + var req TestSMTPRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if req.SmtpPort <= 0 { - req.SmtpPort = 587 + if req.SMTPPort <= 0 { + req.SMTPPort = 587 } // 如果未提供密码,从数据库获取已保存的密码 - password := req.SmtpPassword + password := req.SMTPPassword if password == "" { - savedConfig, err := h.emailService.GetSmtpConfig(c.Request.Context()) + savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context()) if err == nil && savedConfig != nil { password = savedConfig.Password } } - config := &service.SmtpConfig{ - Host: req.SmtpHost, - Port: req.SmtpPort, - Username: req.SmtpUsername, + config := &service.SMTPConfig{ + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, Password: password, - UseTLS: req.SmtpUseTLS, + UseTLS: req.SMTPUseTLS, } - err := h.emailService.TestSmtpConnectionWithConfig(config) + err := h.emailService.TestSMTPConnectionWithConfig(config) if err != nil { response.ErrorFrom(c, err) return @@ -250,13 +250,13 @@ func (h *SettingHandler) TestSmtpConnection(c *gin.Context) { // SendTestEmailRequest 发送测试邮件请求 type SendTestEmailRequest struct { Email string `json:"email" binding:"required,email"` - SmtpHost string `json:"smtp_host" binding:"required"` - SmtpPort int `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SmtpPassword string `json:"smtp_password"` - SmtpFrom string `json:"smtp_from_email"` - SmtpFromName string `json:"smtp_from_name"` - SmtpUseTLS bool `json:"smtp_use_tls"` + SMTPHost string `json:"smtp_host" binding:"required"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` } // SendTestEmail 发送测试邮件 @@ -268,27 +268,27 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { return } - if req.SmtpPort <= 0 { - req.SmtpPort = 587 + if req.SMTPPort <= 0 { + req.SMTPPort = 587 } // 如果未提供密码,从数据库获取已保存的密码 - password := req.SmtpPassword + password := req.SMTPPassword if password == "" { - savedConfig, err := h.emailService.GetSmtpConfig(c.Request.Context()) + savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context()) if err == nil && savedConfig != nil { password = savedConfig.Password } } - config := &service.SmtpConfig{ - Host: req.SmtpHost, - Port: req.SmtpPort, - Username: req.SmtpUsername, + config := &service.SMTPConfig{ + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, Password: password, - From: req.SmtpFrom, - FromName: req.SmtpFromName, - UseTLS: req.SmtpUseTLS, + From: req.SMTPFrom, + FromName: req.SMTPFromName, + UseTLS: req.SMTPUseTLS, } siteName := h.settingService.GetSiteName(c.Request.Context()) @@ -333,10 +333,10 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { response.Success(c, gin.H{"message": "Test email sent successfully"}) } -// GetAdminApiKey 获取管理员 API Key 状态 +// GetAdminAPIKey 获取管理员 API Key 状态 // GET /api/v1/admin/settings/admin-api-key -func (h *SettingHandler) GetAdminApiKey(c *gin.Context) { - maskedKey, exists, err := h.settingService.GetAdminApiKeyStatus(c.Request.Context()) +func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) { + maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return @@ -348,10 +348,10 @@ func (h *SettingHandler) GetAdminApiKey(c *gin.Context) { }) } -// RegenerateAdminApiKey 生成/重新生成管理员 API Key +// RegenerateAdminAPIKey 生成/重新生成管理员 API Key // POST /api/v1/admin/settings/admin-api-key/regenerate -func (h *SettingHandler) RegenerateAdminApiKey(c *gin.Context) { - key, err := h.settingService.GenerateAdminApiKey(c.Request.Context()) +func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) { + key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return @@ -362,10 +362,10 @@ func (h *SettingHandler) RegenerateAdminApiKey(c *gin.Context) { }) } -// DeleteAdminApiKey 删除管理员 API Key +// DeleteAdminAPIKey 删除管理员 API Key // DELETE /api/v1/admin/settings/admin-api-key -func (h *SettingHandler) DeleteAdminApiKey(c *gin.Context) { - if err := h.settingService.DeleteAdminApiKey(c.Request.Context()); err != nil { +func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { + if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index a75948f7..37da93d3 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -17,14 +17,14 @@ import ( // UsageHandler handles admin usage-related requests type UsageHandler struct { usageService *service.UsageService - apiKeyService *service.ApiKeyService + apiKeyService *service.APIKeyService adminService service.AdminService } // NewUsageHandler creates a new admin usage handler func NewUsageHandler( usageService *service.UsageService, - apiKeyService *service.ApiKeyService, + apiKeyService *service.APIKeyService, adminService service.AdminService, ) *UsageHandler { return &UsageHandler{ @@ -125,7 +125,7 @@ func (h *UsageHandler) List(c *gin.Context) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} filters := usagestats.UsageLogFilters{ UserID: userID, - ApiKeyID: apiKeyID, + APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, Model: model, @@ -207,7 +207,7 @@ func (h *UsageHandler) Stats(c *gin.Context) { } if apiKeyID > 0 { - stats, err := h.usageService.GetStatsByApiKey(c.Request.Context(), apiKeyID, startTime, endTime) + stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime) if err != nil { response.ErrorFrom(c, err) return @@ -269,9 +269,9 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) { response.Success(c, result) } -// SearchApiKeys handles searching API keys by user +// SearchAPIKeys handles searching API keys by user // GET /api/v1/admin/usage/search-api-keys -func (h *UsageHandler) SearchApiKeys(c *gin.Context) { +func (h *UsageHandler) SearchAPIKeys(c *gin.Context) { userIDStr := c.Query("user_id") keyword := c.Query("q") @@ -285,22 +285,22 @@ func (h *UsageHandler) SearchApiKeys(c *gin.Context) { userID = id } - keys, err := h.apiKeyService.SearchApiKeys(c.Request.Context(), userID, keyword, 30) + keys, err := h.apiKeyService.SearchAPIKeys(c.Request.Context(), userID, keyword, 30) if err != nil { response.ErrorFrom(c, err) return } // Return simplified API key list (only id and name) - type SimpleApiKey struct { + type SimpleAPIKey struct { ID int64 `json:"id"` Name string `json:"name"` UserID int64 `json:"user_id"` } - result := make([]SimpleApiKey, len(keys)) + result := make([]SimpleAPIKey, len(keys)) for i, k := range keys { - result[i] = SimpleApiKey{ + result[i] = SimpleAPIKey{ ID: k.ID, Name: k.Name, UserID: k.UserID, diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 11bdebd2..f8cd1d5a 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -243,9 +243,9 @@ func (h *UserHandler) GetUserAPIKeys(c *gin.Context) { return } - out := make([]dto.ApiKey, 0, len(keys)) + out := make([]dto.APIKey, 0, len(keys)) for i := range keys { - out = append(out, *dto.ApiKeyFromService(&keys[i])) + out = append(out, *dto.APIKeyFromService(&keys[i])) } response.Paginated(c, out, total, page, pageSize) } diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 790f4ac2..8eff2924 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -14,11 +14,11 @@ import ( // APIKeyHandler handles API key-related requests type APIKeyHandler struct { - apiKeyService *service.ApiKeyService + apiKeyService *service.APIKeyService } // NewAPIKeyHandler creates a new APIKeyHandler -func NewAPIKeyHandler(apiKeyService *service.ApiKeyService) *APIKeyHandler { +func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler { return &APIKeyHandler{ apiKeyService: apiKeyService, } @@ -56,9 +56,9 @@ func (h *APIKeyHandler) List(c *gin.Context) { return } - out := make([]dto.ApiKey, 0, len(keys)) + out := make([]dto.APIKey, 0, len(keys)) for i := range keys { - out = append(out, *dto.ApiKeyFromService(&keys[i])) + out = append(out, *dto.APIKeyFromService(&keys[i])) } response.Paginated(c, out, result.Total, page, pageSize) } @@ -90,7 +90,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.ApiKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key)) } // Create handles creating a new API key @@ -108,7 +108,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) { return } - svcReq := service.CreateApiKeyRequest{ + svcReq := service.CreateAPIKeyRequest{ Name: req.Name, GroupID: req.GroupID, CustomKey: req.CustomKey, @@ -119,7 +119,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) { return } - response.Success(c, dto.ApiKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key)) } // Update handles updating an API key @@ -143,7 +143,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) { return } - svcReq := service.UpdateApiKeyRequest{} + svcReq := service.UpdateAPIKeyRequest{} if req.Name != "" { svcReq.Name = &req.Name } @@ -158,7 +158,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) { return } - response.Success(c, dto.ApiKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key)) } // Delete handles deleting an API key diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index f94bb7c2..0f5bf981 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -1,3 +1,4 @@ +// Package dto provides mapping utilities for converting between service layer and HTTP handler DTOs. package dto import "github.com/Wei-Shaw/sub2api/internal/service" @@ -26,11 +27,11 @@ func UserFromService(u *service.User) *User { return nil } out := UserFromServiceShallow(u) - if len(u.ApiKeys) > 0 { - out.ApiKeys = make([]ApiKey, 0, len(u.ApiKeys)) - for i := range u.ApiKeys { - k := u.ApiKeys[i] - out.ApiKeys = append(out.ApiKeys, *ApiKeyFromService(&k)) + if len(u.APIKeys) > 0 { + out.APIKeys = make([]APIKey, 0, len(u.APIKeys)) + for i := range u.APIKeys { + k := u.APIKeys[i] + out.APIKeys = append(out.APIKeys, *APIKeyFromService(&k)) } } if len(u.Subscriptions) > 0 { @@ -43,11 +44,11 @@ func UserFromService(u *service.User) *User { return out } -func ApiKeyFromService(k *service.ApiKey) *ApiKey { +func APIKeyFromService(k *service.APIKey) *APIKey { if k == nil { return nil } - return &ApiKey{ + return &APIKey{ ID: k.ID, UserID: k.UserID, Key: k.Key, @@ -220,7 +221,7 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog { return &UsageLog{ ID: l.ID, UserID: l.UserID, - ApiKeyID: l.ApiKeyID, + APIKeyID: l.APIKeyID, AccountID: l.AccountID, RequestID: l.RequestID, Model: l.Model, @@ -245,7 +246,7 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog { FirstTokenMs: l.FirstTokenMs, CreatedAt: l.CreatedAt, User: UserFromServiceShallow(l.User), - ApiKey: ApiKeyFromService(l.ApiKey), + APIKey: APIKeyFromService(l.APIKey), Account: AccountFromService(l.Account), Group: GroupFromServiceShallow(l.Group), Subscription: UserSubscriptionFromService(l.Subscription), diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 96e59e3f..df3189a6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -5,13 +5,13 @@ type SystemSettings struct { RegistrationEnabled bool `json:"registration_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"` - SmtpHost string `json:"smtp_host"` - SmtpPort int `json:"smtp_port"` - SmtpUsername string `json:"smtp_username"` - SmtpPassword string `json:"smtp_password,omitempty"` - SmtpFrom string `json:"smtp_from_email"` - SmtpFromName string `json:"smtp_from_name"` - SmtpUseTLS bool `json:"smtp_use_tls"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password,omitempty"` + SMTPFrom string `json:"smtp_from_email"` + SMTPFromName string `json:"smtp_from_name"` + SMTPUseTLS bool `json:"smtp_use_tls"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` @@ -20,9 +20,9 @@ type SystemSettings struct { SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` - ApiBaseUrl string `json:"api_base_url"` + APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` - DocUrl string `json:"doc_url"` + DocURL string `json:"doc_url"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -36,8 +36,8 @@ type PublicSettings struct { SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` - ApiBaseUrl string `json:"api_base_url"` + APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` - DocUrl string `json:"doc_url"` + DocURL string `json:"doc_url"` Version string `json:"version"` } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 75021875..148ab790 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -15,11 +15,11 @@ type User struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - ApiKeys []ApiKey `json:"api_keys,omitempty"` + APIKeys []APIKey `json:"api_keys,omitempty"` Subscriptions []UserSubscription `json:"subscriptions,omitempty"` } -type ApiKey struct { +type APIKey struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` Key string `json:"key"` @@ -136,7 +136,7 @@ type RedeemCode struct { type UsageLog struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` - ApiKeyID int64 `json:"api_key_id"` + APIKeyID int64 `json:"api_key_id"` AccountID int64 `json:"account_id"` RequestID string `json:"request_id"` Model string `json:"model"` @@ -168,7 +168,7 @@ type UsageLog struct { CreatedAt time.Time `json:"created_at"` User *User `json:"user,omitempty"` - ApiKey *ApiKey `json:"api_key,omitempty"` + APIKey *APIKey `json:"api_key,omitempty"` Account *Account `json:"account,omitempty"` Group *Group `json:"group,omitempty"` Subscription *UserSubscription `json:"subscription,omitempty"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 0ecbd34d..614ded8d 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1,3 +1,5 @@ +// Package handler provides HTTP request handlers for the API gateway. +// It handles authentication, request routing, concurrency control, and billing validation. package handler import ( @@ -27,6 +29,7 @@ type GatewayHandler struct { userService *service.UserService billingCacheService *service.BillingCacheService concurrencyHelper *ConcurrencyHelper + opsService *service.OpsService } // NewGatewayHandler creates a new GatewayHandler @@ -37,6 +40,7 @@ func NewGatewayHandler( userService *service.UserService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, + opsService *service.OpsService, ) *GatewayHandler { return &GatewayHandler{ gatewayService: gatewayService, @@ -45,14 +49,15 @@ func NewGatewayHandler( userService: userService, billingCacheService: billingCacheService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude), + opsService: opsService, } } // Messages handles Claude API compatible messages endpoint // POST /v1/messages func (h *GatewayHandler) Messages(c *gin.Context) { - // 从context获取apiKey和user(ApiKeyAuth中间件已设置) - apiKey, ok := middleware2.GetApiKeyFromContext(c) + // 从context获取apiKey和user(APIKeyAuth中间件已设置) + apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return @@ -87,6 +92,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } reqModel := parsedReq.Model reqStream := parsedReq.Stream + setOpsRequestContext(c, reqModel, reqStream) // 验证 model 必填 if reqModel == "" { @@ -258,7 +264,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, - ApiKey: apiKey, + APIKey: apiKey, User: apiKey.User, Account: usedAccount, Subscription: subscription, @@ -382,7 +388,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, - ApiKey: apiKey, + APIKey: apiKey, User: apiKey.User, Account: usedAccount, Subscription: subscription, @@ -399,7 +405,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // Returns models based on account configurations (model_mapping whitelist) // Falls back to default models if no whitelist is configured func (h *GatewayHandler) Models(c *gin.Context) { - apiKey, _ := middleware2.GetApiKeyFromContext(c) + apiKey, _ := middleware2.GetAPIKeyFromContext(c) var groupID *int64 var platform string @@ -448,7 +454,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { // Usage handles getting account balance for CC Switch integration // GET /v1/usage func (h *GatewayHandler) Usage(c *gin.Context) { - apiKey, ok := middleware2.GetApiKeyFromContext(c) + apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return @@ -573,6 +579,7 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) // handleStreamingAwareError handles errors that may occur after streaming has started func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { if streamStarted { + recordOpsError(c, h.opsService, status, errType, message, "") // Stream already started, send error as SSE event then close flusher, ok := c.Writer.(http.Flusher) if ok { @@ -604,6 +611,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e // errorResponse 返回Claude API格式的错误响应 func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { + recordOpsError(c, h.opsService, status, errType, message, "") c.JSON(status, gin.H{ "type": "error", "error": gin.H{ @@ -617,8 +625,8 @@ func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, mess // POST /v1/messages/count_tokens // 特点:校验订阅/余额,但不计算并发、不记录使用量 func (h *GatewayHandler) CountTokens(c *gin.Context) { - // 从context获取apiKey和user(ApiKeyAuth中间件已设置) - apiKey, ok := middleware2.GetApiKeyFromContext(c) + // 从context获取apiKey和user(APIKeyAuth中间件已设置) + apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 93ab23c9..79ec9950 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -20,7 +20,7 @@ import ( // GeminiV1BetaListModels proxies: // GET /v1beta/models func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { - apiKey, ok := middleware.GetApiKeyFromContext(c) + apiKey, ok := middleware.GetAPIKeyFromContext(c) if !ok || apiKey == nil { googleError(c, http.StatusUnauthorized, "Invalid API key") return @@ -66,7 +66,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { // GeminiV1BetaGetModel proxies: // GET /v1beta/models/{model} func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { - apiKey, ok := middleware.GetApiKeyFromContext(c) + apiKey, ok := middleware.GetAPIKeyFromContext(c) if !ok || apiKey == nil { googleError(c, http.StatusUnauthorized, "Invalid API key") return @@ -119,7 +119,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { // POST /v1beta/models/{model}:generateContent // POST /v1beta/models/{model}:streamGenerateContent?alt=sse func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { - apiKey, ok := middleware.GetApiKeyFromContext(c) + apiKey, ok := middleware.GetAPIKeyFromContext(c) if !ok || apiKey == nil { googleError(c, http.StatusUnauthorized, "Invalid API key") return @@ -298,7 +298,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, - ApiKey: apiKey, + APIKey: apiKey, User: apiKey.User, Account: usedAccount, Subscription: subscription, diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 817b71d3..8e03d7ca 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -7,6 +7,7 @@ import ( // AdminHandlers contains all admin-related HTTP handlers type AdminHandlers struct { Dashboard *admin.DashboardHandler + Ops *admin.OpsHandler User *admin.UserHandler Group *admin.GroupHandler Account *admin.AccountHandler diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 9931052d..3fa9956b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -22,6 +22,7 @@ type OpenAIGatewayHandler struct { gatewayService *service.OpenAIGatewayService billingCacheService *service.BillingCacheService concurrencyHelper *ConcurrencyHelper + opsService *service.OpsService } // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler @@ -29,19 +30,21 @@ func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, + opsService *service.OpsService, ) *OpenAIGatewayHandler { return &OpenAIGatewayHandler{ gatewayService: gatewayService, billingCacheService: billingCacheService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatNone), + opsService: opsService, } } // Responses handles OpenAI Responses API endpoint // POST /openai/v1/responses func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { - // Get apiKey and user from context (set by ApiKeyAuth middleware) - apiKey, ok := middleware2.GetApiKeyFromContext(c) + // Get apiKey and user from context (set by APIKeyAuth middleware) + apiKey, ok := middleware2.GetAPIKeyFromContext(c) if !ok { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return @@ -79,6 +82,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // Extract model and stream reqModel, _ := reqBody["model"].(string) reqStream, _ := reqBody["stream"].(bool) + setOpsRequestContext(c, reqModel, reqStream) // 验证 model 必填 if reqModel == "" { @@ -235,7 +239,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: result, - ApiKey: apiKey, + APIKey: apiKey, User: apiKey.User, Account: usedAccount, Subscription: subscription, @@ -278,6 +282,7 @@ func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, st // handleStreamingAwareError handles errors that may occur after streaming has started func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { if streamStarted { + recordOpsError(c, h.opsService, status, errType, message, service.PlatformOpenAI) // Stream already started, send error as SSE event then close flusher, ok := c.Writer.(http.Flusher) if ok { @@ -297,6 +302,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status // errorResponse returns OpenAI API format error response func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { + recordOpsError(c, h.opsService, status, errType, message, service.PlatformOpenAI) c.JSON(status, gin.H{ "error": gin.H{ "type": errType, diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go new file mode 100644 index 00000000..5b5e1edd --- /dev/null +++ b/backend/internal/handler/ops_error_logger.go @@ -0,0 +1,166 @@ +package handler + +import ( + "context" + "strings" + "sync" + "time" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const ( + opsModelKey = "ops_model" + opsStreamKey = "ops_stream" +) + +const ( + opsErrorLogWorkerCount = 10 + opsErrorLogQueueSize = 256 + opsErrorLogTimeout = 2 * time.Second +) + +type opsErrorLogJob struct { + ops *service.OpsService + entry *service.OpsErrorLog +} + +var ( + opsErrorLogOnce sync.Once + opsErrorLogQueue chan opsErrorLogJob +) + +func startOpsErrorLogWorkers() { + opsErrorLogQueue = make(chan opsErrorLogJob, opsErrorLogQueueSize) + for i := 0; i < opsErrorLogWorkerCount; i++ { + go func() { + for job := range opsErrorLogQueue { + if job.ops == nil || job.entry == nil { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), opsErrorLogTimeout) + _ = job.ops.RecordError(ctx, job.entry) + cancel() + } + }() + } +} + +func enqueueOpsErrorLog(ops *service.OpsService, entry *service.OpsErrorLog) { + if ops == nil || entry == nil { + return + } + + opsErrorLogOnce.Do(startOpsErrorLogWorkers) + + select { + case opsErrorLogQueue <- opsErrorLogJob{ops: ops, entry: entry}: + default: + // Queue is full; drop to avoid blocking request handling. + } +} + +func setOpsRequestContext(c *gin.Context, model string, stream bool) { + c.Set(opsModelKey, model) + c.Set(opsStreamKey, stream) +} + +func recordOpsError(c *gin.Context, ops *service.OpsService, status int, errType, message, fallbackPlatform string) { + if ops == nil || c == nil { + return + } + + model, _ := c.Get(opsModelKey) + stream, _ := c.Get(opsStreamKey) + + var modelName string + if m, ok := model.(string); ok { + modelName = m + } + streaming, _ := stream.(bool) + + apiKey, _ := middleware2.GetAPIKeyFromContext(c) + + logEntry := &service.OpsErrorLog{ + Phase: classifyOpsPhase(errType, message), + Type: errType, + Severity: classifyOpsSeverity(errType, status), + StatusCode: status, + Platform: resolveOpsPlatform(apiKey, fallbackPlatform), + Model: modelName, + RequestID: c.Writer.Header().Get("x-request-id"), + Message: message, + ClientIP: c.ClientIP(), + RequestPath: func() string { + if c.Request != nil && c.Request.URL != nil { + return c.Request.URL.Path + } + return "" + }(), + Stream: streaming, + } + + if apiKey != nil { + logEntry.APIKeyID = &apiKey.ID + if apiKey.User != nil { + logEntry.UserID = &apiKey.User.ID + } + if apiKey.GroupID != nil { + logEntry.GroupID = apiKey.GroupID + } + } + + enqueueOpsErrorLog(ops, logEntry) +} + +func resolveOpsPlatform(apiKey *service.APIKey, fallback string) string { + if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform != "" { + return apiKey.Group.Platform + } + return fallback +} + +func classifyOpsPhase(errType, message string) string { + msg := strings.ToLower(message) + switch errType { + case "authentication_error": + return "auth" + case "billing_error", "subscription_error": + return "billing" + case "rate_limit_error": + if strings.Contains(msg, "concurrency") || strings.Contains(msg, "pending") { + return "concurrency" + } + return "upstream" + case "invalid_request_error": + return "response" + case "upstream_error", "overloaded_error": + return "upstream" + case "api_error": + if strings.Contains(msg, "no available accounts") { + return "scheduling" + } + return "internal" + default: + return "internal" + } +} + +func classifyOpsSeverity(errType string, status int) string { + switch errType { + case "invalid_request_error", "authentication_error", "billing_error", "subscription_error": + return "P3" + } + if status >= 500 { + return "P1" + } + if status == 429 { + return "P1" + } + if status >= 400 { + return "P2" + } + return "P3" +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 90165288..3cae7a7f 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -39,9 +39,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, - ApiBaseUrl: settings.ApiBaseUrl, + APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, - DocUrl: settings.DocUrl, + DocURL: settings.DocURL, Version: h.version, }) } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index a0cf9f2c..9e503d4c 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -18,11 +18,11 @@ import ( // UsageHandler handles usage-related requests type UsageHandler struct { usageService *service.UsageService - apiKeyService *service.ApiKeyService + apiKeyService *service.APIKeyService } // NewUsageHandler creates a new UsageHandler -func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.ApiKeyService) *UsageHandler { +func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.APIKeyService) *UsageHandler { return &UsageHandler{ usageService: usageService, apiKeyService: apiKeyService, @@ -111,7 +111,7 @@ func (h *UsageHandler) List(c *gin.Context) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} filters := usagestats.UsageLogFilters{ UserID: subject.UserID, // Always filter by current user for security - ApiKeyID: apiKeyID, + APIKeyID: apiKeyID, Model: model, Stream: stream, BillingType: billingType, @@ -235,7 +235,7 @@ func (h *UsageHandler) Stats(c *gin.Context) { var stats *service.UsageStats var err error if apiKeyID > 0 { - stats, err = h.usageService.GetStatsByApiKey(c.Request.Context(), apiKeyID, startTime, endTime) + stats, err = h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime) } else { stats, err = h.usageService.GetStatsByUser(c.Request.Context(), subject.UserID, startTime, endTime) } @@ -346,49 +346,49 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) { }) } -// BatchApiKeysUsageRequest represents the request for batch API keys usage -type BatchApiKeysUsageRequest struct { - ApiKeyIDs []int64 `json:"api_key_ids" binding:"required"` +// BatchAPIKeysUsageRequest represents the request for batch API keys usage +type BatchAPIKeysUsageRequest struct { + APIKeyIDs []int64 `json:"api_key_ids" binding:"required"` } -// DashboardApiKeysUsage handles getting usage stats for user's own API keys +// DashboardAPIKeysUsage handles getting usage stats for user's own API keys // POST /api/v1/usage/dashboard/api-keys-usage -func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) { +func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) { subject, ok := middleware2.GetAuthSubjectFromContext(c) if !ok { response.Unauthorized(c, "User not authenticated") return } - var req BatchApiKeysUsageRequest + var req BatchAPIKeysUsageRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if len(req.ApiKeyIDs) == 0 { + if len(req.APIKeyIDs) == 0 { response.Success(c, gin.H{"stats": map[string]any{}}) return } // Limit the number of API key IDs to prevent SQL parameter overflow - if len(req.ApiKeyIDs) > 100 { + if len(req.APIKeyIDs) > 100 { response.BadRequest(c, "Too many API key IDs (maximum 100 allowed)") return } - validApiKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.ApiKeyIDs) + validAPIKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.APIKeyIDs) if err != nil { response.ErrorFrom(c, err) return } - if len(validApiKeyIDs) == 0 { + if len(validAPIKeyIDs) == 0 { response.Success(c, gin.H{"stats": map[string]any{}}) return } - stats, err := h.usageService.GetBatchApiKeyUsageStats(c.Request.Context(), validApiKeyIDs) + stats, err := h.usageService.GetBatchAPIKeyUsageStats(c.Request.Context(), validAPIKeyIDs) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 1695f8a9..9a8e4c14 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -10,6 +10,7 @@ import ( // ProvideAdminHandlers creates the AdminHandlers struct func ProvideAdminHandlers( dashboardHandler *admin.DashboardHandler, + opsHandler *admin.OpsHandler, userHandler *admin.UserHandler, groupHandler *admin.GroupHandler, accountHandler *admin.AccountHandler, @@ -27,6 +28,7 @@ func ProvideAdminHandlers( ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, + Ops: opsHandler, User: userHandler, Group: groupHandler, Account: accountHandler, @@ -96,6 +98,7 @@ var ProviderSet = wire.NewSet( // Admin handlers admin.NewDashboardHandler, + admin.NewOpsHandler, admin.NewUserHandler, admin.NewGroupHandler, admin.NewAccountHandler, diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 3bcbf26b..90ff34e7 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -1,3 +1,5 @@ +// Package antigravity provides a client for interacting with Google's Antigravity API, +// handling OAuth authentication, token management, and account tier information retrieval. package antigravity import ( diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 0db3ed4a..ee5bddc4 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,3 +1,4 @@ +// Package claude provides Claude API client constants and utilities. package claude // Claude Code 客户端相关常量 @@ -16,13 +17,13 @@ const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleav // HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking -// ApiKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth) -const ApiKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming +// APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth) +const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming -// ApiKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) -const ApiKeyHaikuBetaHeader = BetaInterleavedThinking +// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) +const APIKeyHaikuBetaHeader = BetaInterleavedThinking -// Claude Code 客户端默认请求头 +// DefaultHeaders are the default request headers for Claude Code client. var DefaultHeaders = map[string]string{ "User-Agent": "claude-cli/2.0.62 (external, cli)", "X-Stainless-Lang": "js", diff --git a/backend/internal/pkg/errors/types.go b/backend/internal/pkg/errors/types.go index dd98f6f5..e5d7f24a 100644 --- a/backend/internal/pkg/errors/types.go +++ b/backend/internal/pkg/errors/types.go @@ -1,3 +1,4 @@ +// Package errors provides custom error types and error handling utilities. // nolint:mnd package errors diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index 2be13c44..6bab22fa 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -1,7 +1,7 @@ +// Package gemini provides minimal fallback model metadata for Gemini native endpoints. package gemini -// This package provides minimal fallback model metadata for Gemini native endpoints. -// It is used when upstream model listing is unavailable (e.g. OAuth token missing AI Studio scopes). +// This package is used when upstream model listing is unavailable (e.g. OAuth token missing AI Studio scopes). type Model struct { Name string `json:"name"` diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go index 14cfa3a1..25eae409 100644 --- a/backend/internal/pkg/geminicli/constants.go +++ b/backend/internal/pkg/geminicli/constants.go @@ -1,3 +1,5 @@ +// Package geminicli provides OAuth authentication and API client functionality +// for Google's Gemini AI services, supporting both AI Studio and Code Assist endpoints. package geminicli import "time" diff --git a/backend/internal/pkg/googleapi/status.go b/backend/internal/pkg/googleapi/status.go index b8def1eb..2186906d 100644 --- a/backend/internal/pkg/googleapi/status.go +++ b/backend/internal/pkg/googleapi/status.go @@ -1,3 +1,4 @@ +// Package googleapi provides utilities for Google API interactions. package googleapi import "net/http" diff --git a/backend/internal/pkg/oauth/oauth.go b/backend/internal/pkg/oauth/oauth.go index 22dbff3f..a52d417a 100644 --- a/backend/internal/pkg/oauth/oauth.go +++ b/backend/internal/pkg/oauth/oauth.go @@ -1,3 +1,4 @@ +// Package oauth provides OAuth 2.0 utilities including PKCE flow, session management, and token exchange. package oauth import ( diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index d97507a8..c784d06e 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -1,3 +1,4 @@ +// Package openai provides OpenAI API models and configuration. package openai import _ "embed" diff --git a/backend/internal/pkg/openai/oauth.go b/backend/internal/pkg/openai/oauth.go index 90d2e001..4dadf839 100644 --- a/backend/internal/pkg/openai/oauth.go +++ b/backend/internal/pkg/openai/oauth.go @@ -327,7 +327,7 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) { return &claims, nil } -// ExtractUserInfo extracts user information from ID Token claims +// UserInfo extracts user information from ID Token claims type UserInfo struct { Email string ChatGPTAccountID string diff --git a/backend/internal/pkg/pagination/pagination.go b/backend/internal/pkg/pagination/pagination.go index 12ff321e..4800c3eb 100644 --- a/backend/internal/pkg/pagination/pagination.go +++ b/backend/internal/pkg/pagination/pagination.go @@ -1,3 +1,4 @@ +// Package pagination provides utilities for handling paginated queries and results. package pagination // PaginationParams 分页参数 diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index 87dc4264..7b76ca6d 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -1,3 +1,4 @@ +// Package response provides HTTP response utilities for standardized API responses and error handling. package response import ( diff --git a/backend/internal/pkg/sysutil/restart.go b/backend/internal/pkg/sysutil/restart.go index f390a6cf..0dd0e244 100644 --- a/backend/internal/pkg/sysutil/restart.go +++ b/backend/internal/pkg/sysutil/restart.go @@ -1,3 +1,4 @@ +// Package sysutil provides system-level utilities for service management. package sysutil import ( diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 946501d4..b37fe97f 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -1,3 +1,4 @@ +// Package usagestats defines types for tracking and reporting API usage statistics. package usagestats import "time" @@ -10,8 +11,8 @@ type DashboardStats struct { ActiveUsers int64 `json:"active_users"` // 今日有请求的用户数 // API Key 统计 - TotalApiKeys int64 `json:"total_api_keys"` - ActiveApiKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数 + TotalAPIKeys int64 `json:"total_api_keys"` + ActiveAPIKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数 // 账户统计 TotalAccounts int64 `json:"total_accounts"` @@ -82,10 +83,10 @@ type UserUsageTrendPoint struct { ActualCost float64 `json:"actual_cost"` // 实际扣除 } -// ApiKeyUsageTrendPoint represents API key usage trend data point -type ApiKeyUsageTrendPoint struct { +// APIKeyUsageTrendPoint represents API key usage trend data point +type APIKeyUsageTrendPoint struct { Date string `json:"date"` - ApiKeyID int64 `json:"api_key_id"` + APIKeyID int64 `json:"api_key_id"` KeyName string `json:"key_name"` Requests int64 `json:"requests"` Tokens int64 `json:"tokens"` @@ -94,8 +95,8 @@ type ApiKeyUsageTrendPoint struct { // UserDashboardStats 用户仪表盘统计 type UserDashboardStats struct { // API Key 统计 - TotalApiKeys int64 `json:"total_api_keys"` - ActiveApiKeys int64 `json:"active_api_keys"` + TotalAPIKeys int64 `json:"total_api_keys"` + ActiveAPIKeys int64 `json:"active_api_keys"` // 累计 Token 使用统计 TotalRequests int64 `json:"total_requests"` @@ -128,7 +129,7 @@ type UserDashboardStats struct { // UsageLogFilters represents filters for usage log queries type UsageLogFilters struct { UserID int64 - ApiKeyID int64 + APIKeyID int64 AccountID int64 GroupID int64 Model string @@ -157,9 +158,9 @@ type BatchUserUsageStats struct { TotalActualCost float64 `json:"total_actual_cost"` } -// BatchApiKeyUsageStats represents usage stats for a single API key -type BatchApiKeyUsageStats struct { - ApiKeyID int64 `json:"api_key_id"` +// BatchAPIKeyUsageStats represents usage stats for a single API key +type BatchAPIKeyUsageStats struct { + APIKeyID int64 `json:"api_key_id"` TodayActualCost float64 `json:"today_actual_cost"` TotalActualCost float64 `json:"total_actual_cost"` } diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index 84a88f23..250b141d 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -135,12 +135,12 @@ func (s *AccountRepoSuite) TestListWithFilters() { name: "filter_by_type", setup: func(client *dbent.Client) { mustCreateAccount(s.T(), client, &service.Account{Name: "t1", Type: service.AccountTypeOAuth}) - mustCreateAccount(s.T(), client, &service.Account{Name: "t2", Type: service.AccountTypeApiKey}) + mustCreateAccount(s.T(), client, &service.Account{Name: "t2", Type: service.AccountTypeAPIKey}) }, - accType: service.AccountTypeApiKey, + accType: service.AccountTypeAPIKey, wantCount: 1, validate: func(accounts []service.Account) { - s.Require().Equal(service.AccountTypeApiKey, accounts[0].Type) + s.Require().Equal(service.AccountTypeAPIKey, accounts[0].Type) }, }, { diff --git a/backend/internal/repository/allowed_groups_contract_integration_test.go b/backend/internal/repository/allowed_groups_contract_integration_test.go index 02cde527..e12ef6cc 100644 --- a/backend/internal/repository/allowed_groups_contract_integration_test.go +++ b/backend/internal/repository/allowed_groups_contract_integration_test.go @@ -80,7 +80,7 @@ func TestUserRepository_RemoveGroupFromAllowedGroups_RemovesAllOccurrences(t *te require.NotContains(t, u2After.AllowedGroups, targetGroup.ID) } -func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *testing.T) { +func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsAPIKeys(t *testing.T) { ctx := context.Background() tx := testEntTx(t) entClient := tx.Client() @@ -98,7 +98,7 @@ func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *t userRepo := newUserRepositoryWithSQL(entClient, tx) groupRepo := newGroupRepositoryWithSQL(entClient, tx) - apiKeyRepo := NewApiKeyRepository(entClient) + apiKeyRepo := NewAPIKeyRepository(entClient) u := &service.User{ Email: uniqueTestValue(t, "cascade-user") + "@example.com", @@ -110,7 +110,7 @@ func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *t } require.NoError(t, userRepo.Create(ctx, u)) - key := &service.ApiKey{ + key := &service.APIKey{ UserID: u.ID, Key: uniqueTestValue(t, "sk-test-delete-cascade"), Name: "test key", diff --git a/backend/internal/repository/api_key_cache.go b/backend/internal/repository/api_key_cache.go index 84565b47..73a929c5 100644 --- a/backend/internal/repository/api_key_cache.go +++ b/backend/internal/repository/api_key_cache.go @@ -24,7 +24,7 @@ type apiKeyCache struct { rdb *redis.Client } -func NewApiKeyCache(rdb *redis.Client) service.ApiKeyCache { +func NewAPIKeyCache(rdb *redis.Client) service.APIKeyCache { return &apiKeyCache{rdb: rdb} } diff --git a/backend/internal/repository/api_key_cache_integration_test.go b/backend/internal/repository/api_key_cache_integration_test.go index e9394917..f3c4b244 100644 --- a/backend/internal/repository/api_key_cache_integration_test.go +++ b/backend/internal/repository/api_key_cache_integration_test.go @@ -13,11 +13,11 @@ import ( "github.com/stretchr/testify/suite" ) -type ApiKeyCacheSuite struct { +type APIKeyCacheSuite struct { IntegrationRedisSuite } -func (s *ApiKeyCacheSuite) TestCreateAttemptCount() { +func (s *APIKeyCacheSuite) TestCreateAttemptCount() { tests := []struct { name string fn func(ctx context.Context, rdb *redis.Client, cache *apiKeyCache) @@ -78,7 +78,7 @@ func (s *ApiKeyCacheSuite) TestCreateAttemptCount() { } } -func (s *ApiKeyCacheSuite) TestDailyUsage() { +func (s *APIKeyCacheSuite) TestDailyUsage() { tests := []struct { name string fn func(ctx context.Context, rdb *redis.Client, cache *apiKeyCache) @@ -122,6 +122,6 @@ func (s *ApiKeyCacheSuite) TestDailyUsage() { } } -func TestApiKeyCacheSuite(t *testing.T) { - suite.Run(t, new(ApiKeyCacheSuite)) +func TestAPIKeyCacheSuite(t *testing.T) { + suite.Run(t, new(APIKeyCacheSuite)) } diff --git a/backend/internal/repository/api_key_cache_test.go b/backend/internal/repository/api_key_cache_test.go index 7ad84ba2..b14a710c 100644 --- a/backend/internal/repository/api_key_cache_test.go +++ b/backend/internal/repository/api_key_cache_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestApiKeyRateLimitKey(t *testing.T) { +func TestAPIKeyRateLimitKey(t *testing.T) { tests := []struct { name string userID int64 diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 9fcee1ca..8db905d0 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -16,17 +16,17 @@ type apiKeyRepository struct { client *dbent.Client } -func NewApiKeyRepository(client *dbent.Client) service.ApiKeyRepository { +func NewAPIKeyRepository(client *dbent.Client) service.APIKeyRepository { return &apiKeyRepository{client: client} } -func (r *apiKeyRepository) activeQuery() *dbent.ApiKeyQuery { +func (r *apiKeyRepository) activeQuery() *dbent.APIKeyQuery { // 默认过滤已软删除记录,避免删除后仍被查询到。 - return r.client.ApiKey.Query().Where(apikey.DeletedAtIsNil()) + return r.client.APIKey.Query().Where(apikey.DeletedAtIsNil()) } -func (r *apiKeyRepository) Create(ctx context.Context, key *service.ApiKey) error { - created, err := r.client.ApiKey.Create(). +func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) error { + created, err := r.client.APIKey.Create(). SetUserID(key.UserID). SetKey(key.Key). SetName(key.Name). @@ -38,10 +38,10 @@ func (r *apiKeyRepository) Create(ctx context.Context, key *service.ApiKey) erro key.CreatedAt = created.CreatedAt key.UpdatedAt = created.UpdatedAt } - return translatePersistenceError(err, nil, service.ErrApiKeyExists) + return translatePersistenceError(err, nil, service.ErrAPIKeyExists) } -func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) { +func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.APIKey, error) { m, err := r.activeQuery(). Where(apikey.IDEQ(id)). WithUser(). @@ -49,7 +49,7 @@ func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.ApiK Only(ctx) if err != nil { if dbent.IsNotFound(err) { - return nil, service.ErrApiKeyNotFound + return nil, service.ErrAPIKeyNotFound } return nil, err } @@ -59,7 +59,7 @@ func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.ApiK // GetOwnerID 根据 API Key ID 获取其所有者(用户)的 ID。 // 相比 GetByID,此方法性能更优,因为: // - 使用 Select() 只查询 user_id 字段,减少数据传输量 -// - 不加载完整的 ApiKey 实体及其关联数据(User、Group 等) +// - 不加载完整的 APIKey 实体及其关联数据(User、Group 等) // - 适用于权限验证等只需用户 ID 的场景(如删除前的所有权检查) func (r *apiKeyRepository) GetOwnerID(ctx context.Context, id int64) (int64, error) { m, err := r.activeQuery(). @@ -68,14 +68,14 @@ func (r *apiKeyRepository) GetOwnerID(ctx context.Context, id int64) (int64, err Only(ctx) if err != nil { if dbent.IsNotFound(err) { - return 0, service.ErrApiKeyNotFound + return 0, service.ErrAPIKeyNotFound } return 0, err } return m.UserID, nil } -func (r *apiKeyRepository) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) { +func (r *apiKeyRepository) GetByKey(ctx context.Context, key string) (*service.APIKey, error) { m, err := r.activeQuery(). Where(apikey.KeyEQ(key)). WithUser(). @@ -83,21 +83,21 @@ func (r *apiKeyRepository) GetByKey(ctx context.Context, key string) (*service.A Only(ctx) if err != nil { if dbent.IsNotFound(err) { - return nil, service.ErrApiKeyNotFound + return nil, service.ErrAPIKeyNotFound } return nil, err } return apiKeyEntityToService(m), nil } -func (r *apiKeyRepository) Update(ctx context.Context, key *service.ApiKey) error { +func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) error { // 使用原子操作:将软删除检查与更新合并到同一语句,避免竞态条件。 // 之前的实现先检查 Exist 再 UpdateOneID,若在两步之间发生软删除, // 则会更新已删除的记录。 // 这里选择 Update().Where(),确保只有未软删除记录能被更新。 // 同时显式设置 updated_at,避免二次查询带来的并发可见性问题。 now := time.Now() - builder := r.client.ApiKey.Update(). + builder := r.client.APIKey.Update(). Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()). SetName(key.Name). SetStatus(key.Status). @@ -114,7 +114,7 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.ApiKey) erro } if affected == 0 { // 更新影响行数为 0,说明记录不存在或已被软删除。 - return service.ErrApiKeyNotFound + return service.ErrAPIKeyNotFound } // 使用同一时间戳回填,避免并发删除导致二次查询失败。 @@ -124,18 +124,18 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.ApiKey) erro func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error { // 显式软删除:避免依赖 Hook 行为,确保 deleted_at 一定被设置。 - affected, err := r.client.ApiKey.Update(). + affected, err := r.client.APIKey.Update(). Where(apikey.IDEQ(id), apikey.DeletedAtIsNil()). SetDeletedAt(time.Now()). Save(ctx) if err != nil { if dbent.IsNotFound(err) { - return service.ErrApiKeyNotFound + return service.ErrAPIKeyNotFound } return err } if affected == 0 { - exists, err := r.client.ApiKey.Query(). + exists, err := r.client.APIKey.Query(). Where(apikey.IDEQ(id)). Exist(mixins.SkipSoftDelete(ctx)) if err != nil { @@ -144,12 +144,12 @@ func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error { if exists { return nil } - return service.ErrApiKeyNotFound + return service.ErrAPIKeyNotFound } return nil } -func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { q := r.activeQuery().Where(apikey.UserIDEQ(userID)) total, err := q.Count(ctx) @@ -167,7 +167,7 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param return nil, nil, err } - outKeys := make([]service.ApiKey, 0, len(keys)) + outKeys := make([]service.APIKey, 0, len(keys)) for i := range keys { outKeys = append(outKeys, *apiKeyEntityToService(keys[i])) } @@ -180,7 +180,7 @@ func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, ap return []int64{}, nil } - ids, err := r.client.ApiKey.Query(). + ids, err := r.client.APIKey.Query(). Where(apikey.UserIDEQ(userID), apikey.IDIn(apiKeyIDs...), apikey.DeletedAtIsNil()). IDs(ctx) if err != nil { @@ -199,7 +199,7 @@ func (r *apiKeyRepository) ExistsByKey(ctx context.Context, key string) (bool, e return count > 0, err } -func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { q := r.activeQuery().Where(apikey.GroupIDEQ(groupID)) total, err := q.Count(ctx) @@ -217,7 +217,7 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par return nil, nil, err } - outKeys := make([]service.ApiKey, 0, len(keys)) + outKeys := make([]service.APIKey, 0, len(keys)) for i := range keys { outKeys = append(outKeys, *apiKeyEntityToService(keys[i])) } @@ -225,8 +225,8 @@ func (r *apiKeyRepository) ListByGroupID(ctx context.Context, groupID int64, par return outKeys, paginationResultFromTotal(int64(total), params), nil } -// SearchApiKeys searches API keys by user ID and/or keyword (name) -func (r *apiKeyRepository) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) { +// SearchAPIKeys searches API keys by user ID and/or keyword (name) +func (r *apiKeyRepository) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) { q := r.activeQuery() if userID > 0 { q = q.Where(apikey.UserIDEQ(userID)) @@ -241,7 +241,7 @@ func (r *apiKeyRepository) SearchApiKeys(ctx context.Context, userID int64, keyw return nil, err } - outKeys := make([]service.ApiKey, 0, len(keys)) + outKeys := make([]service.APIKey, 0, len(keys)) for i := range keys { outKeys = append(outKeys, *apiKeyEntityToService(keys[i])) } @@ -250,7 +250,7 @@ func (r *apiKeyRepository) SearchApiKeys(ctx context.Context, userID int64, keyw // ClearGroupIDByGroupID 将指定分组的所有 API Key 的 group_id 设为 nil func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { - n, err := r.client.ApiKey.Update(). + n, err := r.client.APIKey.Update(). Where(apikey.GroupIDEQ(groupID), apikey.DeletedAtIsNil()). ClearGroupID(). Save(ctx) @@ -263,11 +263,11 @@ func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (i return int64(count), err } -func apiKeyEntityToService(m *dbent.ApiKey) *service.ApiKey { +func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey { if m == nil { return nil } - out := &service.ApiKey{ + out := &service.APIKey{ ID: m.ID, UserID: m.UserID, Key: m.Key, diff --git a/backend/internal/repository/api_key_repo_integration_test.go b/backend/internal/repository/api_key_repo_integration_test.go index 79564ff0..4b3161e4 100644 --- a/backend/internal/repository/api_key_repo_integration_test.go +++ b/backend/internal/repository/api_key_repo_integration_test.go @@ -12,30 +12,30 @@ import ( "github.com/stretchr/testify/suite" ) -type ApiKeyRepoSuite struct { +type APIKeyRepoSuite struct { suite.Suite ctx context.Context client *dbent.Client repo *apiKeyRepository } -func (s *ApiKeyRepoSuite) SetupTest() { +func (s *APIKeyRepoSuite) SetupTest() { s.ctx = context.Background() tx := testEntTx(s.T()) s.client = tx.Client() - s.repo = NewApiKeyRepository(s.client).(*apiKeyRepository) + s.repo = NewAPIKeyRepository(s.client).(*apiKeyRepository) } -func TestApiKeyRepoSuite(t *testing.T) { - suite.Run(t, new(ApiKeyRepoSuite)) +func TestAPIKeyRepoSuite(t *testing.T) { + suite.Run(t, new(APIKeyRepoSuite)) } // --- Create / GetByID / GetByKey --- -func (s *ApiKeyRepoSuite) TestCreate() { +func (s *APIKeyRepoSuite) TestCreate() { user := s.mustCreateUser("create@test.com") - key := &service.ApiKey{ + key := &service.APIKey{ UserID: user.ID, Key: "sk-create-test", Name: "Test Key", @@ -51,16 +51,16 @@ func (s *ApiKeyRepoSuite) TestCreate() { s.Require().Equal("sk-create-test", got.Key) } -func (s *ApiKeyRepoSuite) TestGetByID_NotFound() { +func (s *APIKeyRepoSuite) TestGetByID_NotFound() { _, err := s.repo.GetByID(s.ctx, 999999) s.Require().Error(err, "expected error for non-existent ID") } -func (s *ApiKeyRepoSuite) TestGetByKey() { +func (s *APIKeyRepoSuite) TestGetByKey() { user := s.mustCreateUser("getbykey@test.com") group := s.mustCreateGroup("g-key") - key := &service.ApiKey{ + key := &service.APIKey{ UserID: user.ID, Key: "sk-getbykey", Name: "My Key", @@ -78,16 +78,16 @@ func (s *ApiKeyRepoSuite) TestGetByKey() { s.Require().Equal(group.ID, got.Group.ID) } -func (s *ApiKeyRepoSuite) TestGetByKey_NotFound() { +func (s *APIKeyRepoSuite) TestGetByKey_NotFound() { _, err := s.repo.GetByKey(s.ctx, "non-existent-key") s.Require().Error(err, "expected error for non-existent key") } // --- Update --- -func (s *ApiKeyRepoSuite) TestUpdate() { +func (s *APIKeyRepoSuite) TestUpdate() { user := s.mustCreateUser("update@test.com") - key := &service.ApiKey{ + key := &service.APIKey{ UserID: user.ID, Key: "sk-update", Name: "Original", @@ -108,10 +108,10 @@ func (s *ApiKeyRepoSuite) TestUpdate() { s.Require().Equal(service.StatusDisabled, got.Status) } -func (s *ApiKeyRepoSuite) TestUpdate_ClearGroupID() { +func (s *APIKeyRepoSuite) TestUpdate_ClearGroupID() { user := s.mustCreateUser("cleargroup@test.com") group := s.mustCreateGroup("g-clear") - key := &service.ApiKey{ + key := &service.APIKey{ UserID: user.ID, Key: "sk-clear-group", Name: "Group Key", @@ -131,9 +131,9 @@ func (s *ApiKeyRepoSuite) TestUpdate_ClearGroupID() { // --- Delete --- -func (s *ApiKeyRepoSuite) TestDelete() { +func (s *APIKeyRepoSuite) TestDelete() { user := s.mustCreateUser("delete@test.com") - key := &service.ApiKey{ + key := &service.APIKey{ UserID: user.ID, Key: "sk-delete", Name: "Delete Me", @@ -150,10 +150,10 @@ func (s *ApiKeyRepoSuite) TestDelete() { // --- ListByUserID / CountByUserID --- -func (s *ApiKeyRepoSuite) TestListByUserID() { +func (s *APIKeyRepoSuite) TestListByUserID() { user := s.mustCreateUser("listbyuser@test.com") - s.mustCreateApiKey(user.ID, "sk-list-1", "Key 1", nil) - s.mustCreateApiKey(user.ID, "sk-list-2", "Key 2", nil) + s.mustCreateAPIKey(user.ID, "sk-list-1", "Key 1", nil) + s.mustCreateAPIKey(user.ID, "sk-list-2", "Key 2", nil) keys, page, err := s.repo.ListByUserID(s.ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}) s.Require().NoError(err, "ListByUserID") @@ -161,10 +161,10 @@ func (s *ApiKeyRepoSuite) TestListByUserID() { s.Require().Equal(int64(2), page.Total) } -func (s *ApiKeyRepoSuite) TestListByUserID_Pagination() { +func (s *APIKeyRepoSuite) TestListByUserID_Pagination() { user := s.mustCreateUser("paging@test.com") for i := 0; i < 5; i++ { - s.mustCreateApiKey(user.ID, "sk-page-"+string(rune('a'+i)), "Key", nil) + s.mustCreateAPIKey(user.ID, "sk-page-"+string(rune('a'+i)), "Key", nil) } keys, page, err := s.repo.ListByUserID(s.ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 2}) @@ -174,10 +174,10 @@ func (s *ApiKeyRepoSuite) TestListByUserID_Pagination() { s.Require().Equal(3, page.Pages) } -func (s *ApiKeyRepoSuite) TestCountByUserID() { +func (s *APIKeyRepoSuite) TestCountByUserID() { user := s.mustCreateUser("count@test.com") - s.mustCreateApiKey(user.ID, "sk-count-1", "K1", nil) - s.mustCreateApiKey(user.ID, "sk-count-2", "K2", nil) + s.mustCreateAPIKey(user.ID, "sk-count-1", "K1", nil) + s.mustCreateAPIKey(user.ID, "sk-count-2", "K2", nil) count, err := s.repo.CountByUserID(s.ctx, user.ID) s.Require().NoError(err, "CountByUserID") @@ -186,13 +186,13 @@ func (s *ApiKeyRepoSuite) TestCountByUserID() { // --- ListByGroupID / CountByGroupID --- -func (s *ApiKeyRepoSuite) TestListByGroupID() { +func (s *APIKeyRepoSuite) TestListByGroupID() { user := s.mustCreateUser("listbygroup@test.com") group := s.mustCreateGroup("g-list") - s.mustCreateApiKey(user.ID, "sk-grp-1", "K1", &group.ID) - s.mustCreateApiKey(user.ID, "sk-grp-2", "K2", &group.ID) - s.mustCreateApiKey(user.ID, "sk-grp-3", "K3", nil) // no group + s.mustCreateAPIKey(user.ID, "sk-grp-1", "K1", &group.ID) + s.mustCreateAPIKey(user.ID, "sk-grp-2", "K2", &group.ID) + s.mustCreateAPIKey(user.ID, "sk-grp-3", "K3", nil) // no group keys, page, err := s.repo.ListByGroupID(s.ctx, group.ID, pagination.PaginationParams{Page: 1, PageSize: 10}) s.Require().NoError(err, "ListByGroupID") @@ -202,10 +202,10 @@ func (s *ApiKeyRepoSuite) TestListByGroupID() { s.Require().NotNil(keys[0].User) } -func (s *ApiKeyRepoSuite) TestCountByGroupID() { +func (s *APIKeyRepoSuite) TestCountByGroupID() { user := s.mustCreateUser("countgroup@test.com") group := s.mustCreateGroup("g-count") - s.mustCreateApiKey(user.ID, "sk-gc-1", "K1", &group.ID) + s.mustCreateAPIKey(user.ID, "sk-gc-1", "K1", &group.ID) count, err := s.repo.CountByGroupID(s.ctx, group.ID) s.Require().NoError(err, "CountByGroupID") @@ -214,9 +214,9 @@ func (s *ApiKeyRepoSuite) TestCountByGroupID() { // --- ExistsByKey --- -func (s *ApiKeyRepoSuite) TestExistsByKey() { +func (s *APIKeyRepoSuite) TestExistsByKey() { user := s.mustCreateUser("exists@test.com") - s.mustCreateApiKey(user.ID, "sk-exists", "K", nil) + s.mustCreateAPIKey(user.ID, "sk-exists", "K", nil) exists, err := s.repo.ExistsByKey(s.ctx, "sk-exists") s.Require().NoError(err, "ExistsByKey") @@ -227,47 +227,47 @@ func (s *ApiKeyRepoSuite) TestExistsByKey() { s.Require().False(notExists) } -// --- SearchApiKeys --- +// --- SearchAPIKeys --- -func (s *ApiKeyRepoSuite) TestSearchApiKeys() { +func (s *APIKeyRepoSuite) TestSearchAPIKeys() { user := s.mustCreateUser("search@test.com") - s.mustCreateApiKey(user.ID, "sk-search-1", "Production Key", nil) - s.mustCreateApiKey(user.ID, "sk-search-2", "Development Key", nil) + s.mustCreateAPIKey(user.ID, "sk-search-1", "Production Key", nil) + s.mustCreateAPIKey(user.ID, "sk-search-2", "Development Key", nil) - found, err := s.repo.SearchApiKeys(s.ctx, user.ID, "prod", 10) - s.Require().NoError(err, "SearchApiKeys") + found, err := s.repo.SearchAPIKeys(s.ctx, user.ID, "prod", 10) + s.Require().NoError(err, "SearchAPIKeys") s.Require().Len(found, 1) s.Require().Contains(found[0].Name, "Production") } -func (s *ApiKeyRepoSuite) TestSearchApiKeys_NoKeyword() { +func (s *APIKeyRepoSuite) TestSearchAPIKeys_NoKeyword() { user := s.mustCreateUser("searchnokw@test.com") - s.mustCreateApiKey(user.ID, "sk-nk-1", "K1", nil) - s.mustCreateApiKey(user.ID, "sk-nk-2", "K2", nil) + s.mustCreateAPIKey(user.ID, "sk-nk-1", "K1", nil) + s.mustCreateAPIKey(user.ID, "sk-nk-2", "K2", nil) - found, err := s.repo.SearchApiKeys(s.ctx, user.ID, "", 10) + found, err := s.repo.SearchAPIKeys(s.ctx, user.ID, "", 10) s.Require().NoError(err) s.Require().Len(found, 2) } -func (s *ApiKeyRepoSuite) TestSearchApiKeys_NoUserID() { +func (s *APIKeyRepoSuite) TestSearchAPIKeys_NoUserID() { user := s.mustCreateUser("searchnouid@test.com") - s.mustCreateApiKey(user.ID, "sk-nu-1", "TestKey", nil) + s.mustCreateAPIKey(user.ID, "sk-nu-1", "TestKey", nil) - found, err := s.repo.SearchApiKeys(s.ctx, 0, "testkey", 10) + found, err := s.repo.SearchAPIKeys(s.ctx, 0, "testkey", 10) s.Require().NoError(err) s.Require().Len(found, 1) } // --- ClearGroupIDByGroupID --- -func (s *ApiKeyRepoSuite) TestClearGroupIDByGroupID() { +func (s *APIKeyRepoSuite) TestClearGroupIDByGroupID() { user := s.mustCreateUser("cleargrp@test.com") group := s.mustCreateGroup("g-clear-bulk") - k1 := s.mustCreateApiKey(user.ID, "sk-clr-1", "K1", &group.ID) - k2 := s.mustCreateApiKey(user.ID, "sk-clr-2", "K2", &group.ID) - s.mustCreateApiKey(user.ID, "sk-clr-3", "K3", nil) // no group + k1 := s.mustCreateAPIKey(user.ID, "sk-clr-1", "K1", &group.ID) + k2 := s.mustCreateAPIKey(user.ID, "sk-clr-2", "K2", &group.ID) + s.mustCreateAPIKey(user.ID, "sk-clr-3", "K3", nil) // no group affected, err := s.repo.ClearGroupIDByGroupID(s.ctx, group.ID) s.Require().NoError(err, "ClearGroupIDByGroupID") @@ -284,10 +284,10 @@ func (s *ApiKeyRepoSuite) TestClearGroupIDByGroupID() { // --- Combined CRUD/Search/ClearGroupID (original test preserved as integration) --- -func (s *ApiKeyRepoSuite) TestCRUD_Search_ClearGroupID() { +func (s *APIKeyRepoSuite) TestCRUD_Search_ClearGroupID() { user := s.mustCreateUser("k@example.com") group := s.mustCreateGroup("g-k") - key := s.mustCreateApiKey(user.ID, "sk-test-1", "My Key", &group.ID) + key := s.mustCreateAPIKey(user.ID, "sk-test-1", "My Key", &group.ID) key.GroupID = &group.ID got, err := s.repo.GetByKey(s.ctx, key.Key) @@ -320,13 +320,13 @@ func (s *ApiKeyRepoSuite) TestCRUD_Search_ClearGroupID() { s.Require().NoError(err, "ExistsByKey") s.Require().True(exists, "expected key to exist") - found, err := s.repo.SearchApiKeys(s.ctx, user.ID, "renam", 10) - s.Require().NoError(err, "SearchApiKeys") + found, err := s.repo.SearchAPIKeys(s.ctx, user.ID, "renam", 10) + s.Require().NoError(err, "SearchAPIKeys") s.Require().Len(found, 1) s.Require().Equal(key.ID, found[0].ID) // ClearGroupIDByGroupID - k2 := s.mustCreateApiKey(user.ID, "sk-test-2", "Group Key", &group.ID) + k2 := s.mustCreateAPIKey(user.ID, "sk-test-2", "Group Key", &group.ID) k2.GroupID = &group.ID countBefore, err := s.repo.CountByGroupID(s.ctx, group.ID) @@ -346,7 +346,7 @@ func (s *ApiKeyRepoSuite) TestCRUD_Search_ClearGroupID() { s.Require().Equal(int64(0), countAfter, "expected 0 keys in group after clear") } -func (s *ApiKeyRepoSuite) mustCreateUser(email string) *service.User { +func (s *APIKeyRepoSuite) mustCreateUser(email string) *service.User { s.T().Helper() u, err := s.client.User.Create(). @@ -359,7 +359,7 @@ func (s *ApiKeyRepoSuite) mustCreateUser(email string) *service.User { return userEntityToService(u) } -func (s *ApiKeyRepoSuite) mustCreateGroup(name string) *service.Group { +func (s *APIKeyRepoSuite) mustCreateGroup(name string) *service.Group { s.T().Helper() g, err := s.client.Group.Create(). @@ -370,10 +370,10 @@ func (s *ApiKeyRepoSuite) mustCreateGroup(name string) *service.Group { return groupEntityToService(g) } -func (s *ApiKeyRepoSuite) mustCreateApiKey(userID int64, key, name string, groupID *int64) *service.ApiKey { +func (s *APIKeyRepoSuite) mustCreateAPIKey(userID int64, key, name string, groupID *int64) *service.APIKey { s.T().Helper() - k := &service.ApiKey{ + k := &service.APIKey{ UserID: userID, Key: key, Name: name, diff --git a/backend/internal/repository/concurrency_cache.go b/backend/internal/repository/concurrency_cache.go index 95370f51..dfa555aa 100644 --- a/backend/internal/repository/concurrency_cache.go +++ b/backend/internal/repository/concurrency_cache.go @@ -27,8 +27,14 @@ const ( accountSlotKeyPrefix = "concurrency:account:" // 格式: concurrency:user:{userID} userSlotKeyPrefix = "concurrency:user:" - // 等待队列计数器格式: concurrency:wait:{userID} - waitQueueKeyPrefix = "concurrency:wait:" + + // Wait queue keys (global structures) + // - total: integer total queue depth across all users + // - updated: sorted set of userID -> lastUpdateUnixSec (for TTL cleanup) + // - counts: hash of userID -> current wait count + waitQueueTotalKey = "concurrency:wait:total" + waitQueueUpdatedKey = "concurrency:wait:updated" + waitQueueCountsKey = "concurrency:wait:counts" // 账号级等待队列计数器格式: wait:account:{accountID} accountWaitKeyPrefix = "wait:account:" @@ -94,27 +100,55 @@ var ( `) // incrementWaitScript - only sets TTL on first creation to avoid refreshing - // KEYS[1] = wait queue key - // ARGV[1] = maxWait - // ARGV[2] = TTL in seconds + // KEYS[1] = total key + // KEYS[2] = updated zset key + // KEYS[3] = counts hash key + // ARGV[1] = userID + // ARGV[2] = maxWait + // ARGV[3] = TTL in seconds + // ARGV[4] = cleanup limit incrementWaitScript = redis.NewScript(` - local current = redis.call('GET', KEYS[1]) - if current == false then - current = 0 - else - current = tonumber(current) + local totalKey = KEYS[1] + local updatedKey = KEYS[2] + local countsKey = KEYS[3] + + local userID = ARGV[1] + local maxWait = tonumber(ARGV[2]) + local ttl = tonumber(ARGV[3]) + local cleanupLimit = tonumber(ARGV[4]) + + redis.call('SETNX', totalKey, 0) + + local timeResult = redis.call('TIME') + local now = tonumber(timeResult[1]) + local expireBefore = now - ttl + + -- Cleanup expired users (bounded) + local expired = redis.call('ZRANGEBYSCORE', updatedKey, '-inf', expireBefore, 'LIMIT', 0, cleanupLimit) + for _, uid in ipairs(expired) do + local c = tonumber(redis.call('HGET', countsKey, uid) or '0') + if c > 0 then + redis.call('DECRBY', totalKey, c) + end + redis.call('HDEL', countsKey, uid) + redis.call('ZREM', updatedKey, uid) end - if current >= tonumber(ARGV[1]) then + local current = tonumber(redis.call('HGET', countsKey, userID) or '0') + if current >= maxWait then return 0 end - local newVal = redis.call('INCR', KEYS[1]) + local newVal = current + 1 + redis.call('HSET', countsKey, userID, newVal) + redis.call('ZADD', updatedKey, now, userID) + redis.call('INCR', totalKey) - -- Only set TTL on first creation to avoid refreshing zombie data - if newVal == 1 then - redis.call('EXPIRE', KEYS[1], ARGV[2]) - end + -- Keep global structures from living forever in totally idle deployments. + local ttlKeep = ttl * 2 + redis.call('EXPIRE', totalKey, ttlKeep) + redis.call('EXPIRE', updatedKey, ttlKeep) + redis.call('EXPIRE', countsKey, ttlKeep) return 1 `) @@ -144,6 +178,111 @@ var ( // decrementWaitScript - same as before decrementWaitScript = redis.NewScript(` + local totalKey = KEYS[1] + local updatedKey = KEYS[2] + local countsKey = KEYS[3] + + local userID = ARGV[1] + local ttl = tonumber(ARGV[2]) + local cleanupLimit = tonumber(ARGV[3]) + + redis.call('SETNX', totalKey, 0) + + local timeResult = redis.call('TIME') + local now = tonumber(timeResult[1]) + local expireBefore = now - ttl + + -- Cleanup expired users (bounded) + local expired = redis.call('ZRANGEBYSCORE', updatedKey, '-inf', expireBefore, 'LIMIT', 0, cleanupLimit) + for _, uid in ipairs(expired) do + local c = tonumber(redis.call('HGET', countsKey, uid) or '0') + if c > 0 then + redis.call('DECRBY', totalKey, c) + end + redis.call('HDEL', countsKey, uid) + redis.call('ZREM', updatedKey, uid) + end + + local current = tonumber(redis.call('HGET', countsKey, userID) or '0') + if current <= 0 then + return 1 + end + + local newVal = current - 1 + if newVal <= 0 then + redis.call('HDEL', countsKey, userID) + redis.call('ZREM', updatedKey, userID) + else + redis.call('HSET', countsKey, userID, newVal) + redis.call('ZADD', updatedKey, now, userID) + end + redis.call('DECR', totalKey) + + local ttlKeep = ttl * 2 + redis.call('EXPIRE', totalKey, ttlKeep) + redis.call('EXPIRE', updatedKey, ttlKeep) + redis.call('EXPIRE', countsKey, ttlKeep) + + return 1 + `) + + // getTotalWaitScript returns the global wait depth with TTL cleanup. + // KEYS[1] = total key + // KEYS[2] = updated zset key + // KEYS[3] = counts hash key + // ARGV[1] = TTL in seconds + // ARGV[2] = cleanup limit + getTotalWaitScript = redis.NewScript(` + local totalKey = KEYS[1] + local updatedKey = KEYS[2] + local countsKey = KEYS[3] + + local ttl = tonumber(ARGV[1]) + local cleanupLimit = tonumber(ARGV[2]) + + redis.call('SETNX', totalKey, 0) + + local timeResult = redis.call('TIME') + local now = tonumber(timeResult[1]) + local expireBefore = now - ttl + + -- Cleanup expired users (bounded) + local expired = redis.call('ZRANGEBYSCORE', updatedKey, '-inf', expireBefore, 'LIMIT', 0, cleanupLimit) + for _, uid in ipairs(expired) do + local c = tonumber(redis.call('HGET', countsKey, uid) or '0') + if c > 0 then + redis.call('DECRBY', totalKey, c) + end + redis.call('HDEL', countsKey, uid) + redis.call('ZREM', updatedKey, uid) + end + + -- If totalKey got lost but counts exist (e.g. Redis restart), recompute once. + local total = redis.call('GET', totalKey) + if total == false then + total = 0 + local vals = redis.call('HVALS', countsKey) + for _, v in ipairs(vals) do + total = total + tonumber(v) + end + redis.call('SET', totalKey, total) + end + + local ttlKeep = ttl * 2 + redis.call('EXPIRE', totalKey, ttlKeep) + redis.call('EXPIRE', updatedKey, ttlKeep) + redis.call('EXPIRE', countsKey, ttlKeep) + + local result = tonumber(redis.call('GET', totalKey) or '0') + if result < 0 then + result = 0 + redis.call('SET', totalKey, 0) + end + return result + `) + + // decrementAccountWaitScript - account-level wait queue decrement + decrementAccountWaitScript = redis.NewScript(` local current = redis.call('GET', KEYS[1]) if current ~= false and tonumber(current) > 0 then redis.call('DECR', KEYS[1]) @@ -244,7 +383,9 @@ func userSlotKey(userID int64) string { } func waitQueueKey(userID int64) string { - return fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID) + // Historical: per-user string keys were used. + // Now we use global structures keyed by userID string. + return strconv.FormatInt(userID, 10) } func accountWaitKey(accountID int64) string { @@ -308,8 +449,16 @@ func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) // Wait queue operations func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) { - key := waitQueueKey(userID) - result, err := incrementWaitScript.Run(ctx, c.rdb, []string{key}, maxWait, c.slotTTLSeconds).Int() + userKey := waitQueueKey(userID) + result, err := incrementWaitScript.Run( + ctx, + c.rdb, + []string{waitQueueTotalKey, waitQueueUpdatedKey, waitQueueCountsKey}, + userKey, + maxWait, + c.waitQueueTTLSeconds, + 200, // cleanup limit per call + ).Int() if err != nil { return false, err } @@ -317,11 +466,35 @@ func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, } func (c *concurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error { - key := waitQueueKey(userID) - _, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result() + userKey := waitQueueKey(userID) + _, err := decrementWaitScript.Run( + ctx, + c.rdb, + []string{waitQueueTotalKey, waitQueueUpdatedKey, waitQueueCountsKey}, + userKey, + c.waitQueueTTLSeconds, + 200, // cleanup limit per call + ).Result() return err } +func (c *concurrencyCache) GetTotalWaitCount(ctx context.Context) (int, error) { + if c.rdb == nil { + return 0, nil + } + total, err := getTotalWaitScript.Run( + ctx, + c.rdb, + []string{waitQueueTotalKey, waitQueueUpdatedKey, waitQueueCountsKey}, + c.waitQueueTTLSeconds, + 500, // cleanup limit per query (rare) + ).Int64() + if err != nil { + return 0, err + } + return int(total), nil +} + // Account wait queue operations func (c *concurrencyCache) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) { @@ -335,7 +508,7 @@ func (c *concurrencyCache) IncrementAccountWaitCount(ctx context.Context, accoun func (c *concurrencyCache) DecrementAccountWaitCount(ctx context.Context, accountID int64) error { key := accountWaitKey(accountID) - _, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result() + _, err := decrementAccountWaitScript.Run(ctx, c.rdb, []string{key}).Result() return err } diff --git a/backend/internal/repository/concurrency_cache_integration_test.go b/backend/internal/repository/concurrency_cache_integration_test.go index 5983c832..56cd1d2e 100644 --- a/backend/internal/repository/concurrency_cache_integration_test.go +++ b/backend/internal/repository/concurrency_cache_integration_test.go @@ -158,7 +158,7 @@ func (s *ConcurrencyCacheSuite) TestUserSlot_TTL() { func (s *ConcurrencyCacheSuite) TestWaitQueue_IncrementAndDecrement() { userID := int64(20) - waitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID) + userKey := waitQueueKey(userID) ok, err := s.cache.IncrementWaitCount(s.ctx, userID, 2) require.NoError(s.T(), err, "IncrementWaitCount 1") @@ -172,31 +172,31 @@ func (s *ConcurrencyCacheSuite) TestWaitQueue_IncrementAndDecrement() { require.NoError(s.T(), err, "IncrementWaitCount 3") require.False(s.T(), ok, "expected wait increment over max to fail") - ttl, err := s.rdb.TTL(s.ctx, waitKey).Result() - require.NoError(s.T(), err, "TTL waitKey") - s.AssertTTLWithin(ttl, 1*time.Second, testSlotTTL) + ttl, err := s.rdb.TTL(s.ctx, waitQueueTotalKey).Result() + require.NoError(s.T(), err, "TTL wait total key") + s.AssertTTLWithin(ttl, 1*time.Second, testSlotTTL*2) require.NoError(s.T(), s.cache.DecrementWaitCount(s.ctx, userID), "DecrementWaitCount") - val, err := s.rdb.Get(s.ctx, waitKey).Int() - if !errors.Is(err, redis.Nil) { - require.NoError(s.T(), err, "Get waitKey") - } + val, err := s.rdb.HGet(s.ctx, waitQueueCountsKey, userKey).Int() + require.NoError(s.T(), err, "HGET wait queue count") require.Equal(s.T(), 1, val, "expected wait count 1") + + total, err := s.rdb.Get(s.ctx, waitQueueTotalKey).Int() + require.NoError(s.T(), err, "GET wait queue total") + require.Equal(s.T(), 1, total, "expected total wait count 1") } func (s *ConcurrencyCacheSuite) TestWaitQueue_DecrementNoNegative() { userID := int64(300) - waitKey := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID) + userKey := waitQueueKey(userID) // Test decrement on non-existent key - should not error and should not create negative value require.NoError(s.T(), s.cache.DecrementWaitCount(s.ctx, userID), "DecrementWaitCount on non-existent key") - // Verify no key was created or it's not negative - val, err := s.rdb.Get(s.ctx, waitKey).Int() - if !errors.Is(err, redis.Nil) { - require.NoError(s.T(), err, "Get waitKey") - } + // Verify count remains zero / absent. + val, err := s.rdb.HGet(s.ctx, waitQueueCountsKey, userKey).Int() + require.True(s.T(), errors.Is(err, redis.Nil)) require.GreaterOrEqual(s.T(), val, 0, "expected non-negative wait count after decrement on empty") // Set count to 1, then decrement twice @@ -210,12 +210,15 @@ func (s *ConcurrencyCacheSuite) TestWaitQueue_DecrementNoNegative() { // Decrement again on 0 - should not go negative require.NoError(s.T(), s.cache.DecrementWaitCount(s.ctx, userID), "DecrementWaitCount on zero") - // Verify count is 0, not negative - val, err = s.rdb.Get(s.ctx, waitKey).Int() + // Verify per-user count is absent and total is non-negative. + _, err = s.rdb.HGet(s.ctx, waitQueueCountsKey, userKey).Result() + require.True(s.T(), errors.Is(err, redis.Nil), "expected count field removed on zero") + + total, err := s.rdb.Get(s.ctx, waitQueueTotalKey).Int() if !errors.Is(err, redis.Nil) { - require.NoError(s.T(), err, "Get waitKey after double decrement") + require.NoError(s.T(), err) } - require.GreaterOrEqual(s.T(), val, 0, "expected non-negative wait count") + require.GreaterOrEqual(s.T(), total, 0, "expected non-negative total wait count") } func (s *ConcurrencyCacheSuite) TestAccountWaitQueue_IncrementAndDecrement() { diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index d457ba72..9df74a83 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -1,4 +1,4 @@ -// Package infrastructure 提供应用程序的基础设施层组件。 +// Package repository 提供应用程序的基础设施层组件。 // 包括数据库连接初始化、ORM 客户端管理、Redis 连接、数据库迁移等核心功能。 package repository diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index ab8e8a4f..c606b75e 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -243,7 +243,7 @@ func mustCreateAccount(t *testing.T, client *dbent.Client, a *service.Account) * return a } -func mustCreateApiKey(t *testing.T, client *dbent.Client, k *service.ApiKey) *service.ApiKey { +func mustCreateAPIKey(t *testing.T, client *dbent.Client, k *service.APIKey) *service.APIKey { t.Helper() ctx := context.Background() @@ -257,7 +257,7 @@ func mustCreateApiKey(t *testing.T, client *dbent.Client, k *service.ApiKey) *se k.Name = "default" } - create := client.ApiKey.Create(). + create := client.APIKey.Create(). SetUserID(k.UserID). SetKey(k.Key). SetName(k.Name). diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 53085247..c4597ce2 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -293,8 +293,8 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, // 2. Clear group_id for api keys bound to this group. // 仅更新未软删除的记录,避免修改已删除数据,保证审计与历史回溯一致性。 - // 与 ApiKeyRepository 的软删除语义保持一致,减少跨模块行为差异。 - if _, err := txClient.ApiKey.Update(). + // 与 APIKeyRepository 的软删除语义保持一致,减少跨模块行为差异。 + if _, err := txClient.APIKey.Update(). Where(apikey.GroupIDEQ(id), apikey.DeletedAtIsNil()). ClearGroupID(). Save(ctx); err != nil { diff --git a/backend/internal/repository/ops.go b/backend/internal/repository/ops.go new file mode 100644 index 00000000..969a49a7 --- /dev/null +++ b/backend/internal/repository/ops.go @@ -0,0 +1,190 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// ListErrorLogs queries ops_error_logs with optional filters and pagination. +// It returns the list items and the total count of matching rows. +func (r *OpsRepository) ListErrorLogs(ctx context.Context, filter *service.ErrorLogFilter) ([]*service.ErrorLog, int64, error) { + page := 1 + pageSize := 20 + if filter != nil { + if filter.Page > 0 { + page = filter.Page + } + if filter.PageSize > 0 { + pageSize = filter.PageSize + } + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + conditions := make([]string, 0) + args := make([]any, 0) + + addCondition := func(condition string, values ...any) { + conditions = append(conditions, condition) + args = append(args, values...) + } + + if filter != nil { + // 默认查询最近 24 小时 + if filter.StartTime == nil && filter.EndTime == nil { + defaultStart := time.Now().Add(-24 * time.Hour) + filter.StartTime = &defaultStart + } + + if filter.StartTime != nil { + addCondition(fmt.Sprintf("created_at >= $%d", len(args)+1), *filter.StartTime) + } + if filter.EndTime != nil { + addCondition(fmt.Sprintf("created_at <= $%d", len(args)+1), *filter.EndTime) + } + if filter.ErrorCode != nil { + addCondition(fmt.Sprintf("status_code = $%d", len(args)+1), *filter.ErrorCode) + } + if provider := strings.TrimSpace(filter.Provider); provider != "" { + addCondition(fmt.Sprintf("platform = $%d", len(args)+1), provider) + } + if filter.AccountID != nil { + addCondition(fmt.Sprintf("account_id = $%d", len(args)+1), *filter.AccountID) + } + } + + where := "" + if len(conditions) > 0 { + where = "WHERE " + strings.Join(conditions, " AND ") + } + + countQuery := fmt.Sprintf(`SELECT COUNT(1) FROM ops_error_logs %s`, where) + var total int64 + if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil { + if err == sql.ErrNoRows { + total = 0 + } else { + return nil, 0, err + } + } + + listQuery := fmt.Sprintf(` + SELECT + id, + created_at, + severity, + request_id, + account_id, + request_path, + platform, + model, + status_code, + error_message, + duration_ms, + retry_count, + stream + FROM ops_error_logs + %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d + `, where, len(args)+1, len(args)+2) + + listArgs := append(append([]any{}, args...), pageSize, offset) + rows, err := r.sql.QueryContext(ctx, listQuery, listArgs...) + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + results := make([]*service.ErrorLog, 0) + for rows.Next() { + var ( + id int64 + createdAt time.Time + severity sql.NullString + requestID sql.NullString + accountID sql.NullInt64 + requestURI sql.NullString + platform sql.NullString + model sql.NullString + statusCode sql.NullInt64 + message sql.NullString + durationMs sql.NullInt64 + retryCount sql.NullInt64 + stream sql.NullBool + ) + + if err := rows.Scan( + &id, + &createdAt, + &severity, + &requestID, + &accountID, + &requestURI, + &platform, + &model, + &statusCode, + &message, + &durationMs, + &retryCount, + &stream, + ); err != nil { + return nil, 0, err + } + + entry := &service.ErrorLog{ + ID: id, + Timestamp: createdAt, + Level: levelFromSeverity(severity.String), + RequestID: requestID.String, + APIPath: requestURI.String, + Provider: platform.String, + Model: model.String, + HTTPCode: int(statusCode.Int64), + Stream: stream.Bool, + } + if accountID.Valid { + entry.AccountID = strconv.FormatInt(accountID.Int64, 10) + } + if message.Valid { + entry.ErrorMessage = message.String + } + if durationMs.Valid { + v := int(durationMs.Int64) + entry.DurationMs = &v + } + if retryCount.Valid { + v := int(retryCount.Int64) + entry.RetryCount = &v + } + + results = append(results, entry) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + return results, total, nil +} + +func levelFromSeverity(severity string) string { + sev := strings.ToUpper(strings.TrimSpace(severity)) + switch sev { + case "P0", "P1": + return "CRITICAL" + case "P2": + return "ERROR" + case "P3": + return "WARN" + default: + return "ERROR" + } +} diff --git a/backend/internal/repository/ops_cache.go b/backend/internal/repository/ops_cache.go new file mode 100644 index 00000000..99d60634 --- /dev/null +++ b/backend/internal/repository/ops_cache.go @@ -0,0 +1,127 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const ( + opsLatestMetricsKey = "ops:metrics:latest" + + opsDashboardOverviewKeyPrefix = "ops:dashboard:overview:" + + opsLatestMetricsTTL = 10 * time.Second +) + +func (r *OpsRepository) GetCachedLatestSystemMetric(ctx context.Context) (*service.OpsMetrics, error) { + if ctx == nil { + ctx = context.Background() + } + if r == nil || r.rdb == nil { + return nil, nil + } + + data, err := r.rdb.Get(ctx, opsLatestMetricsKey).Bytes() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("redis get cached latest system metric: %w", err) + } + + var metric service.OpsMetrics + if err := json.Unmarshal(data, &metric); err != nil { + return nil, fmt.Errorf("unmarshal cached latest system metric: %w", err) + } + return &metric, nil +} + +func (r *OpsRepository) SetCachedLatestSystemMetric(ctx context.Context, metric *service.OpsMetrics) error { + if metric == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + if r == nil || r.rdb == nil { + return nil + } + + data, err := json.Marshal(metric) + if err != nil { + return fmt.Errorf("marshal cached latest system metric: %w", err) + } + return r.rdb.Set(ctx, opsLatestMetricsKey, data, opsLatestMetricsTTL).Err() +} + +func (r *OpsRepository) GetCachedDashboardOverview(ctx context.Context, timeRange string) (*service.DashboardOverviewData, error) { + if ctx == nil { + ctx = context.Background() + } + if r == nil || r.rdb == nil { + return nil, nil + } + rangeKey := strings.TrimSpace(timeRange) + if rangeKey == "" { + rangeKey = "1h" + } + + key := opsDashboardOverviewKeyPrefix + rangeKey + data, err := r.rdb.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("redis get cached dashboard overview: %w", err) + } + + var overview service.DashboardOverviewData + if err := json.Unmarshal(data, &overview); err != nil { + return nil, fmt.Errorf("unmarshal cached dashboard overview: %w", err) + } + return &overview, nil +} + +func (r *OpsRepository) SetCachedDashboardOverview(ctx context.Context, timeRange string, data *service.DashboardOverviewData, ttl time.Duration) error { + if data == nil { + return nil + } + if ttl <= 0 { + ttl = 10 * time.Second + } + if ctx == nil { + ctx = context.Background() + } + if r == nil || r.rdb == nil { + return nil + } + + rangeKey := strings.TrimSpace(timeRange) + if rangeKey == "" { + rangeKey = "1h" + } + + payload, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshal cached dashboard overview: %w", err) + } + key := opsDashboardOverviewKeyPrefix + rangeKey + return r.rdb.Set(ctx, key, payload, ttl).Err() +} + +func (r *OpsRepository) PingRedis(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + if r == nil || r.rdb == nil { + return errors.New("redis client is nil") + } + return r.rdb.Ping(ctx).Err() +} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go new file mode 100644 index 00000000..f75f9abf --- /dev/null +++ b/backend/internal/repository/ops_repo.go @@ -0,0 +1,1333 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const ( + DefaultWindowMinutes = 1 + + MaxErrorLogsLimit = 500 + DefaultErrorLogsLimit = 200 + + MaxRecentSystemMetricsLimit = 500 + DefaultRecentSystemMetricsLimit = 60 + + MaxMetricsLimit = 5000 + DefaultMetricsLimit = 300 +) + +type OpsRepository struct { + sql sqlExecutor + rdb *redis.Client +} + +func NewOpsRepository(_ *dbent.Client, sqlDB *sql.DB, rdb *redis.Client) service.OpsRepository { + return &OpsRepository{sql: sqlDB, rdb: rdb} +} + +func (r *OpsRepository) CreateErrorLog(ctx context.Context, log *service.OpsErrorLog) error { + if log == nil { + return nil + } + + createdAt := log.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + + query := ` + INSERT INTO ops_error_logs ( + request_id, + user_id, + api_key_id, + account_id, + group_id, + client_ip, + error_phase, + error_type, + severity, + status_code, + platform, + model, + request_path, + stream, + error_message, + duration_ms, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17 + ) + RETURNING id, created_at + ` + + requestID := nullString(log.RequestID) + clientIP := nullString(log.ClientIP) + platform := nullString(log.Platform) + model := nullString(log.Model) + requestPath := nullString(log.RequestPath) + message := nullString(log.Message) + latency := nullInt(log.LatencyMs) + + args := []any{ + requestID, + nullInt64(log.UserID), + nullInt64(log.APIKeyID), + nullInt64(log.AccountID), + nullInt64(log.GroupID), + clientIP, + log.Phase, + log.Type, + log.Severity, + log.StatusCode, + platform, + model, + requestPath, + log.Stream, + message, + latency, + createdAt, + } + + if err := scanSingleRow(ctx, r.sql, query, args, &log.ID, &log.CreatedAt); err != nil { + return err + } + return nil +} + +func (r *OpsRepository) ListErrorLogsLegacy(ctx context.Context, filters service.OpsErrorLogFilters) ([]service.OpsErrorLog, error) { + conditions := make([]string, 0) + args := make([]any, 0) + + addCondition := func(condition string, values ...any) { + conditions = append(conditions, condition) + args = append(args, values...) + } + + if filters.StartTime != nil { + addCondition(fmt.Sprintf("created_at >= $%d", len(args)+1), *filters.StartTime) + } + if filters.EndTime != nil { + addCondition(fmt.Sprintf("created_at <= $%d", len(args)+1), *filters.EndTime) + } + if filters.Platform != "" { + addCondition(fmt.Sprintf("platform = $%d", len(args)+1), filters.Platform) + } + if filters.Phase != "" { + addCondition(fmt.Sprintf("error_phase = $%d", len(args)+1), filters.Phase) + } + if filters.Severity != "" { + addCondition(fmt.Sprintf("severity = $%d", len(args)+1), filters.Severity) + } + if filters.Query != "" { + like := "%" + strings.ToLower(filters.Query) + "%" + startIdx := len(args) + 1 + addCondition( + fmt.Sprintf("(LOWER(request_id) LIKE $%d OR LOWER(model) LIKE $%d OR LOWER(error_message) LIKE $%d OR LOWER(error_type) LIKE $%d)", + startIdx, startIdx+1, startIdx+2, startIdx+3, + ), + like, like, like, like, + ) + } + + limit := filters.Limit + if limit <= 0 || limit > MaxErrorLogsLimit { + limit = DefaultErrorLogsLimit + } + + where := "" + if len(conditions) > 0 { + where = "WHERE " + strings.Join(conditions, " AND ") + } + + query := fmt.Sprintf(` + SELECT + id, + created_at, + user_id, + api_key_id, + account_id, + group_id, + client_ip, + error_phase, + error_type, + severity, + status_code, + platform, + model, + request_path, + stream, + duration_ms, + request_id, + error_message + FROM ops_error_logs + %s + ORDER BY created_at DESC + LIMIT $%d + `, where, len(args)+1) + + args = append(args, limit) + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]service.OpsErrorLog, 0) + for rows.Next() { + logEntry, err := scanOpsErrorLog(rows) + if err != nil { + return nil, err + } + results = append(results, *logEntry) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func (r *OpsRepository) GetLatestSystemMetric(ctx context.Context) (*service.OpsMetrics, error) { + query := ` + SELECT + window_minutes, + request_count, + success_count, + error_count, + success_rate, + error_rate, + p95_latency_ms, + p99_latency_ms, + http2_errors, + active_alerts, + cpu_usage_percent, + memory_used_mb, + memory_total_mb, + memory_usage_percent, + heap_alloc_mb, + gc_pause_ms, + concurrency_queue_depth, + created_at AS updated_at + FROM ops_system_metrics + WHERE window_minutes = $1 + ORDER BY updated_at DESC, id DESC + LIMIT 1 + ` + + var windowMinutes sql.NullInt64 + var requestCount, successCount, errorCount sql.NullInt64 + var successRate, errorRate sql.NullFloat64 + var p95Latency, p99Latency, http2Errors, activeAlerts sql.NullInt64 + var cpuUsage, memoryUsage, gcPause sql.NullFloat64 + var memoryUsed, memoryTotal, heapAlloc, queueDepth sql.NullInt64 + var createdAt time.Time + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{DefaultWindowMinutes}, + &windowMinutes, + &requestCount, + &successCount, + &errorCount, + &successRate, + &errorRate, + &p95Latency, + &p99Latency, + &http2Errors, + &activeAlerts, + &cpuUsage, + &memoryUsed, + &memoryTotal, + &memoryUsage, + &heapAlloc, + &gcPause, + &queueDepth, + &createdAt, + ); err != nil { + return nil, err + } + + metric := &service.OpsMetrics{ + UpdatedAt: createdAt, + } + if windowMinutes.Valid { + metric.WindowMinutes = int(windowMinutes.Int64) + } + if requestCount.Valid { + metric.RequestCount = requestCount.Int64 + } + if successCount.Valid { + metric.SuccessCount = successCount.Int64 + } + if errorCount.Valid { + metric.ErrorCount = errorCount.Int64 + } + if successRate.Valid { + metric.SuccessRate = successRate.Float64 + } + if errorRate.Valid { + metric.ErrorRate = errorRate.Float64 + } + if p95Latency.Valid { + metric.P95LatencyMs = int(p95Latency.Int64) + } + if p99Latency.Valid { + metric.P99LatencyMs = int(p99Latency.Int64) + } + if http2Errors.Valid { + metric.HTTP2Errors = int(http2Errors.Int64) + } + if activeAlerts.Valid { + metric.ActiveAlerts = int(activeAlerts.Int64) + } + if cpuUsage.Valid { + metric.CPUUsagePercent = cpuUsage.Float64 + } + if memoryUsed.Valid { + metric.MemoryUsedMB = memoryUsed.Int64 + } + if memoryTotal.Valid { + metric.MemoryTotalMB = memoryTotal.Int64 + } + if memoryUsage.Valid { + metric.MemoryUsagePercent = memoryUsage.Float64 + } + if heapAlloc.Valid { + metric.HeapAllocMB = heapAlloc.Int64 + } + if gcPause.Valid { + metric.GCPauseMs = gcPause.Float64 + } + if queueDepth.Valid { + metric.ConcurrencyQueueDepth = int(queueDepth.Int64) + } + return metric, nil +} + +func (r *OpsRepository) CreateSystemMetric(ctx context.Context, metric *service.OpsMetrics) error { + if metric == nil { + return nil + } + createdAt := metric.UpdatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + windowMinutes := metric.WindowMinutes + if windowMinutes <= 0 { + windowMinutes = DefaultWindowMinutes + } + + query := ` + INSERT INTO ops_system_metrics ( + window_minutes, + request_count, + success_count, + error_count, + success_rate, + error_rate, + p95_latency_ms, + p99_latency_ms, + http2_errors, + active_alerts, + cpu_usage_percent, + memory_used_mb, + memory_total_mb, + memory_usage_percent, + heap_alloc_mb, + gc_pause_ms, + concurrency_queue_depth, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18 + ) + ` + _, err := r.sql.ExecContext(ctx, query, + windowMinutes, + metric.RequestCount, + metric.SuccessCount, + metric.ErrorCount, + metric.SuccessRate, + metric.ErrorRate, + metric.P95LatencyMs, + metric.P99LatencyMs, + metric.HTTP2Errors, + metric.ActiveAlerts, + metric.CPUUsagePercent, + metric.MemoryUsedMB, + metric.MemoryTotalMB, + metric.MemoryUsagePercent, + metric.HeapAllocMB, + metric.GCPauseMs, + metric.ConcurrencyQueueDepth, + createdAt, + ) + return err +} + +func (r *OpsRepository) ListRecentSystemMetrics(ctx context.Context, windowMinutes, limit int) ([]service.OpsMetrics, error) { + if windowMinutes <= 0 { + windowMinutes = DefaultWindowMinutes + } + if limit <= 0 || limit > MaxRecentSystemMetricsLimit { + limit = DefaultRecentSystemMetricsLimit + } + + query := ` + SELECT + window_minutes, + request_count, + success_count, + error_count, + success_rate, + error_rate, + p95_latency_ms, + p99_latency_ms, + http2_errors, + active_alerts, + cpu_usage_percent, + memory_used_mb, + memory_total_mb, + memory_usage_percent, + heap_alloc_mb, + gc_pause_ms, + concurrency_queue_depth, + created_at AS updated_at + FROM ops_system_metrics + WHERE window_minutes = $1 + ORDER BY updated_at DESC, id DESC + LIMIT $2 + ` + + rows, err := r.sql.QueryContext(ctx, query, windowMinutes, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]service.OpsMetrics, 0) + for rows.Next() { + metric, err := scanOpsSystemMetric(rows) + if err != nil { + return nil, err + } + results = append(results, *metric) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func (r *OpsRepository) ListSystemMetricsRange(ctx context.Context, windowMinutes int, startTime, endTime time.Time, limit int) ([]service.OpsMetrics, error) { + if windowMinutes <= 0 { + windowMinutes = DefaultWindowMinutes + } + if limit <= 0 || limit > MaxMetricsLimit { + limit = DefaultMetricsLimit + } + if endTime.IsZero() { + endTime = time.Now() + } + if startTime.IsZero() { + startTime = endTime.Add(-time.Duration(limit) * time.Minute) + } + if startTime.After(endTime) { + startTime, endTime = endTime, startTime + } + + query := ` + SELECT + window_minutes, + request_count, + success_count, + error_count, + success_rate, + error_rate, + p95_latency_ms, + p99_latency_ms, + http2_errors, + active_alerts, + cpu_usage_percent, + memory_used_mb, + memory_total_mb, + memory_usage_percent, + heap_alloc_mb, + gc_pause_ms, + concurrency_queue_depth, + created_at + FROM ops_system_metrics + WHERE window_minutes = $1 + AND created_at >= $2 + AND created_at <= $3 + ORDER BY created_at ASC + LIMIT $4 + ` + + rows, err := r.sql.QueryContext(ctx, query, windowMinutes, startTime, endTime, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]service.OpsMetrics, 0) + for rows.Next() { + metric, err := scanOpsSystemMetric(rows) + if err != nil { + return nil, err + } + results = append(results, *metric) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func (r *OpsRepository) ListAlertRules(ctx context.Context) ([]service.OpsAlertRule, error) { + query := ` + SELECT + id, + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes, + dimension_filters, + notify_channels, + notify_config, + created_at, + updated_at + FROM ops_alert_rules + ORDER BY id ASC + ` + + rows, err := r.sql.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + rules := make([]service.OpsAlertRule, 0) + for rows.Next() { + var rule service.OpsAlertRule + var description sql.NullString + var webhookURL sql.NullString + var dimensionFilters, notifyChannels, notifyConfig []byte + if err := rows.Scan( + &rule.ID, + &rule.Name, + &description, + &rule.Enabled, + &rule.MetricType, + &rule.Operator, + &rule.Threshold, + &rule.WindowMinutes, + &rule.SustainedMinutes, + &rule.Severity, + &rule.NotifyEmail, + &rule.NotifyWebhook, + &webhookURL, + &rule.CooldownMinutes, + &dimensionFilters, + ¬ifyChannels, + ¬ifyConfig, + &rule.CreatedAt, + &rule.UpdatedAt, + ); err != nil { + return nil, err + } + if description.Valid { + rule.Description = description.String + } + if webhookURL.Valid { + rule.WebhookURL = webhookURL.String + } + if len(dimensionFilters) > 0 { + _ = json.Unmarshal(dimensionFilters, &rule.DimensionFilters) + } + if len(notifyChannels) > 0 { + _ = json.Unmarshal(notifyChannels, &rule.NotifyChannels) + } + if len(notifyConfig) > 0 { + _ = json.Unmarshal(notifyConfig, &rule.NotifyConfig) + } + rules = append(rules, rule) + } + if err := rows.Err(); err != nil { + return nil, err + } + return rules, nil +} + +func (r *OpsRepository) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) { + return r.getAlertEvent(ctx, `WHERE rule_id = $1 AND status = $2`, []any{ruleID, service.OpsAlertStatusFiring}) +} + +func (r *OpsRepository) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) { + return r.getAlertEvent(ctx, `WHERE rule_id = $1`, []any{ruleID}) +} + +func (r *OpsRepository) CreateAlertEvent(ctx context.Context, event *service.OpsAlertEvent) error { + if event == nil { + return nil + } + if event.FiredAt.IsZero() { + event.FiredAt = time.Now() + } + if event.CreatedAt.IsZero() { + event.CreatedAt = event.FiredAt + } + if event.Status == "" { + event.Status = service.OpsAlertStatusFiring + } + + query := ` + INSERT INTO ops_alert_events ( + rule_id, + severity, + status, + title, + description, + metric_value, + threshold_value, + fired_at, + resolved_at, + email_sent, + webhook_sent, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12 + ) + RETURNING id, created_at + ` + + var resolvedAt sql.NullTime + if event.ResolvedAt != nil { + resolvedAt = sql.NullTime{Time: *event.ResolvedAt, Valid: true} + } + + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{ + event.RuleID, + event.Severity, + event.Status, + event.Title, + event.Description, + event.MetricValue, + event.ThresholdValue, + event.FiredAt, + resolvedAt, + event.EmailSent, + event.WebhookSent, + event.CreatedAt, + }, + &event.ID, + &event.CreatedAt, + ); err != nil { + return err + } + return nil +} + +func (r *OpsRepository) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error { + var resolved sql.NullTime + if resolvedAt != nil { + resolved = sql.NullTime{Time: *resolvedAt, Valid: true} + } + _, err := r.sql.ExecContext(ctx, ` + UPDATE ops_alert_events + SET status = $2, resolved_at = $3 + WHERE id = $1 + `, eventID, status, resolved) + return err +} + +func (r *OpsRepository) UpdateAlertEventNotifications(ctx context.Context, eventID int64, emailSent, webhookSent bool) error { + _, err := r.sql.ExecContext(ctx, ` + UPDATE ops_alert_events + SET email_sent = $2, webhook_sent = $3 + WHERE id = $1 + `, eventID, emailSent, webhookSent) + return err +} + +func (r *OpsRepository) CountActiveAlerts(ctx context.Context) (int, error) { + var count int64 + if err := scanSingleRow( + ctx, + r.sql, + `SELECT COUNT(*) FROM ops_alert_events WHERE status = $1`, + []any{service.OpsAlertStatusFiring}, + &count, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return 0, err + } + return int(count), nil +} + +func (r *OpsRepository) GetWindowStats(ctx context.Context, startTime, endTime time.Time) (*service.OpsWindowStats, error) { + query := ` + WITH + usage_agg AS ( + SELECT + COUNT(*) AS success_count, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) + FILTER (WHERE duration_ms IS NOT NULL) AS p95, + percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) + FILTER (WHERE duration_ms IS NOT NULL) AS p99 + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + ), + error_agg AS ( + SELECT + COUNT(*) AS error_count, + COUNT(*) FILTER ( + WHERE + error_type = 'network_error' + OR error_message ILIKE '%http2%' + OR error_message ILIKE '%http/2%' + ) AS http2_errors + FROM ops_error_logs + WHERE created_at >= $1 AND created_at < $2 + ) + SELECT + usage_agg.success_count, + error_agg.error_count, + usage_agg.p95, + usage_agg.p99, + error_agg.http2_errors + FROM usage_agg + CROSS JOIN error_agg + ` + + var stats service.OpsWindowStats + var p95Latency, p99Latency sql.NullFloat64 + var http2Errors int64 + if err := scanSingleRow( + ctx, + r.sql, + query, + []any{startTime, endTime}, + &stats.SuccessCount, + &stats.ErrorCount, + &p95Latency, + &p99Latency, + &http2Errors, + ); err != nil { + return nil, err + } + + stats.HTTP2Errors = int(http2Errors) + if p95Latency.Valid { + stats.P95LatencyMs = int(math.Round(p95Latency.Float64)) + } + if p99Latency.Valid { + stats.P99LatencyMs = int(math.Round(p99Latency.Float64)) + } + + return &stats, nil +} + +func (r *OpsRepository) GetOverviewStats(ctx context.Context, startTime, endTime time.Time) (*service.OverviewStats, error) { + query := ` + WITH + usage_stats AS ( + SELECT + COUNT(*) AS request_count, + COUNT(*) FILTER (WHERE duration_ms IS NOT NULL) AS success_count, + percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS p95, + percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS p99, + percentile_cont(0.999) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS p999, + AVG(duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS avg_latency, + MAX(duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS max_latency + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + ), + error_stats AS ( + SELECT + COUNT(*) AS error_count, + COUNT(*) FILTER (WHERE status_code >= 400 AND status_code < 500) AS error_4xx, + COUNT(*) FILTER (WHERE status_code >= 500) AS error_5xx, + COUNT(*) FILTER ( + WHERE + error_type IN ('timeout', 'timeout_error') + OR error_message ILIKE '%timeout%' + OR error_message ILIKE '%deadline exceeded%' + ) AS timeout_count + FROM ops_error_logs + WHERE created_at >= $1 AND created_at < $2 + ), + top_error AS ( + SELECT + COALESCE(status_code::text, 'unknown') AS error_code, + error_message, + COUNT(*) AS error_count + FROM ops_error_logs + WHERE created_at >= $1 AND created_at < $2 + GROUP BY status_code, error_message + ORDER BY error_count DESC + LIMIT 1 + ), + latest_metrics AS ( + SELECT + cpu_usage_percent, + memory_usage_percent, + memory_used_mb, + memory_total_mb, + concurrency_queue_depth + FROM ops_system_metrics + ORDER BY created_at DESC + LIMIT 1 + ) + SELECT + COALESCE(usage_stats.request_count, 0) + COALESCE(error_stats.error_count, 0) AS request_count, + COALESCE(usage_stats.success_count, 0), + COALESCE(error_stats.error_count, 0), + COALESCE(error_stats.error_4xx, 0), + COALESCE(error_stats.error_5xx, 0), + COALESCE(error_stats.timeout_count, 0), + COALESCE(usage_stats.p50, 0), + COALESCE(usage_stats.p95, 0), + COALESCE(usage_stats.p99, 0), + COALESCE(usage_stats.p999, 0), + COALESCE(usage_stats.avg_latency, 0), + COALESCE(usage_stats.max_latency, 0), + COALESCE(top_error.error_code, ''), + COALESCE(top_error.error_message, ''), + COALESCE(top_error.error_count, 0), + COALESCE(latest_metrics.cpu_usage_percent, 0), + COALESCE(latest_metrics.memory_usage_percent, 0), + COALESCE(latest_metrics.memory_used_mb, 0), + COALESCE(latest_metrics.memory_total_mb, 0), + COALESCE(latest_metrics.concurrency_queue_depth, 0) + FROM usage_stats + CROSS JOIN error_stats + LEFT JOIN top_error ON true + LEFT JOIN latest_metrics ON true + ` + + var stats service.OverviewStats + var p50, p95, p99, p999, avgLatency, maxLatency sql.NullFloat64 + + err := scanSingleRow( + ctx, + r.sql, + query, + []any{startTime, endTime}, + &stats.RequestCount, + &stats.SuccessCount, + &stats.ErrorCount, + &stats.Error4xxCount, + &stats.Error5xxCount, + &stats.TimeoutCount, + &p50, + &p95, + &p99, + &p999, + &avgLatency, + &maxLatency, + &stats.TopErrorCode, + &stats.TopErrorMsg, + &stats.TopErrorCount, + &stats.CPUUsage, + &stats.MemoryUsage, + &stats.MemoryUsedMB, + &stats.MemoryTotalMB, + &stats.ConcurrencyQueueDepth, + ) + if err != nil { + return nil, err + } + + if p50.Valid { + stats.LatencyP50 = int(p50.Float64) + } + if p95.Valid { + stats.LatencyP95 = int(p95.Float64) + } + if p99.Valid { + stats.LatencyP99 = int(p99.Float64) + } + if p999.Valid { + stats.LatencyP999 = int(p999.Float64) + } + if avgLatency.Valid { + stats.LatencyAvg = int(avgLatency.Float64) + } + if maxLatency.Valid { + stats.LatencyMax = int(maxLatency.Float64) + } + + return &stats, nil +} + +func (r *OpsRepository) GetProviderStats(ctx context.Context, startTime, endTime time.Time) ([]*service.ProviderStats, error) { + if startTime.IsZero() || endTime.IsZero() { + return nil, nil + } + if startTime.After(endTime) { + startTime, endTime = endTime, startTime + } + + query := ` + WITH combined AS ( + SELECT + COALESCE(g.platform, a.platform, '') AS platform, + u.duration_ms AS duration_ms, + 1 AS is_success, + 0 AS is_error, + NULL::INT AS status_code, + NULL::TEXT AS error_type, + NULL::TEXT AS error_message + FROM usage_logs u + LEFT JOIN groups g ON g.id = u.group_id + LEFT JOIN accounts a ON a.id = u.account_id + WHERE u.created_at >= $1 AND u.created_at < $2 + + UNION ALL + + SELECT + COALESCE(NULLIF(o.platform, ''), g.platform, a.platform, '') AS platform, + o.duration_ms AS duration_ms, + 0 AS is_success, + 1 AS is_error, + o.status_code AS status_code, + o.error_type AS error_type, + o.error_message AS error_message + FROM ops_error_logs o + LEFT JOIN groups g ON g.id = o.group_id + LEFT JOIN accounts a ON a.id = o.account_id + WHERE o.created_at >= $1 AND o.created_at < $2 + ) + SELECT + platform, + COUNT(*) AS request_count, + COALESCE(SUM(is_success), 0) AS success_count, + COALESCE(SUM(is_error), 0) AS error_count, + COALESCE(AVG(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), 0) AS avg_latency_ms, + percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) + FILTER (WHERE duration_ms IS NOT NULL) AS p99_latency_ms, + COUNT(*) FILTER (WHERE is_error = 1 AND status_code >= 400 AND status_code < 500) AS error_4xx, + COUNT(*) FILTER (WHERE is_error = 1 AND status_code >= 500 AND status_code < 600) AS error_5xx, + COUNT(*) FILTER ( + WHERE + is_error = 1 + AND ( + status_code = 504 + OR error_type ILIKE '%timeout%' + OR error_message ILIKE '%timeout%' + ) + ) AS timeout_count + FROM combined + WHERE platform <> '' + GROUP BY platform + ORDER BY request_count DESC, platform ASC + ` + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]*service.ProviderStats, 0) + for rows.Next() { + var item service.ProviderStats + var avgLatency sql.NullFloat64 + var p99Latency sql.NullFloat64 + if err := rows.Scan( + &item.Platform, + &item.RequestCount, + &item.SuccessCount, + &item.ErrorCount, + &avgLatency, + &p99Latency, + &item.Error4xxCount, + &item.Error5xxCount, + &item.TimeoutCount, + ); err != nil { + return nil, err + } + + if avgLatency.Valid { + item.AvgLatencyMs = int(math.Round(avgLatency.Float64)) + } + if p99Latency.Valid { + item.P99LatencyMs = int(math.Round(p99Latency.Float64)) + } + + results = append(results, &item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +func (r *OpsRepository) GetLatencyHistogram(ctx context.Context, startTime, endTime time.Time) ([]*service.LatencyHistogramItem, error) { + query := ` + WITH buckets AS ( + SELECT + CASE + WHEN duration_ms < 200 THEN '<200ms' + WHEN duration_ms < 500 THEN '200-500ms' + WHEN duration_ms < 1000 THEN '500-1000ms' + WHEN duration_ms < 3000 THEN '1000-3000ms' + ELSE '>3000ms' + END AS range_name, + CASE + WHEN duration_ms < 200 THEN 1 + WHEN duration_ms < 500 THEN 2 + WHEN duration_ms < 1000 THEN 3 + WHEN duration_ms < 3000 THEN 4 + ELSE 5 + END AS range_order, + COUNT(*) AS count + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 AND duration_ms IS NOT NULL + GROUP BY 1, 2 + ), + total AS ( + SELECT SUM(count) AS total_count FROM buckets + ) + SELECT + b.range_name, + b.count, + ROUND((b.count::numeric / t.total_count) * 100, 2) AS percentage + FROM buckets b + CROSS JOIN total t + ORDER BY b.range_order ASC + ` + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]*service.LatencyHistogramItem, 0) + for rows.Next() { + var item service.LatencyHistogramItem + if err := rows.Scan(&item.Range, &item.Count, &item.Percentage); err != nil { + return nil, err + } + results = append(results, &item) + } + return results, nil +} + +func (r *OpsRepository) GetErrorDistribution(ctx context.Context, startTime, endTime time.Time) ([]*service.ErrorDistributionItem, error) { + query := ` + WITH errors AS ( + SELECT + COALESCE(status_code::text, 'unknown') AS code, + COALESCE(error_message, 'Unknown error') AS message, + COUNT(*) AS count + FROM ops_error_logs + WHERE created_at >= $1 AND created_at < $2 + GROUP BY 1, 2 + ), + total AS ( + SELECT SUM(count) AS total_count FROM errors + ) + SELECT + e.code, + e.message, + e.count, + ROUND((e.count::numeric / t.total_count) * 100, 2) AS percentage + FROM errors e + CROSS JOIN total t + ORDER BY e.count DESC + LIMIT 20 + ` + + rows, err := r.sql.QueryContext(ctx, query, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + results := make([]*service.ErrorDistributionItem, 0) + for rows.Next() { + var item service.ErrorDistributionItem + if err := rows.Scan(&item.Code, &item.Message, &item.Count, &item.Percentage); err != nil { + return nil, err + } + results = append(results, &item) + } + return results, nil +} + +func (r *OpsRepository) getAlertEvent(ctx context.Context, whereClause string, args []any) (*service.OpsAlertEvent, error) { + query := fmt.Sprintf(` + SELECT + id, + rule_id, + severity, + status, + title, + description, + metric_value, + threshold_value, + fired_at, + resolved_at, + email_sent, + webhook_sent, + created_at + FROM ops_alert_events + %s + ORDER BY fired_at DESC + LIMIT 1 + `, whereClause) + + var event service.OpsAlertEvent + var resolvedAt sql.NullTime + var metricValue sql.NullFloat64 + var thresholdValue sql.NullFloat64 + if err := scanSingleRow( + ctx, + r.sql, + query, + args, + &event.ID, + &event.RuleID, + &event.Severity, + &event.Status, + &event.Title, + &event.Description, + &metricValue, + &thresholdValue, + &event.FiredAt, + &resolvedAt, + &event.EmailSent, + &event.WebhookSent, + &event.CreatedAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + if metricValue.Valid { + event.MetricValue = metricValue.Float64 + } + if thresholdValue.Valid { + event.ThresholdValue = thresholdValue.Float64 + } + if resolvedAt.Valid { + event.ResolvedAt = &resolvedAt.Time + } + return &event, nil +} + +func scanOpsSystemMetric(rows *sql.Rows) (*service.OpsMetrics, error) { + var metric service.OpsMetrics + var windowMinutes sql.NullInt64 + var requestCount, successCount, errorCount sql.NullInt64 + var successRate, errorRate sql.NullFloat64 + var p95Latency, p99Latency, http2Errors, activeAlerts sql.NullInt64 + var cpuUsage, memoryUsage, gcPause sql.NullFloat64 + var memoryUsed, memoryTotal, heapAlloc, queueDepth sql.NullInt64 + + if err := rows.Scan( + &windowMinutes, + &requestCount, + &successCount, + &errorCount, + &successRate, + &errorRate, + &p95Latency, + &p99Latency, + &http2Errors, + &activeAlerts, + &cpuUsage, + &memoryUsed, + &memoryTotal, + &memoryUsage, + &heapAlloc, + &gcPause, + &queueDepth, + &metric.UpdatedAt, + ); err != nil { + return nil, err + } + + if windowMinutes.Valid { + metric.WindowMinutes = int(windowMinutes.Int64) + } + if requestCount.Valid { + metric.RequestCount = requestCount.Int64 + } + if successCount.Valid { + metric.SuccessCount = successCount.Int64 + } + if errorCount.Valid { + metric.ErrorCount = errorCount.Int64 + } + if successRate.Valid { + metric.SuccessRate = successRate.Float64 + } + if errorRate.Valid { + metric.ErrorRate = errorRate.Float64 + } + if p95Latency.Valid { + metric.P95LatencyMs = int(p95Latency.Int64) + } + if p99Latency.Valid { + metric.P99LatencyMs = int(p99Latency.Int64) + } + if http2Errors.Valid { + metric.HTTP2Errors = int(http2Errors.Int64) + } + if activeAlerts.Valid { + metric.ActiveAlerts = int(activeAlerts.Int64) + } + if cpuUsage.Valid { + metric.CPUUsagePercent = cpuUsage.Float64 + } + if memoryUsed.Valid { + metric.MemoryUsedMB = memoryUsed.Int64 + } + if memoryTotal.Valid { + metric.MemoryTotalMB = memoryTotal.Int64 + } + if memoryUsage.Valid { + metric.MemoryUsagePercent = memoryUsage.Float64 + } + if heapAlloc.Valid { + metric.HeapAllocMB = heapAlloc.Int64 + } + if gcPause.Valid { + metric.GCPauseMs = gcPause.Float64 + } + if queueDepth.Valid { + metric.ConcurrencyQueueDepth = int(queueDepth.Int64) + } + + return &metric, nil +} + +func scanOpsErrorLog(rows *sql.Rows) (*service.OpsErrorLog, error) { + var entry service.OpsErrorLog + var userID, apiKeyID, accountID, groupID sql.NullInt64 + var clientIP sql.NullString + var statusCode sql.NullInt64 + var platform sql.NullString + var model sql.NullString + var requestPath sql.NullString + var stream sql.NullBool + var latency sql.NullInt64 + var requestID sql.NullString + var message sql.NullString + + if err := rows.Scan( + &entry.ID, + &entry.CreatedAt, + &userID, + &apiKeyID, + &accountID, + &groupID, + &clientIP, + &entry.Phase, + &entry.Type, + &entry.Severity, + &statusCode, + &platform, + &model, + &requestPath, + &stream, + &latency, + &requestID, + &message, + ); err != nil { + return nil, err + } + + if userID.Valid { + v := userID.Int64 + entry.UserID = &v + } + if apiKeyID.Valid { + v := apiKeyID.Int64 + entry.APIKeyID = &v + } + if accountID.Valid { + v := accountID.Int64 + entry.AccountID = &v + } + if groupID.Valid { + v := groupID.Int64 + entry.GroupID = &v + } + if clientIP.Valid { + entry.ClientIP = clientIP.String + } + if statusCode.Valid { + entry.StatusCode = int(statusCode.Int64) + } + if platform.Valid { + entry.Platform = platform.String + } + if model.Valid { + entry.Model = model.String + } + if requestPath.Valid { + entry.RequestPath = requestPath.String + } + if stream.Valid { + entry.Stream = stream.Bool + } + if latency.Valid { + value := int(latency.Int64) + entry.LatencyMs = &value + } + if requestID.Valid { + entry.RequestID = requestID.String + } + if message.Valid { + entry.Message = message.String + } + + return &entry, nil +} + +func nullString(value string) sql.NullString { + if value == "" { + return sql.NullString{} + } + return sql.NullString{String: value, Valid: true} +} diff --git a/backend/internal/repository/soft_delete_ent_integration_test.go b/backend/internal/repository/soft_delete_ent_integration_test.go index e3560ab5..ccba9043 100644 --- a/backend/internal/repository/soft_delete_ent_integration_test.go +++ b/backend/internal/repository/soft_delete_ent_integration_test.go @@ -34,15 +34,15 @@ func createEntUser(t *testing.T, ctx context.Context, client *dbent.Client, emai return u } -func TestEntSoftDelete_ApiKey_DefaultFilterAndSkip(t *testing.T) { +func TestEntSoftDelete_APIKey_DefaultFilterAndSkip(t *testing.T) { ctx := context.Background() // 使用全局 ent client,确保软删除验证在实际持久化数据上进行。 client := testEntClient(t) u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user")+"@example.com") - repo := NewApiKeyRepository(client) - key := &service.ApiKey{ + repo := NewAPIKeyRepository(client) + key := &service.APIKey{ UserID: u.ID, Key: uniqueSoftDeleteValue(t, "sk-soft-delete"), Name: "soft-delete", @@ -53,28 +53,28 @@ func TestEntSoftDelete_ApiKey_DefaultFilterAndSkip(t *testing.T) { require.NoError(t, repo.Delete(ctx, key.ID), "soft delete api key") _, err := repo.GetByID(ctx, key.ID) - require.ErrorIs(t, err, service.ErrApiKeyNotFound, "deleted rows should be hidden by default") + require.ErrorIs(t, err, service.ErrAPIKeyNotFound, "deleted rows should be hidden by default") - _, err = client.ApiKey.Query().Where(apikey.IDEQ(key.ID)).Only(ctx) + _, err = client.APIKey.Query().Where(apikey.IDEQ(key.ID)).Only(ctx) require.Error(t, err, "default ent query should not see soft-deleted rows") require.True(t, dbent.IsNotFound(err), "expected ent not-found after default soft delete filter") - got, err := client.ApiKey.Query(). + got, err := client.APIKey.Query(). Where(apikey.IDEQ(key.ID)). Only(mixins.SkipSoftDelete(ctx)) require.NoError(t, err, "SkipSoftDelete should include soft-deleted rows") require.NotNil(t, got.DeletedAt, "deleted_at should be set after soft delete") } -func TestEntSoftDelete_ApiKey_DeleteIdempotent(t *testing.T) { +func TestEntSoftDelete_APIKey_DeleteIdempotent(t *testing.T) { ctx := context.Background() // 使用全局 ent client,避免事务回滚影响幂等性验证。 client := testEntClient(t) u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user2")+"@example.com") - repo := NewApiKeyRepository(client) - key := &service.ApiKey{ + repo := NewAPIKeyRepository(client) + key := &service.APIKey{ UserID: u.ID, Key: uniqueSoftDeleteValue(t, "sk-soft-delete2"), Name: "soft-delete2", @@ -86,15 +86,15 @@ func TestEntSoftDelete_ApiKey_DeleteIdempotent(t *testing.T) { require.NoError(t, repo.Delete(ctx, key.ID), "second delete should be idempotent") } -func TestEntSoftDelete_ApiKey_HardDeleteViaSkipSoftDelete(t *testing.T) { +func TestEntSoftDelete_APIKey_HardDeleteViaSkipSoftDelete(t *testing.T) { ctx := context.Background() // 使用全局 ent client,确保 SkipSoftDelete 的硬删除语义可验证。 client := testEntClient(t) u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user3")+"@example.com") - repo := NewApiKeyRepository(client) - key := &service.ApiKey{ + repo := NewAPIKeyRepository(client) + key := &service.APIKey{ UserID: u.ID, Key: uniqueSoftDeleteValue(t, "sk-soft-delete3"), Name: "soft-delete3", @@ -105,10 +105,10 @@ func TestEntSoftDelete_ApiKey_HardDeleteViaSkipSoftDelete(t *testing.T) { require.NoError(t, repo.Delete(ctx, key.ID), "soft delete api key") // Hard delete using SkipSoftDelete so the hook doesn't convert it to update-deleted_at. - _, err := client.ApiKey.Delete().Where(apikey.IDEQ(key.ID)).Exec(mixins.SkipSoftDelete(ctx)) + _, err := client.APIKey.Delete().Where(apikey.IDEQ(key.ID)).Exec(mixins.SkipSoftDelete(ctx)) require.NoError(t, err, "hard delete") - _, err = client.ApiKey.Query(). + _, err = client.APIKey.Query(). Where(apikey.IDEQ(key.ID)). Only(mixins.SkipSoftDelete(ctx)) require.True(t, dbent.IsNotFound(err), "expected row to be hard deleted") diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 367ad430..0371ad0d 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -117,7 +117,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) args := []any{ log.UserID, - log.ApiKeyID, + log.APIKeyID, log.AccountID, log.RequestID, log.Model, @@ -183,7 +183,7 @@ func (r *usageLogRepository) ListByUser(ctx context.Context, userID int64, param return r.listUsageLogsWithPagination(ctx, "WHERE user_id = $1", []any{userID}, params) } -func (r *usageLogRepository) ListByApiKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { +func (r *usageLogRepository) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { return r.listUsageLogsWithPagination(ctx, "WHERE api_key_id = $1", []any{apiKeyID}, params) } @@ -270,8 +270,8 @@ func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS r.sql, apiKeyStatsQuery, []any{service.StatusActive}, - &stats.TotalApiKeys, - &stats.ActiveApiKeys, + &stats.TotalAPIKeys, + &stats.ActiveAPIKeys, ); err != nil { return nil, err } @@ -418,8 +418,8 @@ func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID return &stats, nil } -// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation -func (r *usageLogRepository) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { +// GetAPIKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation +func (r *usageLogRepository) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { query := ` SELECT COUNT(*) as total_requests, @@ -623,7 +623,7 @@ func resolveUsageStatsTimezone() string { return "UTC" } -func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { +func (r *usageLogRepository) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC" logs, err := r.queryUsageLogs(ctx, query, apiKeyID, startTime, endTime) return logs, nil, err @@ -709,11 +709,11 @@ type ModelStat = usagestats.ModelStat // UserUsageTrendPoint represents user usage trend data point type UserUsageTrendPoint = usagestats.UserUsageTrendPoint -// ApiKeyUsageTrendPoint represents API key usage trend data point -type ApiKeyUsageTrendPoint = usagestats.ApiKeyUsageTrendPoint +// APIKeyUsageTrendPoint represents API key usage trend data point +type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint -// GetApiKeyUsageTrend returns usage trend data grouped by API key and date -func (r *usageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []ApiKeyUsageTrendPoint, err error) { +// GetAPIKeyUsageTrend returns usage trend data grouped by API key and date +func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []APIKeyUsageTrendPoint, err error) { dateFormat := "YYYY-MM-DD" if granularity == "hour" { dateFormat = "YYYY-MM-DD HH24:00" @@ -755,10 +755,10 @@ func (r *usageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, } }() - results = make([]ApiKeyUsageTrendPoint, 0) + results = make([]APIKeyUsageTrendPoint, 0) for rows.Next() { - var row ApiKeyUsageTrendPoint - if err = rows.Scan(&row.Date, &row.ApiKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil { + var row APIKeyUsageTrendPoint + if err = rows.Scan(&row.Date, &row.APIKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil { return nil, err } results = append(results, row) @@ -844,7 +844,7 @@ func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID i r.sql, "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL", []any{userID}, - &stats.TotalApiKeys, + &stats.TotalAPIKeys, ); err != nil { return nil, err } @@ -853,7 +853,7 @@ func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID i r.sql, "SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND status = $2 AND deleted_at IS NULL", []any{userID, service.StatusActive}, - &stats.ActiveApiKeys, + &stats.ActiveAPIKeys, ); err != nil { return nil, err } @@ -1023,9 +1023,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) args = append(args, filters.UserID) } - if filters.ApiKeyID > 0 { + if filters.APIKeyID > 0 { conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) - args = append(args, filters.ApiKeyID) + args = append(args, filters.APIKeyID) } if filters.AccountID > 0 { conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) @@ -1145,18 +1145,18 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs return result, nil } -// BatchApiKeyUsageStats represents usage stats for a single API key -type BatchApiKeyUsageStats = usagestats.BatchApiKeyUsageStats +// BatchAPIKeyUsageStats represents usage stats for a single API key +type BatchAPIKeyUsageStats = usagestats.BatchAPIKeyUsageStats -// GetBatchApiKeyUsageStats gets today and total actual_cost for multiple API keys -func (r *usageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchApiKeyUsageStats, error) { - result := make(map[int64]*BatchApiKeyUsageStats) +// GetBatchAPIKeyUsageStats gets today and total actual_cost for multiple API keys +func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchAPIKeyUsageStats, error) { + result := make(map[int64]*BatchAPIKeyUsageStats) if len(apiKeyIDs) == 0 { return result, nil } for _, id := range apiKeyIDs { - result[id] = &BatchApiKeyUsageStats{ApiKeyID: id} + result[id] = &BatchAPIKeyUsageStats{APIKeyID: id} } query := ` @@ -1582,7 +1582,7 @@ func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, lo if err != nil { return err } - apiKeys, err := r.loadApiKeys(ctx, ids.apiKeyIDs) + apiKeys, err := r.loadAPIKeys(ctx, ids.apiKeyIDs) if err != nil { return err } @@ -1603,8 +1603,8 @@ func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, lo if user, ok := users[logs[i].UserID]; ok { logs[i].User = user } - if key, ok := apiKeys[logs[i].ApiKeyID]; ok { - logs[i].ApiKey = key + if key, ok := apiKeys[logs[i].APIKeyID]; ok { + logs[i].APIKey = key } if acc, ok := accounts[logs[i].AccountID]; ok { logs[i].Account = acc @@ -1642,7 +1642,7 @@ func collectUsageLogIDs(logs []service.UsageLog) usageLogIDs { for i := range logs { userIDs[logs[i].UserID] = struct{}{} - apiKeyIDs[logs[i].ApiKeyID] = struct{}{} + apiKeyIDs[logs[i].APIKeyID] = struct{}{} accountIDs[logs[i].AccountID] = struct{}{} if logs[i].GroupID != nil { groupIDs[*logs[i].GroupID] = struct{}{} @@ -1676,12 +1676,12 @@ func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[in return out, nil } -func (r *usageLogRepository) loadApiKeys(ctx context.Context, ids []int64) (map[int64]*service.ApiKey, error) { - out := make(map[int64]*service.ApiKey) +func (r *usageLogRepository) loadAPIKeys(ctx context.Context, ids []int64) (map[int64]*service.APIKey, error) { + out := make(map[int64]*service.APIKey) if len(ids) == 0 { return out, nil } - models, err := r.client.ApiKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx) + models, err := r.client.APIKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx) if err != nil { return nil, err } @@ -1800,7 +1800,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e log := &service.UsageLog{ ID: id, UserID: userID, - ApiKeyID: apiKeyID, + APIKeyID: apiKeyID, AccountID: accountID, Model: model, InputTokens: inputTokens, diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index ef03ada7..694b23a4 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -35,10 +35,10 @@ func TestUsageLogRepoSuite(t *testing.T) { suite.Run(t, new(UsageLogRepoSuite)) } -func (s *UsageLogRepoSuite) createUsageLog(user *service.User, apiKey *service.ApiKey, account *service.Account, inputTokens, outputTokens int, cost float64, createdAt time.Time) *service.UsageLog { +func (s *UsageLogRepoSuite) createUsageLog(user *service.User, apiKey *service.APIKey, account *service.Account, inputTokens, outputTokens int, cost float64, createdAt time.Time) *service.UsageLog { log := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3", InputTokens: inputTokens, @@ -55,12 +55,12 @@ func (s *UsageLogRepoSuite) createUsageLog(user *service.User, apiKey *service.A func (s *UsageLogRepoSuite) TestCreate() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "create@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-create", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-create", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-create"}) log := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3", InputTokens: 10, @@ -76,7 +76,7 @@ func (s *UsageLogRepoSuite) TestCreate() { func (s *UsageLogRepoSuite) TestGetByID() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "getbyid@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-getbyid", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-getbyid", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-getbyid"}) log := s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -96,7 +96,7 @@ func (s *UsageLogRepoSuite) TestGetByID_NotFound() { func (s *UsageLogRepoSuite) TestDelete() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "delete@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-delete", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-delete", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-delete"}) log := s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -112,7 +112,7 @@ func (s *UsageLogRepoSuite) TestDelete() { func (s *UsageLogRepoSuite) TestListByUser() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "listbyuser@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-listbyuser", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-listbyuser", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-listbyuser"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -124,18 +124,18 @@ func (s *UsageLogRepoSuite) TestListByUser() { s.Require().Equal(int64(2), page.Total) } -// --- ListByApiKey --- +// --- ListByAPIKey --- -func (s *UsageLogRepoSuite) TestListByApiKey() { +func (s *UsageLogRepoSuite) TestListByAPIKey() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "listbyapikey@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-listbyapikey", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-listbyapikey", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-listbyapikey"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) s.createUsageLog(user, apiKey, account, 15, 25, 0.6, time.Now()) - logs, page, err := s.repo.ListByApiKey(s.ctx, apiKey.ID, pagination.PaginationParams{Page: 1, PageSize: 10}) - s.Require().NoError(err, "ListByApiKey") + logs, page, err := s.repo.ListByAPIKey(s.ctx, apiKey.ID, pagination.PaginationParams{Page: 1, PageSize: 10}) + s.Require().NoError(err, "ListByAPIKey") s.Require().Len(logs, 2) s.Require().Equal(int64(2), page.Total) } @@ -144,7 +144,7 @@ func (s *UsageLogRepoSuite) TestListByApiKey() { func (s *UsageLogRepoSuite) TestListByAccount() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "listbyaccount@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-listbyaccount", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-listbyaccount", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-listbyaccount"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -159,7 +159,7 @@ func (s *UsageLogRepoSuite) TestListByAccount() { func (s *UsageLogRepoSuite) TestGetUserStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "userstats@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-userstats", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-userstats", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-userstats"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -179,7 +179,7 @@ func (s *UsageLogRepoSuite) TestGetUserStats() { func (s *UsageLogRepoSuite) TestListWithFilters() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "filters@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-filters", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-filters", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-filters"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -211,8 +211,8 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { }) group := mustCreateGroup(s.T(), s.client, &service.Group{Name: "g-ul"}) - apiKey1 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: userToday.ID, Key: "sk-ul-1", Name: "ul1"}) - mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: userOld.ID, Key: "sk-ul-2", Name: "ul2", Status: service.StatusDisabled}) + apiKey1 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: userToday.ID, Key: "sk-ul-1", Name: "ul1"}) + mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: userOld.ID, Key: "sk-ul-2", Name: "ul2", Status: service.StatusDisabled}) resetAt := now.Add(10 * time.Minute) accNormal := mustCreateAccount(s.T(), s.client, &service.Account{Name: "a-normal", Schedulable: true}) @@ -223,7 +223,7 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { d1, d2, d3 := 100, 200, 300 logToday := &service.UsageLog{ UserID: userToday.ID, - ApiKeyID: apiKey1.ID, + APIKeyID: apiKey1.ID, AccountID: accNormal.ID, Model: "claude-3", GroupID: &group.ID, @@ -240,7 +240,7 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { logOld := &service.UsageLog{ UserID: userOld.ID, - ApiKeyID: apiKey1.ID, + APIKeyID: apiKey1.ID, AccountID: accNormal.ID, Model: "claude-3", InputTokens: 5, @@ -254,7 +254,7 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { logPerf := &service.UsageLog{ UserID: userToday.ID, - ApiKeyID: apiKey1.ID, + APIKeyID: apiKey1.ID, AccountID: accNormal.ID, Model: "claude-3", InputTokens: 1, @@ -272,8 +272,8 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { s.Require().Equal(baseStats.TotalUsers+2, stats.TotalUsers, "TotalUsers mismatch") s.Require().Equal(baseStats.TodayNewUsers+1, stats.TodayNewUsers, "TodayNewUsers mismatch") s.Require().Equal(baseStats.ActiveUsers+1, stats.ActiveUsers, "ActiveUsers mismatch") - s.Require().Equal(baseStats.TotalApiKeys+2, stats.TotalApiKeys, "TotalApiKeys mismatch") - s.Require().Equal(baseStats.ActiveApiKeys+1, stats.ActiveApiKeys, "ActiveApiKeys mismatch") + s.Require().Equal(baseStats.TotalAPIKeys+2, stats.TotalAPIKeys, "TotalAPIKeys mismatch") + s.Require().Equal(baseStats.ActiveAPIKeys+1, stats.ActiveAPIKeys, "ActiveAPIKeys mismatch") s.Require().Equal(baseStats.TotalAccounts+4, stats.TotalAccounts, "TotalAccounts mismatch") s.Require().Equal(baseStats.ErrorAccounts+1, stats.ErrorAccounts, "ErrorAccounts mismatch") s.Require().Equal(baseStats.RateLimitAccounts+1, stats.RateLimitAccounts, "RateLimitAccounts mismatch") @@ -300,14 +300,14 @@ func (s *UsageLogRepoSuite) TestDashboardStats_TodayTotalsAndPerformance() { func (s *UsageLogRepoSuite) TestGetUserDashboardStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "userdash@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-userdash", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-userdash", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-userdash"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) stats, err := s.repo.GetUserDashboardStats(s.ctx, user.ID) s.Require().NoError(err, "GetUserDashboardStats") - s.Require().Equal(int64(1), stats.TotalApiKeys) + s.Require().Equal(int64(1), stats.TotalAPIKeys) s.Require().Equal(int64(1), stats.TotalRequests) } @@ -315,7 +315,7 @@ func (s *UsageLogRepoSuite) TestGetUserDashboardStats() { func (s *UsageLogRepoSuite) TestGetAccountTodayStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "acctoday@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-acctoday", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-acctoday", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-today"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) @@ -331,8 +331,8 @@ func (s *UsageLogRepoSuite) TestGetAccountTodayStats() { func (s *UsageLogRepoSuite) TestGetBatchUserUsageStats() { user1 := mustCreateUser(s.T(), s.client, &service.User{Email: "batch1@test.com"}) user2 := mustCreateUser(s.T(), s.client, &service.User{Email: "batch2@test.com"}) - apiKey1 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user1.ID, Key: "sk-batch1", Name: "k"}) - apiKey2 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user2.ID, Key: "sk-batch2", Name: "k"}) + apiKey1 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user1.ID, Key: "sk-batch1", Name: "k"}) + apiKey2 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user2.ID, Key: "sk-batch2", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-batch"}) s.createUsageLog(user1, apiKey1, account, 10, 20, 0.5, time.Now()) @@ -351,24 +351,24 @@ func (s *UsageLogRepoSuite) TestGetBatchUserUsageStats_Empty() { s.Require().Empty(stats) } -// --- GetBatchApiKeyUsageStats --- +// --- GetBatchAPIKeyUsageStats --- -func (s *UsageLogRepoSuite) TestGetBatchApiKeyUsageStats() { +func (s *UsageLogRepoSuite) TestGetBatchAPIKeyUsageStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "batchkey@test.com"}) - apiKey1 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-batchkey1", Name: "k1"}) - apiKey2 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-batchkey2", Name: "k2"}) + apiKey1 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-batchkey1", Name: "k1"}) + apiKey2 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-batchkey2", Name: "k2"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-batchkey"}) s.createUsageLog(user, apiKey1, account, 10, 20, 0.5, time.Now()) s.createUsageLog(user, apiKey2, account, 15, 25, 0.6, time.Now()) - stats, err := s.repo.GetBatchApiKeyUsageStats(s.ctx, []int64{apiKey1.ID, apiKey2.ID}) - s.Require().NoError(err, "GetBatchApiKeyUsageStats") + stats, err := s.repo.GetBatchAPIKeyUsageStats(s.ctx, []int64{apiKey1.ID, apiKey2.ID}) + s.Require().NoError(err, "GetBatchAPIKeyUsageStats") s.Require().Len(stats, 2) } -func (s *UsageLogRepoSuite) TestGetBatchApiKeyUsageStats_Empty() { - stats, err := s.repo.GetBatchApiKeyUsageStats(s.ctx, []int64{}) +func (s *UsageLogRepoSuite) TestGetBatchAPIKeyUsageStats_Empty() { + stats, err := s.repo.GetBatchAPIKeyUsageStats(s.ctx, []int64{}) s.Require().NoError(err) s.Require().Empty(stats) } @@ -377,7 +377,7 @@ func (s *UsageLogRepoSuite) TestGetBatchApiKeyUsageStats_Empty() { func (s *UsageLogRepoSuite) TestGetGlobalStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "global@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-global", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-global", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-global"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -402,7 +402,7 @@ func maxTime(a, b time.Time) time.Time { func (s *UsageLogRepoSuite) TestListByUserAndTimeRange() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "timerange@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-timerange", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-timerange", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-timerange"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -417,11 +417,11 @@ func (s *UsageLogRepoSuite) TestListByUserAndTimeRange() { s.Require().Len(logs, 2) } -// --- ListByApiKeyAndTimeRange --- +// --- ListByAPIKeyAndTimeRange --- -func (s *UsageLogRepoSuite) TestListByApiKeyAndTimeRange() { +func (s *UsageLogRepoSuite) TestListByAPIKeyAndTimeRange() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "keytimerange@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-keytimerange", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-keytimerange", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-keytimerange"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -431,8 +431,8 @@ func (s *UsageLogRepoSuite) TestListByApiKeyAndTimeRange() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(2 * time.Hour) - logs, _, err := s.repo.ListByApiKeyAndTimeRange(s.ctx, apiKey.ID, startTime, endTime) - s.Require().NoError(err, "ListByApiKeyAndTimeRange") + logs, _, err := s.repo.ListByAPIKeyAndTimeRange(s.ctx, apiKey.ID, startTime, endTime) + s.Require().NoError(err, "ListByAPIKeyAndTimeRange") s.Require().Len(logs, 2) } @@ -440,7 +440,7 @@ func (s *UsageLogRepoSuite) TestListByApiKeyAndTimeRange() { func (s *UsageLogRepoSuite) TestListByAccountAndTimeRange() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "acctimerange@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-acctimerange", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-acctimerange", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-acctimerange"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -459,7 +459,7 @@ func (s *UsageLogRepoSuite) TestListByAccountAndTimeRange() { func (s *UsageLogRepoSuite) TestListByModelAndTimeRange() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "modeltimerange@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-modeltimerange", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-modeltimerange", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-modeltimerange"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -467,7 +467,7 @@ func (s *UsageLogRepoSuite) TestListByModelAndTimeRange() { // Create logs with different models log1 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-opus", InputTokens: 10, @@ -480,7 +480,7 @@ func (s *UsageLogRepoSuite) TestListByModelAndTimeRange() { log2 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-opus", InputTokens: 15, @@ -493,7 +493,7 @@ func (s *UsageLogRepoSuite) TestListByModelAndTimeRange() { log3 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-sonnet", InputTokens: 20, @@ -515,7 +515,7 @@ func (s *UsageLogRepoSuite) TestListByModelAndTimeRange() { func (s *UsageLogRepoSuite) TestGetAccountWindowStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "windowstats@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-windowstats", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-windowstats", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-windowstats"}) now := time.Now() @@ -535,7 +535,7 @@ func (s *UsageLogRepoSuite) TestGetAccountWindowStats() { func (s *UsageLogRepoSuite) TestGetUserUsageTrendByUserID() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "usertrend@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-usertrend", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-usertrend", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-usertrend"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -552,7 +552,7 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrendByUserID() { func (s *UsageLogRepoSuite) TestGetUserUsageTrendByUserID_HourlyGranularity() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "usertrendhourly@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-usertrendhourly", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-usertrendhourly", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-usertrendhourly"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -571,7 +571,7 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrendByUserID_HourlyGranularity() { func (s *UsageLogRepoSuite) TestGetUserModelStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "modelstats@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-modelstats", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-modelstats", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-modelstats"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -579,7 +579,7 @@ func (s *UsageLogRepoSuite) TestGetUserModelStats() { // Create logs with different models log1 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-opus", InputTokens: 100, @@ -592,7 +592,7 @@ func (s *UsageLogRepoSuite) TestGetUserModelStats() { log2 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-sonnet", InputTokens: 50, @@ -618,7 +618,7 @@ func (s *UsageLogRepoSuite) TestGetUserModelStats() { func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "trendfilters@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-trendfilters", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-trendfilters", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-trendfilters"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -646,7 +646,7 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters() { func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters_HourlyGranularity() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "trendfilters-h@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-trendfilters-h", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-trendfilters-h", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-trendfilters-h"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -665,14 +665,14 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters_HourlyGranularity() { func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "modelfilters@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-modelfilters", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-modelfilters", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-modelfilters"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) log1 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-opus", InputTokens: 100, @@ -685,7 +685,7 @@ func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { log2 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-sonnet", InputTokens: 50, @@ -719,7 +719,7 @@ func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { func (s *UsageLogRepoSuite) TestGetAccountUsageStats() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "accstats@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-accstats", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-accstats", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-accstats"}) base := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) @@ -727,7 +727,7 @@ func (s *UsageLogRepoSuite) TestGetAccountUsageStats() { // Create logs on different days log1 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-opus", InputTokens: 100, @@ -740,7 +740,7 @@ func (s *UsageLogRepoSuite) TestGetAccountUsageStats() { log2 := &service.UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3-sonnet", InputTokens: 50, @@ -782,8 +782,8 @@ func (s *UsageLogRepoSuite) TestGetAccountUsageStats_EmptyRange() { func (s *UsageLogRepoSuite) TestGetUserUsageTrend() { user1 := mustCreateUser(s.T(), s.client, &service.User{Email: "usertrend1@test.com"}) user2 := mustCreateUser(s.T(), s.client, &service.User{Email: "usertrend2@test.com"}) - apiKey1 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user1.ID, Key: "sk-usertrend1", Name: "k1"}) - apiKey2 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user2.ID, Key: "sk-usertrend2", Name: "k2"}) + apiKey1 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user1.ID, Key: "sk-usertrend1", Name: "k1"}) + apiKey2 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user2.ID, Key: "sk-usertrend2", Name: "k2"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-usertrends"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -799,12 +799,12 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrend() { s.Require().GreaterOrEqual(len(trend), 2) } -// --- GetApiKeyUsageTrend --- +// --- GetAPIKeyUsageTrend --- -func (s *UsageLogRepoSuite) TestGetApiKeyUsageTrend() { +func (s *UsageLogRepoSuite) TestGetAPIKeyUsageTrend() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "keytrend@test.com"}) - apiKey1 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-keytrend1", Name: "k1"}) - apiKey2 := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-keytrend2", Name: "k2"}) + apiKey1 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-keytrend1", Name: "k1"}) + apiKey2 := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-keytrend2", Name: "k2"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-keytrends"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -815,14 +815,14 @@ func (s *UsageLogRepoSuite) TestGetApiKeyUsageTrend() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(48 * time.Hour) - trend, err := s.repo.GetApiKeyUsageTrend(s.ctx, startTime, endTime, "day", 10) - s.Require().NoError(err, "GetApiKeyUsageTrend") + trend, err := s.repo.GetAPIKeyUsageTrend(s.ctx, startTime, endTime, "day", 10) + s.Require().NoError(err, "GetAPIKeyUsageTrend") s.Require().GreaterOrEqual(len(trend), 2) } -func (s *UsageLogRepoSuite) TestGetApiKeyUsageTrend_HourlyGranularity() { +func (s *UsageLogRepoSuite) TestGetAPIKeyUsageTrend_HourlyGranularity() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "keytrendh@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-keytrendh", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-keytrendh", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-keytrendh"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -832,21 +832,21 @@ func (s *UsageLogRepoSuite) TestGetApiKeyUsageTrend_HourlyGranularity() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(3 * time.Hour) - trend, err := s.repo.GetApiKeyUsageTrend(s.ctx, startTime, endTime, "hour", 10) - s.Require().NoError(err, "GetApiKeyUsageTrend hourly") + trend, err := s.repo.GetAPIKeyUsageTrend(s.ctx, startTime, endTime, "hour", 10) + s.Require().NoError(err, "GetAPIKeyUsageTrend hourly") s.Require().Len(trend, 2) } // --- ListWithFilters (additional filter tests) --- -func (s *UsageLogRepoSuite) TestListWithFilters_ApiKeyFilter() { +func (s *UsageLogRepoSuite) TestListWithFilters_APIKeyFilter() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "filterskey@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-filterskey", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-filterskey", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-filterskey"}) s.createUsageLog(user, apiKey, account, 10, 20, 0.5, time.Now()) - filters := usagestats.UsageLogFilters{ApiKeyID: apiKey.ID} + filters := usagestats.UsageLogFilters{APIKeyID: apiKey.ID} logs, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, filters) s.Require().NoError(err, "ListWithFilters apiKey") s.Require().Len(logs, 1) @@ -855,7 +855,7 @@ func (s *UsageLogRepoSuite) TestListWithFilters_ApiKeyFilter() { func (s *UsageLogRepoSuite) TestListWithFilters_TimeRange() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "filterstime@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-filterstime", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-filterstime", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-filterstime"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -874,7 +874,7 @@ func (s *UsageLogRepoSuite) TestListWithFilters_TimeRange() { func (s *UsageLogRepoSuite) TestListWithFilters_CombinedFilters() { user := mustCreateUser(s.T(), s.client, &service.User{Email: "filterscombined@test.com"}) - apiKey := mustCreateApiKey(s.T(), s.client, &service.ApiKey{UserID: user.ID, Key: "sk-filterscombined", Name: "k"}) + apiKey := mustCreateAPIKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-filterscombined", Name: "k"}) account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-filterscombined"}) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) @@ -885,7 +885,7 @@ func (s *UsageLogRepoSuite) TestListWithFilters_CombinedFilters() { endTime := base.Add(2 * time.Hour) filters := usagestats.UsageLogFilters{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, StartTime: &startTime, EndTime: &endTime, } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index c1852364..2aeb152c 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -28,12 +28,13 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc // ProviderSet is the Wire provider set for all repositories var ProviderSet = wire.NewSet( NewUserRepository, - NewApiKeyRepository, + NewAPIKeyRepository, NewGroupRepository, NewAccountRepository, NewProxyRepository, NewRedeemCodeRepository, NewUsageLogRepository, + NewOpsRepository, NewSettingRepository, NewUserSubscriptionRepository, NewUserAttributeDefinitionRepository, @@ -42,7 +43,7 @@ var ProviderSet = wire.NewSet( // Cache implementations NewGatewayCache, NewBillingCache, - NewApiKeyCache, + NewAPIKeyCache, ProvideConcurrencyCache, NewEmailCache, NewIdentityCache, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d053e686..f944458e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -91,7 +91,7 @@ func TestAPIContracts(t *testing.T) { name: "GET /api/v1/keys (paginated)", setup: func(t *testing.T, deps *contractDeps) { t.Helper() - deps.apiKeyRepo.MustSeed(&service.ApiKey{ + deps.apiKeyRepo.MustSeed(&service.APIKey{ ID: 100, UserID: 1, Key: "sk_custom_1234567890", @@ -135,7 +135,7 @@ func TestAPIContracts(t *testing.T) { { ID: 1, UserID: 1, - ApiKeyID: 100, + APIKeyID: 100, AccountID: 200, Model: "claude-3", InputTokens: 10, @@ -150,7 +150,7 @@ func TestAPIContracts(t *testing.T) { { ID: 2, UserID: 1, - ApiKeyID: 100, + APIKeyID: 100, AccountID: 200, Model: "claude-3", InputTokens: 5, @@ -188,7 +188,7 @@ func TestAPIContracts(t *testing.T) { { ID: 1, UserID: 1, - ApiKeyID: 100, + APIKeyID: 100, AccountID: 200, RequestID: "req_123", Model: "claude-3", @@ -259,13 +259,13 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyRegistrationEnabled: "true", service.SettingKeyEmailVerifyEnabled: "false", - service.SettingKeySmtpHost: "smtp.example.com", - service.SettingKeySmtpPort: "587", - service.SettingKeySmtpUsername: "user", - service.SettingKeySmtpPassword: "secret", - service.SettingKeySmtpFrom: "no-reply@example.com", - service.SettingKeySmtpFromName: "Sub2API", - service.SettingKeySmtpUseTLS: "true", + service.SettingKeySMTPHost: "smtp.example.com", + service.SettingKeySMTPPort: "587", + service.SettingKeySMTPUsername: "user", + service.SettingKeySMTPPassword: "secret", + service.SettingKeySMTPFrom: "no-reply@example.com", + service.SettingKeySMTPFromName: "Sub2API", + service.SettingKeySMTPUseTLS: "true", service.SettingKeyTurnstileEnabled: "true", service.SettingKeyTurnstileSiteKey: "site-key", @@ -274,9 +274,9 @@ func TestAPIContracts(t *testing.T) { service.SettingKeySiteName: "Sub2API", service.SettingKeySiteLogo: "", service.SettingKeySiteSubtitle: "Subtitle", - service.SettingKeyApiBaseUrl: "https://api.example.com", + service.SettingKeyAPIBaseURL: "https://api.example.com", service.SettingKeyContactInfo: "support", - service.SettingKeyDocUrl: "https://docs.example.com", + service.SettingKeyDocURL: "https://docs.example.com", service.SettingKeyDefaultConcurrency: "5", service.SettingKeyDefaultBalance: "1.25", @@ -331,7 +331,7 @@ func TestAPIContracts(t *testing.T) { type contractDeps struct { now time.Time router http.Handler - apiKeyRepo *stubApiKeyRepo + apiKeyRepo *stubAPIKeyRepo usageRepo *stubUsageLogRepo settingRepo *stubSettingRepo } @@ -359,20 +359,20 @@ func newContractDeps(t *testing.T) *contractDeps { }, } - apiKeyRepo := newStubApiKeyRepo(now) - apiKeyCache := stubApiKeyCache{} + apiKeyRepo := newStubAPIKeyRepo(now) + apiKeyCache := stubAPIKeyCache{} groupRepo := stubGroupRepo{} userSubRepo := stubUserSubscriptionRepo{} cfg := &config.Config{ Default: config.DefaultConfig{ - ApiKeyPrefix: "sk-", + APIKeyPrefix: "sk-", }, RunMode: config.RunModeStandard, } userService := service.NewUserService(userRepo) - apiKeyService := service.NewApiKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, apiKeyCache, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, apiKeyCache, cfg) usageRepo := newStubUsageLogRepo() usageService := service.NewUsageService(usageRepo, userRepo) @@ -525,25 +525,25 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID return 0, errors.New("not implemented") } -type stubApiKeyCache struct{} +type stubAPIKeyCache struct{} -func (stubApiKeyCache) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) { +func (stubAPIKeyCache) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) { return 0, nil } -func (stubApiKeyCache) IncrementCreateAttemptCount(ctx context.Context, userID int64) error { +func (stubAPIKeyCache) IncrementCreateAttemptCount(ctx context.Context, userID int64) error { return nil } -func (stubApiKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { +func (stubAPIKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { return nil } -func (stubApiKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error { +func (stubAPIKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error { return nil } -func (stubApiKeyCache) SetDailyUsageExpiry(ctx context.Context, apiKey string, ttl time.Duration) error { +func (stubAPIKeyCache) SetDailyUsageExpiry(ctx context.Context, apiKey string, ttl time.Duration) error { return nil } @@ -660,24 +660,24 @@ func (stubUserSubscriptionRepo) BatchUpdateExpiredStatus(ctx context.Context) (i return 0, errors.New("not implemented") } -type stubApiKeyRepo struct { +type stubAPIKeyRepo struct { now time.Time nextID int64 - byID map[int64]*service.ApiKey - byKey map[string]*service.ApiKey + byID map[int64]*service.APIKey + byKey map[string]*service.APIKey } -func newStubApiKeyRepo(now time.Time) *stubApiKeyRepo { - return &stubApiKeyRepo{ +func newStubAPIKeyRepo(now time.Time) *stubAPIKeyRepo { + return &stubAPIKeyRepo{ now: now, nextID: 100, - byID: make(map[int64]*service.ApiKey), - byKey: make(map[string]*service.ApiKey), + byID: make(map[int64]*service.APIKey), + byKey: make(map[string]*service.APIKey), } } -func (r *stubApiKeyRepo) MustSeed(key *service.ApiKey) { +func (r *stubAPIKeyRepo) MustSeed(key *service.APIKey) { if key == nil { return } @@ -686,7 +686,7 @@ func (r *stubApiKeyRepo) MustSeed(key *service.ApiKey) { r.byKey[clone.Key] = &clone } -func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error { +func (r *stubAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error { if key == nil { return errors.New("nil key") } @@ -706,38 +706,38 @@ func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error return nil } -func (r *stubApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) { +func (r *stubAPIKeyRepo) GetByID(ctx context.Context, id int64) (*service.APIKey, error) { key, ok := r.byID[id] if !ok { - return nil, service.ErrApiKeyNotFound + return nil, service.ErrAPIKeyNotFound } clone := *key return &clone, nil } -func (r *stubApiKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { +func (r *stubAPIKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { key, ok := r.byID[id] if !ok { - return 0, service.ErrApiKeyNotFound + return 0, service.ErrAPIKeyNotFound } return key.UserID, nil } -func (r *stubApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) { +func (r *stubAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIKey, error) { found, ok := r.byKey[key] if !ok { - return nil, service.ErrApiKeyNotFound + return nil, service.ErrAPIKeyNotFound } clone := *found return &clone, nil } -func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error { +func (r *stubAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error { if key == nil { return errors.New("nil key") } if _, ok := r.byID[key.ID]; !ok { - return service.ErrApiKeyNotFound + return service.ErrAPIKeyNotFound } if key.UpdatedAt.IsZero() { key.UpdatedAt = r.now @@ -748,17 +748,17 @@ func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error return nil } -func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error { +func (r *stubAPIKeyRepo) Delete(ctx context.Context, id int64) error { key, ok := r.byID[id] if !ok { - return service.ErrApiKeyNotFound + return service.ErrAPIKeyNotFound } delete(r.byID, id) delete(r.byKey, key.Key) return nil } -func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *stubAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { ids := make([]int64, 0, len(r.byID)) for id := range r.byID { if r.byID[id].UserID == userID { @@ -776,7 +776,7 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params end = len(ids) } - out := make([]service.ApiKey, 0, end-start) + out := make([]service.APIKey, 0, end-start) for _, id := range ids[start:end] { clone := *r.byID[id] out = append(out, clone) @@ -796,7 +796,7 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params }, nil } -func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { +func (r *stubAPIKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { if len(apiKeyIDs) == 0 { return []int64{}, nil } @@ -815,7 +815,7 @@ func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiK return out, nil } -func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { +func (r *stubAPIKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { var count int64 for _, key := range r.byID { if key.UserID == userID { @@ -825,24 +825,24 @@ func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64 return count, nil } -func (r *stubApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { +func (r *stubAPIKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { _, ok := r.byKey[key] return ok, nil } -func (r *stubApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *stubAPIKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) { +func (r *stubAPIKeyRepo) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) { return nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (r *stubAPIKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } -func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (r *stubAPIKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } @@ -877,7 +877,7 @@ func (r *stubUsageLogRepo) ListByUser(ctx context.Context, userID int64, params return out, paginationResult(total, params), nil } -func (r *stubUsageLogRepo) ListByApiKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { +func (r *stubUsageLogRepo) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } @@ -890,7 +890,7 @@ func (r *stubUsageLogRepo) ListByUserAndTimeRange(ctx context.Context, userID in return logs, paginationResult(int64(len(logs)), pagination.PaginationParams{Page: 1, PageSize: 100}), nil } -func (r *stubUsageLogRepo) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { +func (r *stubUsageLogRepo) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } @@ -922,7 +922,7 @@ func (r *stubUsageLogRepo) GetModelStatsWithFilters(ctx context.Context, startTi return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error) { +func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, errors.New("not implemented") } @@ -975,7 +975,7 @@ func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID in }, nil } -func (r *stubUsageLogRepo) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { +func (r *stubUsageLogRepo) GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { return nil, errors.New("not implemented") } @@ -995,7 +995,7 @@ func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs [ return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) { +func (r *stubUsageLogRepo) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) { return nil, errors.New("not implemented") } @@ -1017,8 +1017,8 @@ func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params paginatio // Apply filters var filtered []service.UsageLog for _, log := range logs { - // Apply ApiKeyID filter - if filters.ApiKeyID > 0 && log.ApiKeyID != filters.ApiKeyID { + // Apply APIKeyID filter + if filters.APIKeyID > 0 && log.APIKeyID != filters.APIKeyID { continue } // Apply Model filter @@ -1151,8 +1151,8 @@ func paginationResult(total int64, params pagination.PaginationParams) *paginati // Ensure compile-time interface compliance. var ( _ service.UserRepository = (*stubUserRepo)(nil) - _ service.ApiKeyRepository = (*stubApiKeyRepo)(nil) - _ service.ApiKeyCache = (*stubApiKeyCache)(nil) + _ service.APIKeyRepository = (*stubAPIKeyRepo)(nil) + _ service.APIKeyCache = (*stubAPIKeyCache)(nil) _ service.GroupRepository = (*stubGroupRepo)(nil) _ service.UserSubscriptionRepository = (*stubUserSubscriptionRepo)(nil) _ service.UsageLogRepository = (*stubUsageLogRepo)(nil) diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index b64220d9..81a993c0 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -1,3 +1,4 @@ +// Package server provides HTTP server setup and routing configuration. package server import ( @@ -25,8 +26,8 @@ func ProvideRouter( handlers *handler.Handlers, jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, - apiKeyAuth middleware2.ApiKeyAuthMiddleware, - apiKeyService *service.ApiKeyService, + apiKeyAuth middleware2.APIKeyAuthMiddleware, + apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, ) *gin.Engine { if cfg.Server.Mode == "release" { diff --git a/backend/internal/server/middleware/admin_auth.go b/backend/internal/server/middleware/admin_auth.go index 4f22d80c..02e339ec 100644 --- a/backend/internal/server/middleware/admin_auth.go +++ b/backend/internal/server/middleware/admin_auth.go @@ -32,7 +32,7 @@ func adminAuth( // 检查 x-api-key header(Admin API Key 认证) apiKey := c.GetHeader("x-api-key") if apiKey != "" { - if !validateAdminApiKey(c, apiKey, settingService, userService) { + if !validateAdminAPIKey(c, apiKey, settingService, userService) { return } c.Next() @@ -52,19 +52,48 @@ func adminAuth( } } + // WebSocket 请求无法设置自定义 header,允许在 query 中携带凭证 + if isWebSocketRequest(c) { + if token := strings.TrimSpace(c.Query("token")); token != "" { + if !validateJWTForAdmin(c, token, authService, userService) { + return + } + c.Next() + return + } + if apiKey := strings.TrimSpace(c.Query("api_key")); apiKey != "" { + if !validateAdminAPIKey(c, apiKey, settingService, userService) { + return + } + c.Next() + return + } + } + // 无有效认证信息 AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") } } -// validateAdminApiKey 验证管理员 API Key -func validateAdminApiKey( +func isWebSocketRequest(c *gin.Context) bool { + if c == nil || c.Request == nil { + return false + } + if strings.EqualFold(c.GetHeader("Upgrade"), "websocket") { + return true + } + conn := strings.ToLower(c.GetHeader("Connection")) + return strings.Contains(conn, "upgrade") && strings.EqualFold(c.GetHeader("Upgrade"), "websocket") +} + +// validateAdminAPIKey 验证管理员 API Key +func validateAdminAPIKey( c *gin.Context, key string, settingService *service.SettingService, userService *service.UserService, ) bool { - storedKey, err := settingService.GetAdminApiKey(c.Request.Context()) + storedKey, err := settingService.GetAdminAPIKey(c.Request.Context()) if err != nil { AbortWithError(c, 500, "INTERNAL_ERROR", "Internal server error") return false diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 75e508dd..c63d712d 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -11,13 +11,13 @@ import ( "github.com/gin-gonic/gin" ) -// NewApiKeyAuthMiddleware 创建 API Key 认证中间件 -func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) ApiKeyAuthMiddleware { - return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg)) +// NewAPIKeyAuthMiddleware 创建 API Key 认证中间件 +func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, opsService *service.OpsService) APIKeyAuthMiddleware { + return APIKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg, opsService)) } // apiKeyAuthWithSubscription API Key认证中间件(支持订阅验证) -func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { +func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, opsService *service.OpsService) gin.HandlerFunc { return func(c *gin.Context) { // 尝试从Authorization header中提取API key (Bearer scheme) authHeader := c.GetHeader("Authorization") @@ -53,6 +53,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti // 如果所有header都没有API key if apiKeyString == "" { + recordOpsAuthError(c, opsService, nil, 401, "API key is required in Authorization header (Bearer scheme), x-api-key header, x-goog-api-key header, or key/api_key query parameter") AbortWithError(c, 401, "API_KEY_REQUIRED", "API key is required in Authorization header (Bearer scheme), x-api-key header, x-goog-api-key header, or key/api_key query parameter") return } @@ -60,35 +61,40 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti // 从数据库验证API key apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) if err != nil { - if errors.Is(err, service.ErrApiKeyNotFound) { + if errors.Is(err, service.ErrAPIKeyNotFound) { + recordOpsAuthError(c, opsService, nil, 401, "Invalid API key") AbortWithError(c, 401, "INVALID_API_KEY", "Invalid API key") return } + recordOpsAuthError(c, opsService, nil, 500, "Failed to validate API key") AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to validate API key") return } // 检查API key是否激活 if !apiKey.IsActive() { + recordOpsAuthError(c, opsService, apiKey, 401, "API key is disabled") AbortWithError(c, 401, "API_KEY_DISABLED", "API key is disabled") return } // 检查关联的用户 if apiKey.User == nil { + recordOpsAuthError(c, opsService, apiKey, 401, "User associated with API key not found") AbortWithError(c, 401, "USER_NOT_FOUND", "User associated with API key not found") return } // 检查用户状态 if !apiKey.User.IsActive() { + recordOpsAuthError(c, opsService, apiKey, 401, "User account is not active") AbortWithError(c, 401, "USER_INACTIVE", "User account is not active") return } if cfg.RunMode == config.RunModeSimple { // 简易模式:跳过余额和订阅检查,但仍需设置必要的上下文 - c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyAPIKey), apiKey) c.Set(string(ContextKeyUser), AuthSubject{ UserID: apiKey.User.ID, Concurrency: apiKey.User.Concurrency, @@ -109,12 +115,14 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti apiKey.Group.ID, ) if err != nil { + recordOpsAuthError(c, opsService, apiKey, 403, "No active subscription found for this group") AbortWithError(c, 403, "SUBSCRIPTION_NOT_FOUND", "No active subscription found for this group") return } // 验证订阅状态(是否过期、暂停等) if err := subscriptionService.ValidateSubscription(c.Request.Context(), subscription); err != nil { + recordOpsAuthError(c, opsService, apiKey, 403, err.Error()) AbortWithError(c, 403, "SUBSCRIPTION_INVALID", err.Error()) return } @@ -131,6 +139,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti // 预检查用量限制(使用0作为额外费用进行预检查) if err := subscriptionService.CheckUsageLimits(c.Request.Context(), subscription, apiKey.Group, 0); err != nil { + recordOpsAuthError(c, opsService, apiKey, 429, err.Error()) AbortWithError(c, 429, "USAGE_LIMIT_EXCEEDED", err.Error()) return } @@ -140,13 +149,14 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti } else { // 余额模式:检查用户余额 if apiKey.User.Balance <= 0 { + recordOpsAuthError(c, opsService, apiKey, 403, "Insufficient account balance") AbortWithError(c, 403, "INSUFFICIENT_BALANCE", "Insufficient account balance") return } } // 将API key和用户信息存入上下文 - c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyAPIKey), apiKey) c.Set(string(ContextKeyUser), AuthSubject{ UserID: apiKey.User.ID, Concurrency: apiKey.User.Concurrency, @@ -157,13 +167,66 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti } } -// GetApiKeyFromContext 从上下文中获取API key -func GetApiKeyFromContext(c *gin.Context) (*service.ApiKey, bool) { - value, exists := c.Get(string(ContextKeyApiKey)) +func recordOpsAuthError(c *gin.Context, opsService *service.OpsService, apiKey *service.APIKey, status int, message string) { + if opsService == nil || c == nil { + return + } + + errType := "authentication_error" + phase := "auth" + severity := "P3" + switch status { + case 403: + errType = "billing_error" + phase = "billing" + case 429: + errType = "rate_limit_error" + phase = "billing" + severity = "P2" + case 500: + errType = "api_error" + phase = "internal" + severity = "P1" + } + + logEntry := &service.OpsErrorLog{ + Phase: phase, + Type: errType, + Severity: severity, + StatusCode: status, + Message: message, + ClientIP: c.ClientIP(), + RequestPath: func() string { + if c.Request != nil && c.Request.URL != nil { + return c.Request.URL.Path + } + return "" + }(), + } + + if apiKey != nil { + logEntry.APIKeyID = &apiKey.ID + if apiKey.User != nil { + logEntry.UserID = &apiKey.User.ID + } + if apiKey.GroupID != nil { + logEntry.GroupID = apiKey.GroupID + } + if apiKey.Group != nil { + logEntry.Platform = apiKey.Group.Platform + } + } + + enqueueOpsAuthErrorLog(opsService, logEntry) +} + +// GetAPIKeyFromContext 从上下文中获取API key +func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) { + value, exists := c.Get(string(ContextKeyAPIKey)) if !exists { return nil, false } - apiKey, ok := value.(*service.ApiKey) + apiKey, ok := value.(*service.APIKey) return apiKey, ok } diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index d8f47bd2..92d8b861 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -11,16 +11,16 @@ import ( "github.com/gin-gonic/gin" ) -// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. -func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService, cfg *config.Config) gin.HandlerFunc { - return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg) +// APIKeyAuthGoogle is a Google-style error wrapper for API key auth. +func APIKeyAuthGoogle(apiKeyService *service.APIKeyService, cfg *config.Config) gin.HandlerFunc { + return APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg) } -// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors: +// APIKeyAuthWithSubscriptionGoogle behaves like APIKeyAuthWithSubscription but returns Google-style errors: // {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}} // // It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations. -func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { +func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { apiKeyString := extractAPIKeyFromRequest(c) if apiKeyString == "" { @@ -30,7 +30,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) if err != nil { - if errors.Is(err, service.ErrApiKeyNotFound) { + if errors.Is(err, service.ErrAPIKeyNotFound) { abortWithGoogleError(c, 401, "Invalid API key") return } @@ -53,7 +53,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs // 简易模式:跳过余额和订阅检查 if cfg.RunMode == config.RunModeSimple { - c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyAPIKey), apiKey) c.Set(string(ContextKeyUser), AuthSubject{ UserID: apiKey.User.ID, Concurrency: apiKey.User.Concurrency, @@ -92,7 +92,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs } } - c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyAPIKey), apiKey) c.Set(string(ContextKeyUser), AuthSubject{ UserID: apiKey.User.ID, Concurrency: apiKey.User.Concurrency, diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 04d67977..b662096a 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -16,53 +16,53 @@ import ( "github.com/stretchr/testify/require" ) -type fakeApiKeyRepo struct { - getByKey func(ctx context.Context, key string) (*service.ApiKey, error) +type fakeAPIKeyRepo struct { + getByKey func(ctx context.Context, key string) (*service.APIKey, error) } -func (f fakeApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error { +func (f fakeAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error { return errors.New("not implemented") } -func (f fakeApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) { +func (f fakeAPIKeyRepo) GetByID(ctx context.Context, id int64) (*service.APIKey, error) { return nil, errors.New("not implemented") } -func (f fakeApiKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { +func (f fakeAPIKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { return 0, errors.New("not implemented") } -func (f fakeApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) { +func (f fakeAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIKey, error) { if f.getByKey == nil { return nil, errors.New("unexpected call") } return f.getByKey(ctx, key) } -func (f fakeApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error { +func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error { return errors.New("not implemented") } -func (f fakeApiKeyRepo) Delete(ctx context.Context, id int64) error { +func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } -func (f fakeApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (f fakeAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (f fakeApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { +func (f fakeAPIKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { return nil, errors.New("not implemented") } -func (f fakeApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { +func (f fakeAPIKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { return 0, errors.New("not implemented") } -func (f fakeApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { +func (f fakeAPIKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { return false, errors.New("not implemented") } -func (f fakeApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (f fakeAPIKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (f fakeApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) { +func (f fakeAPIKeyRepo) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) { return nil, errors.New("not implemented") } -func (f fakeApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (f fakeAPIKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } -func (f fakeApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (f fakeAPIKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } @@ -74,8 +74,8 @@ type googleErrorResponse struct { } `json:"error"` } -func newTestApiKeyService(repo service.ApiKeyRepository) *service.ApiKeyService { - return service.NewApiKeyService( +func newTestAPIKeyService(repo service.APIKeyRepository) *service.APIKeyService { + return service.NewAPIKeyService( repo, nil, // userRepo (unused in GetByKey) nil, // groupRepo @@ -85,16 +85,16 @@ func newTestApiKeyService(repo service.ApiKeyRepository) *service.ApiKeyService ) } -func TestApiKeyAuthWithSubscriptionGoogle_MissingKey(t *testing.T) { +func TestAPIKeyAuthWithSubscriptionGoogle_MissingKey(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - apiKeyService := newTestApiKeyService(fakeApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { + apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { return nil, errors.New("should not be called") }, }) - r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) @@ -109,16 +109,16 @@ func TestApiKeyAuthWithSubscriptionGoogle_MissingKey(t *testing.T) { require.Equal(t, "UNAUTHENTICATED", resp.Error.Status) } -func TestApiKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) { +func TestAPIKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - apiKeyService := newTestApiKeyService(fakeApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { - return nil, service.ErrApiKeyNotFound + apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + return nil, service.ErrAPIKeyNotFound }, }) - r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) @@ -134,16 +134,16 @@ func TestApiKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) { require.Equal(t, "UNAUTHENTICATED", resp.Error.Status) } -func TestApiKeyAuthWithSubscriptionGoogle_RepoError(t *testing.T) { +func TestAPIKeyAuthWithSubscriptionGoogle_RepoError(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - apiKeyService := newTestApiKeyService(fakeApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { + apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { return nil, errors.New("db down") }, }) - r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) @@ -159,13 +159,13 @@ func TestApiKeyAuthWithSubscriptionGoogle_RepoError(t *testing.T) { require.Equal(t, "INTERNAL", resp.Error.Status) } -func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) { +func TestAPIKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - apiKeyService := newTestApiKeyService(fakeApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { - return &service.ApiKey{ + apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + return &service.APIKey{ ID: 1, Key: key, Status: service.StatusDisabled, @@ -176,7 +176,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) { }, nil }, }) - r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) @@ -192,13 +192,13 @@ func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) { require.Equal(t, "UNAUTHENTICATED", resp.Error.Status) } -func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) { +func TestAPIKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - apiKeyService := newTestApiKeyService(fakeApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { - return &service.ApiKey{ + apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + return &service.APIKey{ ID: 1, Key: key, Status: service.StatusActive, @@ -210,7 +210,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) { }, nil }, }) - r.Use(ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{})) r.GET("/v1beta/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 841edd07..bcf596c1 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -35,7 +35,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { Balance: 10, Concurrency: 3, } - apiKey := &service.ApiKey{ + apiKey := &service.APIKey{ ID: 100, UserID: user.ID, Key: "test-key", @@ -45,10 +45,10 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { } apiKey.GroupID = &group.ID - apiKeyRepo := &stubApiKeyRepo{ - getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { + apiKeyRepo := &stubAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { if key != apiKey.Key { - return nil, service.ErrApiKeyNotFound + return nil, service.ErrAPIKeyNotFound } clone := *apiKey return &clone, nil @@ -57,7 +57,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) { cfg := &config.Config{RunMode: config.RunModeSimple} - apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil) router := newAuthTestRouter(apiKeyService, subscriptionService, cfg) @@ -71,7 +71,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { t.Run("standard_mode_enforces_quota_check", func(t *testing.T) { cfg := &config.Config{RunMode: config.RunModeStandard} - apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) now := time.Now() sub := &service.UserSubscription{ @@ -110,75 +110,75 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { }) } -func newAuthTestRouter(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine { +func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine { router := gin.New() - router.Use(gin.HandlerFunc(NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, cfg))) + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg, nil))) router.GET("/t", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) return router } -type stubApiKeyRepo struct { - getByKey func(ctx context.Context, key string) (*service.ApiKey, error) +type stubAPIKeyRepo struct { + getByKey func(ctx context.Context, key string) (*service.APIKey, error) } -func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error { +func (r *stubAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error { return errors.New("not implemented") } -func (r *stubApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) { +func (r *stubAPIKeyRepo) GetByID(ctx context.Context, id int64) (*service.APIKey, error) { return nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { +func (r *stubAPIKeyRepo) GetOwnerID(ctx context.Context, id int64) (int64, error) { return 0, errors.New("not implemented") } -func (r *stubApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) { +func (r *stubAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIKey, error) { if r.getByKey != nil { return r.getByKey(ctx, key) } return nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error { +func (r *stubAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error { return errors.New("not implemented") } -func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error { +func (r *stubAPIKeyRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } -func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *stubAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { +func (r *stubAPIKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { return nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { +func (r *stubAPIKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { return 0, errors.New("not implemented") } -func (r *stubApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { +func (r *stubAPIKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { return false, errors.New("not implemented") } -func (r *stubApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { +func (r *stubAPIKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.APIKey, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) { +func (r *stubAPIKeyRepo) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.APIKey, error) { return nil, errors.New("not implemented") } -func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (r *stubAPIKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } -func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { +func (r *stubAPIKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/logger.go b/backend/internal/server/middleware/logger.go index a9beeb40..092687bf 100644 --- a/backend/internal/server/middleware/logger.go +++ b/backend/internal/server/middleware/logger.go @@ -2,11 +2,14 @@ package middleware import ( "log" + "regexp" "time" "github.com/gin-gonic/gin" ) +var sensitiveQueryParamRE = regexp.MustCompile(`(?i)([?&](?:token|api_key)=)[^&#]*`) + // Logger 请求日志中间件 func Logger() gin.HandlerFunc { return func(c *gin.Context) { @@ -26,7 +29,7 @@ func Logger() gin.HandlerFunc { method := c.Request.Method // 请求路径 - path := c.Request.URL.Path + path := sensitiveQueryParamRE.ReplaceAllString(c.Request.URL.RequestURI(), "${1}***") // 状态码 statusCode := c.Writer.Status() diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go index 75b9f68e..7e4c84d9 100644 --- a/backend/internal/server/middleware/middleware.go +++ b/backend/internal/server/middleware/middleware.go @@ -1,3 +1,5 @@ +// Package middleware provides HTTP middleware components for authentication, +// authorization, logging, error recovery, and request processing. package middleware import ( @@ -15,8 +17,8 @@ const ( ContextKeyUser ContextKey = "user" // ContextKeyUserRole 当前用户角色(string) ContextKeyUserRole ContextKey = "user_role" - // ContextKeyApiKey API密钥上下文键 - ContextKeyApiKey ContextKey = "api_key" + // ContextKeyAPIKey API密钥上下文键 + ContextKeyAPIKey ContextKey = "api_key" // ContextKeySubscription 订阅上下文键 ContextKeySubscription ContextKey = "subscription" // ContextKeyForcePlatform 强制平台(用于 /antigravity 路由) diff --git a/backend/internal/server/middleware/ops_auth_error_logger.go b/backend/internal/server/middleware/ops_auth_error_logger.go new file mode 100644 index 00000000..1c89b807 --- /dev/null +++ b/backend/internal/server/middleware/ops_auth_error_logger.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "context" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const ( + opsAuthErrorLogWorkerCount = 10 + opsAuthErrorLogQueueSize = 256 + opsAuthErrorLogTimeout = 2 * time.Second +) + +type opsAuthErrorLogJob struct { + ops *service.OpsService + entry *service.OpsErrorLog +} + +var ( + opsAuthErrorLogOnce sync.Once + opsAuthErrorLogQueue chan opsAuthErrorLogJob +) + +func startOpsAuthErrorLogWorkers() { + opsAuthErrorLogQueue = make(chan opsAuthErrorLogJob, opsAuthErrorLogQueueSize) + for i := 0; i < opsAuthErrorLogWorkerCount; i++ { + go func() { + for job := range opsAuthErrorLogQueue { + if job.ops == nil || job.entry == nil { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), opsAuthErrorLogTimeout) + _ = job.ops.RecordError(ctx, job.entry) + cancel() + } + }() + } +} + +func enqueueOpsAuthErrorLog(ops *service.OpsService, entry *service.OpsErrorLog) { + if ops == nil || entry == nil { + return + } + + opsAuthErrorLogOnce.Do(startOpsAuthErrorLogWorkers) + + select { + case opsAuthErrorLogQueue <- opsAuthErrorLogJob{ops: ops, entry: entry}: + default: + // Queue is full; drop to avoid blocking request handling. + } +} diff --git a/backend/internal/server/middleware/wire.go b/backend/internal/server/middleware/wire.go index 3ed79f37..dc01b743 100644 --- a/backend/internal/server/middleware/wire.go +++ b/backend/internal/server/middleware/wire.go @@ -11,12 +11,12 @@ type JWTAuthMiddleware gin.HandlerFunc // AdminAuthMiddleware 管理员认证中间件类型 type AdminAuthMiddleware gin.HandlerFunc -// ApiKeyAuthMiddleware API Key 认证中间件类型 -type ApiKeyAuthMiddleware gin.HandlerFunc +// APIKeyAuthMiddleware API Key 认证中间件类型 +type APIKeyAuthMiddleware gin.HandlerFunc // ProviderSet 中间件层的依赖注入 var ProviderSet = wire.NewSet( NewJWTAuthMiddleware, NewAdminAuthMiddleware, - NewApiKeyAuthMiddleware, + NewAPIKeyAuthMiddleware, ) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 2371dafb..6eebb6d8 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -17,8 +17,8 @@ func SetupRouter( handlers *handler.Handlers, jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, - apiKeyAuth middleware2.ApiKeyAuthMiddleware, - apiKeyService *service.ApiKeyService, + apiKeyAuth middleware2.APIKeyAuthMiddleware, + apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, ) *gin.Engine { @@ -43,8 +43,8 @@ func registerRoutes( h *handler.Handlers, jwtAuth middleware2.JWTAuthMiddleware, adminAuth middleware2.AdminAuthMiddleware, - apiKeyAuth middleware2.ApiKeyAuthMiddleware, - apiKeyService *service.ApiKeyService, + apiKeyAuth middleware2.APIKeyAuthMiddleware, + apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, ) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index cc754c29..226fac80 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -19,6 +19,9 @@ func RegisterAdminRoutes( // 仪表盘 registerDashboardRoutes(admin, h) + // 运维监控 + registerOpsRoutes(admin, h) + // 用户管理 registerUserManagementRoutes(admin, h) @@ -67,10 +70,35 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) { dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics) dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend) dashboard.GET("/models", h.Admin.Dashboard.GetModelStats) - dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetApiKeyUsageTrend) + dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend) dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend) dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage) - dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchApiKeysUsage) + dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage) + } +} + +func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + ops := admin.Group("/ops") + { + ops.GET("/metrics", h.Admin.Ops.GetMetrics) + ops.GET("/metrics/history", h.Admin.Ops.ListMetricsHistory) + ops.GET("/errors", h.Admin.Ops.GetErrorLogs) + ops.GET("/error-logs", h.Admin.Ops.ListErrorLogs) + + // Dashboard routes + dashboard := ops.Group("/dashboard") + { + dashboard.GET("/overview", h.Admin.Ops.GetDashboardOverview) + dashboard.GET("/providers", h.Admin.Ops.GetProviderHealth) + dashboard.GET("/latency-histogram", h.Admin.Ops.GetLatencyHistogram) + dashboard.GET("/errors/distribution", h.Admin.Ops.GetErrorDistribution) + } + + // WebSocket routes + ws := ops.Group("/ws") + { + ws.GET("/qps", h.Admin.Ops.QPSWSHandler) + } } } @@ -203,12 +231,12 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { adminSettings.GET("", h.Admin.Setting.GetSettings) adminSettings.PUT("", h.Admin.Setting.UpdateSettings) - adminSettings.POST("/test-smtp", h.Admin.Setting.TestSmtpConnection) + adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) // Admin API Key 管理 - adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminApiKey) - adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminApiKey) - adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminApiKey) + adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) + adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey) + adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey) } } @@ -248,7 +276,7 @@ func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) { usage.GET("", h.Admin.Usage.List) usage.GET("/stats", h.Admin.Usage.Stats) usage.GET("/search-users", h.Admin.Usage.SearchUsers) - usage.GET("/search-api-keys", h.Admin.Usage.SearchApiKeys) + usage.GET("/search-api-keys", h.Admin.Usage.SearchAPIKeys) } } diff --git a/backend/internal/server/routes/common.go b/backend/internal/server/routes/common.go index 4989358d..7d3cfc4e 100644 --- a/backend/internal/server/routes/common.go +++ b/backend/internal/server/routes/common.go @@ -1,3 +1,4 @@ +// Package routes 提供 HTTP 路由注册和处理函数 package routes import ( diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 38df9225..d9e0bb81 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -13,8 +13,8 @@ import ( func RegisterGatewayRoutes( r *gin.Engine, h *handler.Handlers, - apiKeyAuth middleware.ApiKeyAuthMiddleware, - apiKeyService *service.ApiKeyService, + apiKeyAuth middleware.APIKeyAuthMiddleware, + apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, ) { @@ -36,7 +36,7 @@ func RegisterGatewayRoutes( // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) gemini := r.Group("/v1beta") gemini.Use(bodyLimit) - gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) + gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) { gemini.GET("/models", h.Gateway.GeminiV1BetaListModels) gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) @@ -62,7 +62,7 @@ func RegisterGatewayRoutes( antigravityV1Beta := r.Group("/antigravity/v1beta") antigravityV1Beta.Use(bodyLimit) antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity)) - antigravityV1Beta.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) + antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) { antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels) antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 31a354fa..ad2166fe 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -50,7 +50,7 @@ func RegisterUserRoutes( usage.GET("/dashboard/stats", h.Usage.DashboardStats) usage.GET("/dashboard/trend", h.Usage.DashboardTrend) usage.GET("/dashboard/models", h.Usage.DashboardModels) - usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardApiKeysUsage) + usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage) } // 卡密兑换 diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index dcc6c3c5..0acf1aad 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -206,7 +206,7 @@ func (a *Account) GetMappedModel(requestedModel string) string { } func (a *Account) GetBaseURL() string { - if a.Type != AccountTypeApiKey { + if a.Type != AccountTypeAPIKey { return "" } baseURL := a.GetCredential("base_url") @@ -229,7 +229,7 @@ func (a *Account) GetExtraString(key string) string { } func (a *Account) IsCustomErrorCodesEnabled() bool { - if a.Type != AccountTypeApiKey || a.Credentials == nil { + if a.Type != AccountTypeAPIKey || a.Credentials == nil { return false } if v, ok := a.Credentials["custom_error_codes_enabled"]; ok { @@ -300,15 +300,15 @@ func (a *Account) IsOpenAIOAuth() bool { return a.IsOpenAI() && a.Type == AccountTypeOAuth } -func (a *Account) IsOpenAIApiKey() bool { - return a.IsOpenAI() && a.Type == AccountTypeApiKey +func (a *Account) IsOpenAIAPIKey() bool { + return a.IsOpenAI() && a.Type == AccountTypeAPIKey } func (a *Account) GetOpenAIBaseURL() string { if !a.IsOpenAI() { return "" } - if a.Type == AccountTypeApiKey { + if a.Type == AccountTypeAPIKey { baseURL := a.GetCredential("base_url") if baseURL != "" { return baseURL @@ -338,8 +338,8 @@ func (a *Account) GetOpenAIIDToken() string { return a.GetCredential("id_token") } -func (a *Account) GetOpenAIApiKey() string { - if !a.IsOpenAIApiKey() { +func (a *Account) GetOpenAIAPIKey() string { + if !a.IsOpenAIAPIKey() { return "" } return a.GetCredential("api_key") diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 6a107155..3dd165ac 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -1,3 +1,5 @@ +// Package service 提供业务逻辑层服务,封装领域模型的业务规则和操作流程。 +// 服务层协调 repository 层的数据访问,实现跨实体的业务逻辑,并为上层 API 提供统一的业务接口。 package service import ( diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 7dd451cd..748c7993 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -324,7 +324,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account chatgptAccountID = account.GetChatGPTAccountID() } else if account.Type == "apikey" { // API Key - use Platform API - authToken = account.GetOpenAIApiKey() + authToken = account.GetOpenAIAPIKey() if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } @@ -402,7 +402,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account } // For API Key accounts with model mapping, map the model - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { mapping := account.GetModelMapping() if len(mapping) > 0 { if mappedModel, exists := mapping[testModelID]; exists { @@ -426,7 +426,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account var err error switch account.Type { - case AccountTypeApiKey: + case AccountTypeAPIKey: req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload) case AccountTypeOAuth: req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index dfceac07..0fc5c45e 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -17,11 +17,11 @@ type UsageLogRepository interface { Delete(ctx context.Context, id int64) error ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) - ListByApiKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) + ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error) - ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error) + ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error) ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error) ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error) @@ -32,10 +32,10 @@ type UsageLogRepository interface { GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]usagestats.ModelStat, error) - GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error) + GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) - GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) + GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) // User dashboard stats GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error) @@ -51,7 +51,7 @@ type UsageLogRepository interface { // Aggregated stats (optimized) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) - GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) + GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetAccountStatsAggregated(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetModelStatsAggregated(ctx context.Context, modelName string, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 962b3684..f59554ac 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -19,7 +19,7 @@ type AdminService interface { UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) - GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]ApiKey, int64, error) + GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) // Group management @@ -30,7 +30,7 @@ type AdminService interface { CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) DeleteGroup(ctx context.Context, id int64) error - GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]ApiKey, int64, error) + GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) // Account management ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) @@ -65,7 +65,7 @@ type AdminService interface { ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) } -// Input types for admin operations +// CreateUserInput represents the input for creating a new user type CreateUserInput struct { Email string Password string @@ -220,7 +220,7 @@ type adminServiceImpl struct { groupRepo GroupRepository accountRepo AccountRepository proxyRepo ProxyRepository - apiKeyRepo ApiKeyRepository + apiKeyRepo APIKeyRepository redeemCodeRepo RedeemCodeRepository billingCacheService *BillingCacheService proxyProber ProxyExitInfoProber @@ -232,7 +232,7 @@ func NewAdminService( groupRepo GroupRepository, accountRepo AccountRepository, proxyRepo ProxyRepository, - apiKeyRepo ApiKeyRepository, + apiKeyRepo APIKeyRepository, redeemCodeRepo RedeemCodeRepository, billingCacheService *BillingCacheService, proxyProber ProxyExitInfoProber, @@ -430,7 +430,7 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, return user, nil } -func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]ApiKey, int64, error) { +func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params) if err != nil { @@ -583,7 +583,7 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { return nil } -func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]ApiKey, int64, error) { +func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params) if err != nil { diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go index e76f0f8e..0cf0f4f9 100644 --- a/backend/internal/service/api_key.go +++ b/backend/internal/service/api_key.go @@ -2,7 +2,7 @@ package service import "time" -type ApiKey struct { +type APIKey struct { ID int64 UserID int64 Key string @@ -15,6 +15,6 @@ type ApiKey struct { Group *Group } -func (k *ApiKey) IsActive() bool { +func (k *APIKey) IsActive() bool { return k.Status == StatusActive } diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index f22c383a..ea53f81a 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -14,39 +14,39 @@ import ( ) var ( - ErrApiKeyNotFound = infraerrors.NotFound("API_KEY_NOT_FOUND", "api key not found") + ErrAPIKeyNotFound = infraerrors.NotFound("API_KEY_NOT_FOUND", "api key not found") ErrGroupNotAllowed = infraerrors.Forbidden("GROUP_NOT_ALLOWED", "user is not allowed to bind this group") - ErrApiKeyExists = infraerrors.Conflict("API_KEY_EXISTS", "api key already exists") - ErrApiKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters") - ErrApiKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens") - ErrApiKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later") + ErrAPIKeyExists = infraerrors.Conflict("API_KEY_EXISTS", "api key already exists") + ErrAPIKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters") + ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens") + ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later") ) const ( apiKeyMaxErrorsPerHour = 20 ) -type ApiKeyRepository interface { - Create(ctx context.Context, key *ApiKey) error - GetByID(ctx context.Context, id int64) (*ApiKey, error) +type APIKeyRepository interface { + Create(ctx context.Context, key *APIKey) error + GetByID(ctx context.Context, id int64) (*APIKey, error) // GetOwnerID 仅获取 API Key 的所有者 ID,用于删除前的轻量级权限验证 GetOwnerID(ctx context.Context, id int64) (int64, error) - GetByKey(ctx context.Context, key string) (*ApiKey, error) - Update(ctx context.Context, key *ApiKey) error + GetByKey(ctx context.Context, key string) (*APIKey, error) + Update(ctx context.Context, key *APIKey) error Delete(ctx context.Context, id int64) error - ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) + ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) CountByUserID(ctx context.Context, userID int64) (int64, error) ExistsByKey(ctx context.Context, key string) (bool, error) - ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) - SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]ApiKey, error) + ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) + SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) CountByGroupID(ctx context.Context, groupID int64) (int64, error) } -// ApiKeyCache defines cache operations for API key service -type ApiKeyCache interface { +// APIKeyCache defines cache operations for API key service +type APIKeyCache interface { GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) IncrementCreateAttemptCount(ctx context.Context, userID int64) error DeleteCreateAttemptCount(ctx context.Context, userID int64) error @@ -55,40 +55,40 @@ type ApiKeyCache interface { SetDailyUsageExpiry(ctx context.Context, apiKey string, ttl time.Duration) error } -// CreateApiKeyRequest 创建API Key请求 -type CreateApiKeyRequest struct { +// CreateAPIKeyRequest 创建API Key请求 +type CreateAPIKeyRequest struct { Name string `json:"name"` GroupID *int64 `json:"group_id"` CustomKey *string `json:"custom_key"` // 可选的自定义key } -// UpdateApiKeyRequest 更新API Key请求 -type UpdateApiKeyRequest struct { +// UpdateAPIKeyRequest 更新API Key请求 +type UpdateAPIKeyRequest struct { Name *string `json:"name"` GroupID *int64 `json:"group_id"` Status *string `json:"status"` } -// ApiKeyService API Key服务 -type ApiKeyService struct { - apiKeyRepo ApiKeyRepository +// APIKeyService API Key服务 +type APIKeyService struct { + apiKeyRepo APIKeyRepository userRepo UserRepository groupRepo GroupRepository userSubRepo UserSubscriptionRepository - cache ApiKeyCache + cache APIKeyCache cfg *config.Config } -// NewApiKeyService 创建API Key服务实例 -func NewApiKeyService( - apiKeyRepo ApiKeyRepository, +// NewAPIKeyService 创建API Key服务实例 +func NewAPIKeyService( + apiKeyRepo APIKeyRepository, userRepo UserRepository, groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, - cache ApiKeyCache, + cache APIKeyCache, cfg *config.Config, -) *ApiKeyService { - return &ApiKeyService{ +) *APIKeyService { + return &APIKeyService{ apiKeyRepo: apiKeyRepo, userRepo: userRepo, groupRepo: groupRepo, @@ -99,7 +99,7 @@ func NewApiKeyService( } // GenerateKey 生成随机API Key -func (s *ApiKeyService) GenerateKey() (string, error) { +func (s *APIKeyService) GenerateKey() (string, error) { // 生成32字节随机数据 bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { @@ -107,7 +107,7 @@ func (s *ApiKeyService) GenerateKey() (string, error) { } // 转换为十六进制字符串并添加前缀 - prefix := s.cfg.Default.ApiKeyPrefix + prefix := s.cfg.Default.APIKeyPrefix if prefix == "" { prefix = "sk-" } @@ -117,10 +117,10 @@ func (s *ApiKeyService) GenerateKey() (string, error) { } // ValidateCustomKey 验证自定义API Key格式 -func (s *ApiKeyService) ValidateCustomKey(key string) error { +func (s *APIKeyService) ValidateCustomKey(key string) error { // 检查长度 if len(key) < 16 { - return ErrApiKeyTooShort + return ErrAPIKeyTooShort } // 检查字符:只允许字母、数字、下划线、连字符 @@ -131,14 +131,14 @@ func (s *ApiKeyService) ValidateCustomKey(key string) error { c == '_' || c == '-' { continue } - return ErrApiKeyInvalidChars + return ErrAPIKeyInvalidChars } return nil } -// checkApiKeyRateLimit 检查用户创建自定义Key的错误次数是否超限 -func (s *ApiKeyService) checkApiKeyRateLimit(ctx context.Context, userID int64) error { +// checkAPIKeyRateLimit 检查用户创建自定义Key的错误次数是否超限 +func (s *APIKeyService) checkAPIKeyRateLimit(ctx context.Context, userID int64) error { if s.cache == nil { return nil } @@ -150,14 +150,14 @@ func (s *ApiKeyService) checkApiKeyRateLimit(ctx context.Context, userID int64) } if count >= apiKeyMaxErrorsPerHour { - return ErrApiKeyRateLimited + return ErrAPIKeyRateLimited } return nil } -// incrementApiKeyErrorCount 增加用户创建自定义Key的错误计数 -func (s *ApiKeyService) incrementApiKeyErrorCount(ctx context.Context, userID int64) { +// incrementAPIKeyErrorCount 增加用户创建自定义Key的错误计数 +func (s *APIKeyService) incrementAPIKeyErrorCount(ctx context.Context, userID int64) { if s.cache == nil { return } @@ -168,7 +168,7 @@ func (s *ApiKeyService) incrementApiKeyErrorCount(ctx context.Context, userID in // canUserBindGroup 检查用户是否可以绑定指定分组 // 对于订阅类型分组:检查用户是否有有效订阅 // 对于标准类型分组:使用原有的 AllowedGroups 和 IsExclusive 逻辑 -func (s *ApiKeyService) canUserBindGroup(ctx context.Context, user *User, group *Group) bool { +func (s *APIKeyService) canUserBindGroup(ctx context.Context, user *User, group *Group) bool { // 订阅类型分组:需要有效订阅 if group.IsSubscriptionType() { _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, user.ID, group.ID) @@ -179,7 +179,7 @@ func (s *ApiKeyService) canUserBindGroup(ctx context.Context, user *User, group } // Create 创建API Key -func (s *ApiKeyService) Create(ctx context.Context, userID int64, req CreateApiKeyRequest) (*ApiKey, error) { +func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIKeyRequest) (*APIKey, error) { // 验证用户存在 user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -204,7 +204,7 @@ func (s *ApiKeyService) Create(ctx context.Context, userID int64, req CreateApiK // 判断是否使用自定义Key if req.CustomKey != nil && *req.CustomKey != "" { // 检查限流(仅对自定义key进行限流) - if err := s.checkApiKeyRateLimit(ctx, userID); err != nil { + if err := s.checkAPIKeyRateLimit(ctx, userID); err != nil { return nil, err } @@ -219,9 +219,9 @@ func (s *ApiKeyService) Create(ctx context.Context, userID int64, req CreateApiK return nil, fmt.Errorf("check key exists: %w", err) } if exists { - // Key已存在,增加错误计数 - s.incrementApiKeyErrorCount(ctx, userID) - return nil, ErrApiKeyExists + // Key已存在,增加错误计数 + s.incrementAPIKeyErrorCount(ctx, userID) + return nil, ErrAPIKeyExists } key = *req.CustomKey @@ -235,7 +235,7 @@ func (s *ApiKeyService) Create(ctx context.Context, userID int64, req CreateApiK } // 创建API Key记录 - apiKey := &ApiKey{ + apiKey := &APIKey{ UserID: userID, Key: key, Name: req.Name, @@ -251,7 +251,7 @@ func (s *ApiKeyService) Create(ctx context.Context, userID int64, req CreateApiK } // List 获取用户的API Key列表 -func (s *ApiKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) { +func (s *APIKeyService) List(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) { keys, pagination, err := s.apiKeyRepo.ListByUserID(ctx, userID, params) if err != nil { return nil, nil, fmt.Errorf("list api keys: %w", err) @@ -259,7 +259,7 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio return keys, pagination, nil } -func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { +func (s *APIKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { if len(apiKeyIDs) == 0 { return []int64{}, nil } @@ -272,7 +272,7 @@ func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKe } // GetByID 根据ID获取API Key -func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) { +func (s *APIKeyService) GetByID(ctx context.Context, id int64) (*APIKey, error) { apiKey, err := s.apiKeyRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get api key: %w", err) @@ -281,7 +281,7 @@ func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) } // GetByKey 根据Key字符串获取API Key(用于认证) -func (s *ApiKeyService) GetByKey(ctx context.Context, key string) (*ApiKey, error) { +func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, error) { // 尝试从Redis缓存获取 cacheKey := fmt.Sprintf("apikey:%s", key) @@ -301,7 +301,7 @@ func (s *ApiKeyService) GetByKey(ctx context.Context, key string) (*ApiKey, erro } // Update 更新API Key -func (s *ApiKeyService) Update(ctx context.Context, id int64, userID int64, req UpdateApiKeyRequest) (*ApiKey, error) { +func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req UpdateAPIKeyRequest) (*APIKey, error) { apiKey, err := s.apiKeyRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get api key: %w", err) @@ -353,8 +353,8 @@ func (s *ApiKeyService) Update(ctx context.Context, id int64, userID int64, req // Delete 删除API Key // 优化:使用 GetOwnerID 替代 GetByID 进行权限验证, -// 避免加载完整 ApiKey 对象及其关联数据(User、Group),提升删除操作的性能 -func (s *ApiKeyService) Delete(ctx context.Context, id int64, userID int64) error { +// 避免加载完整 APIKey 对象及其关联数据(User、Group),提升删除操作的性能 +func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) error { // 仅获取所有者 ID 用于权限验证,而非加载完整对象 ownerID, err := s.apiKeyRepo.GetOwnerID(ctx, id) if err != nil { @@ -379,7 +379,7 @@ func (s *ApiKeyService) Delete(ctx context.Context, id int64, userID int64) erro } // ValidateKey 验证API Key是否有效(用于认证中间件) -func (s *ApiKeyService) ValidateKey(ctx context.Context, key string) (*ApiKey, *User, error) { +func (s *APIKeyService) ValidateKey(ctx context.Context, key string) (*APIKey, *User, error) { // 获取API Key apiKey, err := s.GetByKey(ctx, key) if err != nil { @@ -406,7 +406,7 @@ func (s *ApiKeyService) ValidateKey(ctx context.Context, key string) (*ApiKey, * } // IncrementUsage 增加API Key使用次数(可选:用于统计) -func (s *ApiKeyService) IncrementUsage(ctx context.Context, keyID int64) error { +func (s *APIKeyService) IncrementUsage(ctx context.Context, keyID int64) error { // 使用Redis计数器 if s.cache != nil { cacheKey := fmt.Sprintf("apikey:usage:%d:%s", keyID, timezone.Now().Format("2006-01-02")) @@ -423,7 +423,7 @@ func (s *ApiKeyService) IncrementUsage(ctx context.Context, keyID int64) error { // 返回用户可以选择的分组: // - 标准类型分组:公开的(非专属)或用户被明确允许的 // - 订阅类型分组:用户有有效订阅的 -func (s *ApiKeyService) GetAvailableGroups(ctx context.Context, userID int64) ([]Group, error) { +func (s *APIKeyService) GetAvailableGroups(ctx context.Context, userID int64) ([]Group, error) { // 获取用户信息 user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -460,7 +460,7 @@ func (s *ApiKeyService) GetAvailableGroups(ctx context.Context, userID int64) ([ } // canUserBindGroupInternal 内部方法,检查用户是否可以绑定分组(使用预加载的订阅数据) -func (s *ApiKeyService) canUserBindGroupInternal(user *User, group *Group, subscribedGroupIDs map[int64]bool) bool { +func (s *APIKeyService) canUserBindGroupInternal(user *User, group *Group, subscribedGroupIDs map[int64]bool) bool { // 订阅类型分组:需要有效订阅 if group.IsSubscriptionType() { return subscribedGroupIDs[group.ID] @@ -469,8 +469,8 @@ func (s *ApiKeyService) canUserBindGroupInternal(user *User, group *Group, subsc return user.CanBindGroup(group.ID, group.IsExclusive) } -func (s *ApiKeyService) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]ApiKey, error) { - keys, err := s.apiKeyRepo.SearchApiKeys(ctx, userID, keyword, limit) +func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) { + keys, err := s.apiKeyRepo.SearchAPIKeys(ctx, userID, keyword, limit) if err != nil { return nil, fmt.Errorf("search api keys: %w", err) } diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index deac8499..1b1bfb6f 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -1,7 +1,7 @@ //go:build unit // API Key 服务删除方法的单元测试 -// 测试 ApiKeyService.Delete 方法在各种场景下的行为, +// 测试 APIKeyService.Delete 方法在各种场景下的行为, // 包括权限验证、缓存清理和错误处理 package service @@ -16,12 +16,12 @@ import ( "github.com/stretchr/testify/require" ) -// apiKeyRepoStub 是 ApiKeyRepository 接口的测试桩实现。 -// 用于隔离测试 ApiKeyService.Delete 方法,避免依赖真实数据库。 +// apiKeyRepoStub 是 APIKeyRepository 接口的测试桩实现。 +// 用于隔离测试 APIKeyService.Delete 方法,避免依赖真实数据库。 // // 设计说明: // - ownerID: 模拟 GetOwnerID 返回的所有者 ID -// - ownerErr: 模拟 GetOwnerID 返回的错误(如 ErrApiKeyNotFound) +// - ownerErr: 模拟 GetOwnerID 返回的错误(如 ErrAPIKeyNotFound) // - deleteErr: 模拟 Delete 返回的错误 // - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证 type apiKeyRepoStub struct { @@ -33,11 +33,11 @@ type apiKeyRepoStub struct { // 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题 -func (s *apiKeyRepoStub) Create(ctx context.Context, key *ApiKey) error { +func (s *apiKeyRepoStub) Create(ctx context.Context, key *APIKey) error { panic("unexpected Create call") } -func (s *apiKeyRepoStub) GetByID(ctx context.Context, id int64) (*ApiKey, error) { +func (s *apiKeyRepoStub) GetByID(ctx context.Context, id int64) (*APIKey, error) { panic("unexpected GetByID call") } @@ -47,11 +47,11 @@ func (s *apiKeyRepoStub) GetOwnerID(ctx context.Context, id int64) (int64, error return s.ownerID, s.ownerErr } -func (s *apiKeyRepoStub) GetByKey(ctx context.Context, key string) (*ApiKey, error) { +func (s *apiKeyRepoStub) GetByKey(ctx context.Context, key string) (*APIKey, error) { panic("unexpected GetByKey call") } -func (s *apiKeyRepoStub) Update(ctx context.Context, key *ApiKey) error { +func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey) error { panic("unexpected Update call") } @@ -64,7 +64,7 @@ func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error { // 以下是接口要求实现但本测试不关心的方法 -func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) { +func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected ListByUserID call") } @@ -80,12 +80,12 @@ func (s *apiKeyRepoStub) ExistsByKey(ctx context.Context, key string) (bool, err panic("unexpected ExistsByKey call") } -func (s *apiKeyRepoStub) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) { +func (s *apiKeyRepoStub) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]APIKey, *pagination.PaginationResult, error) { panic("unexpected ListByGroupID call") } -func (s *apiKeyRepoStub) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]ApiKey, error) { - panic("unexpected SearchApiKeys call") +func (s *apiKeyRepoStub) SearchAPIKeys(ctx context.Context, userID int64, keyword string, limit int) ([]APIKey, error) { + panic("unexpected SearchAPIKeys call") } func (s *apiKeyRepoStub) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { @@ -96,7 +96,7 @@ func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int panic("unexpected CountByGroupID call") } -// apiKeyCacheStub 是 ApiKeyCache 接口的测试桩实现。 +// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。 // 用于验证删除操作时缓存清理逻辑是否被正确调用。 // // 设计说明: @@ -132,17 +132,17 @@ func (s *apiKeyCacheStub) SetDailyUsageExpiry(ctx context.Context, apiKey string return nil } -// TestApiKeyService_Delete_OwnerMismatch 测试非所有者尝试删除时返回权限错误。 +// TestAPIKeyService_Delete_OwnerMismatch 测试非所有者尝试删除时返回权限错误。 // 预期行为: // - GetOwnerID 返回所有者 ID 为 1 // - 调用者 userID 为 2(不匹配) // - 返回 ErrInsufficientPerms 错误 // - Delete 方法不被调用 // - 缓存不被清除 -func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { +func TestAPIKeyService_Delete_OwnerMismatch(t *testing.T) { repo := &apiKeyRepoStub{ownerID: 1} cache := &apiKeyCacheStub{} - svc := &ApiKeyService{apiKeyRepo: repo, cache: cache} + svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2 require.ErrorIs(t, err, ErrInsufficientPerms) @@ -150,17 +150,17 @@ func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { require.Empty(t, cache.invalidated) // 验证缓存未被清除 } -// TestApiKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。 +// TestAPIKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。 // 预期行为: // - GetOwnerID 返回所有者 ID 为 7 // - 调用者 userID 为 7(匹配) // - Delete 成功执行 // - 缓存被正确清除(使用 ownerID) // - 返回 nil 错误 -func TestApiKeyService_Delete_Success(t *testing.T) { +func TestAPIKeyService_Delete_Success(t *testing.T) { repo := &apiKeyRepoStub{ownerID: 7} cache := &apiKeyCacheStub{} - svc := &ApiKeyService{apiKeyRepo: repo, cache: cache} + svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7 require.NoError(t, err) @@ -168,37 +168,37 @@ func TestApiKeyService_Delete_Success(t *testing.T) { require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除 } -// TestApiKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。 +// TestAPIKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。 // 预期行为: -// - GetOwnerID 返回 ErrApiKeyNotFound 错误 -// - 返回 ErrApiKeyNotFound 错误(被 fmt.Errorf 包装) +// - GetOwnerID 返回 ErrAPIKeyNotFound 错误 +// - 返回 ErrAPIKeyNotFound 错误(被 fmt.Errorf 包装) // - Delete 方法不被调用 // - 缓存不被清除 -func TestApiKeyService_Delete_NotFound(t *testing.T) { - repo := &apiKeyRepoStub{ownerErr: ErrApiKeyNotFound} +func TestAPIKeyService_Delete_NotFound(t *testing.T) { + repo := &apiKeyRepoStub{ownerErr: ErrAPIKeyNotFound} cache := &apiKeyCacheStub{} - svc := &ApiKeyService{apiKeyRepo: repo, cache: cache} + svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 99, 1) - require.ErrorIs(t, err, ErrApiKeyNotFound) + require.ErrorIs(t, err, ErrAPIKeyNotFound) require.Empty(t, repo.deletedIDs) require.Empty(t, cache.invalidated) } -// TestApiKeyService_Delete_DeleteFails 测试删除操作失败时的错误处理。 +// TestAPIKeyService_Delete_DeleteFails 测试删除操作失败时的错误处理。 // 预期行为: // - GetOwnerID 返回正确的所有者 ID // - 所有权验证通过 // - 缓存被清除(在删除之前) // - Delete 被调用但返回错误 // - 返回包含 "delete api key" 的错误信息 -func TestApiKeyService_Delete_DeleteFails(t *testing.T) { +func TestAPIKeyService_Delete_DeleteFails(t *testing.T) { repo := &apiKeyRepoStub{ ownerID: 3, deleteErr: errors.New("delete failed"), } cache := &apiKeyCacheStub{} - svc := &ApiKeyService{apiKeyRepo: repo, cache: cache} + svc := &APIKeyService{apiKeyRepo: repo, cache: cache} err := svc.Delete(context.Background(), 3, 3) // API Key ID=3, 调用者 userID=3 require.Error(t, err) diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index 9cdeed7b..86148b37 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -445,7 +445,7 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID // CheckBillingEligibility 检查用户是否有资格发起请求 // 余额模式:检查缓存余额 > 0 // 订阅模式:检查缓存用量未超过限额(Group限额从参数传入) -func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error { +func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *APIKey, group *Group, subscription *UserSubscription) error { // 简易模式:跳过所有计费检查 if s.cfg.RunMode == config.RunModeSimple { return nil diff --git a/backend/internal/service/concurrency_service.go b/backend/internal/service/concurrency_service.go index 65ef16db..8b0ad94c 100644 --- a/backend/internal/service/concurrency_service.go +++ b/backend/internal/service/concurrency_service.go @@ -32,6 +32,7 @@ type ConcurrencyCache interface { // 等待队列计数(只在首次创建时设置 TTL) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) DecrementWaitCount(ctx context.Context, userID int64) error + GetTotalWaitCount(ctx context.Context) (int, error) // 批量负载查询(只读) GetAccountsLoadBatch(ctx context.Context, accounts []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error) @@ -200,6 +201,14 @@ func (s *ConcurrencyService) DecrementWaitCount(ctx context.Context, userID int6 } } +// GetTotalWaitCount returns the total wait queue depth across users. +func (s *ConcurrencyService) GetTotalWaitCount(ctx context.Context) (int, error) { + if s.cache == nil { + return 0, nil + } + return s.cache.GetTotalWaitCount(ctx) +} + // IncrementAccountWaitCount increments the wait queue counter for an account. func (s *ConcurrencyService) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) { if s.cache == nil { diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index fd23ecb2..1647b62e 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -82,7 +82,7 @@ type crsExportResponse struct { OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"` OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"` GeminiOAuthAccounts []crsGeminiOAuthAccount `json:"geminiOAuthAccounts"` - GeminiAPIKeyAccounts []crsGeminiAPIKeyAccount `json:"geminiApiKeyAccounts"` + GeminiAPIKeyAccounts []crsGeminiAPIKeyAccount `json:"geminiAPIKeyAccounts"` } `json:"data"` } @@ -430,7 +430,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformAnthropic, - Type: AccountTypeApiKey, + Type: AccountTypeAPIKey, Credentials: credentials, Extra: extra, ProxyID: proxyID, @@ -455,7 +455,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformAnthropic - existing.Type = AccountTypeApiKey + existing.Type = AccountTypeAPIKey existing.Credentials = mergeMap(existing.Credentials, credentials) if proxyID != nil { existing.ProxyID = proxyID @@ -674,7 +674,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformOpenAI, - Type: AccountTypeApiKey, + Type: AccountTypeAPIKey, Credentials: credentials, Extra: extra, ProxyID: proxyID, @@ -699,7 +699,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformOpenAI - existing.Type = AccountTypeApiKey + existing.Type = AccountTypeAPIKey existing.Credentials = mergeMap(existing.Credentials, credentials) if proxyID != nil { existing.ProxyID = proxyID @@ -893,7 +893,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformGemini, - Type: AccountTypeApiKey, + Type: AccountTypeAPIKey, Credentials: credentials, Extra: extra, ProxyID: proxyID, @@ -918,7 +918,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput existing.Extra = mergeMap(existing.Extra, extra) existing.Name = defaultName(src.Name, src.ID) existing.Platform = PlatformGemini - existing.Type = AccountTypeApiKey + existing.Type = AccountTypeAPIKey existing.Credentials = mergeMap(existing.Credentials, credentials) if proxyID != nil { existing.ProxyID = proxyID diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 4de4a751..f0b1f2a0 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -43,8 +43,8 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi return stats, nil } -func (s *DashboardService) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error) { - trend, err := s.usageRepo.GetApiKeyUsageTrend(ctx, startTime, endTime, granularity, limit) +func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { + trend, err := s.usageRepo.GetAPIKeyUsageTrend(ctx, startTime, endTime, granularity, limit) if err != nil { return nil, fmt.Errorf("get api key usage trend: %w", err) } @@ -67,8 +67,8 @@ func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs [ return stats, nil } -func (s *DashboardService) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) { - stats, err := s.usageRepo.GetBatchApiKeyUsageStats(ctx, apiKeyIDs) +func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) { + stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs) if err != nil { return nil, fmt.Errorf("get batch api key usage stats: %w", err) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ca2c2c99..5dde0df0 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -28,7 +28,7 @@ const ( const ( AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference) AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope) - AccountTypeApiKey = "apikey" // API Key类型账号 + AccountTypeAPIKey = "apikey" // API Key类型账号 ) // Redeem type constants @@ -64,13 +64,13 @@ const ( SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 // 邮件服务设置 - SettingKeySmtpHost = "smtp_host" // SMTP服务器地址 - SettingKeySmtpPort = "smtp_port" // SMTP端口 - SettingKeySmtpUsername = "smtp_username" // SMTP用户名 - SettingKeySmtpPassword = "smtp_password" // SMTP密码(加密存储) - SettingKeySmtpFrom = "smtp_from" // 发件人地址 - SettingKeySmtpFromName = "smtp_from_name" // 发件人名称 - SettingKeySmtpUseTLS = "smtp_use_tls" // 是否使用TLS + SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 + SettingKeySMTPPort = "smtp_port" // SMTP端口 + SettingKeySMTPUsername = "smtp_username" // SMTP用户名 + SettingKeySMTPPassword = "smtp_password" // SMTP密码(加密存储) + SettingKeySMTPFrom = "smtp_from" // 发件人地址 + SettingKeySMTPFromName = "smtp_from_name" // 发件人名称 + SettingKeySMTPUseTLS = "smtp_use_tls" // 是否使用TLS // Cloudflare Turnstile 设置 SettingKeyTurnstileEnabled = "turnstile_enabled" // 是否启用 Turnstile 验证 @@ -81,20 +81,20 @@ const ( SettingKeySiteName = "site_name" // 网站名称 SettingKeySiteLogo = "site_logo" // 网站Logo (base64) SettingKeySiteSubtitle = "site_subtitle" // 网站副标题 - SettingKeyApiBaseUrl = "api_base_url" // API端点地址(用于客户端配置和导入) + SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入) SettingKeyContactInfo = "contact_info" // 客服联系方式 - SettingKeyDocUrl = "doc_url" // 文档链接 + SettingKeyDocURL = "doc_url" // 文档链接 // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultBalance = "default_balance" // 新用户默认余额 // 管理员 API Key - SettingKeyAdminApiKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成) + SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成) // Gemini 配额策略(JSON) SettingKeyGeminiQuotaPolicy = "gemini_quota_policy" ) -// Admin API Key prefix (distinct from user "sk-" keys) -const AdminApiKeyPrefix = "admin-" +// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys) +const AdminAPIKeyPrefix = "admin-" diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 6537b01e..d6a3c05b 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -40,8 +40,8 @@ const ( maxVerifyCodeAttempts = 5 ) -// SmtpConfig SMTP配置 -type SmtpConfig struct { +// SMTPConfig SMTP配置 +type SMTPConfig struct { Host string Port int Username string @@ -65,16 +65,16 @@ func NewEmailService(settingRepo SettingRepository, cache EmailCache) *EmailServ } } -// GetSmtpConfig 从数据库获取SMTP配置 -func (s *EmailService) GetSmtpConfig(ctx context.Context) (*SmtpConfig, error) { +// GetSMTPConfig 从数据库获取SMTP配置 +func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { keys := []string{ - SettingKeySmtpHost, - SettingKeySmtpPort, - SettingKeySmtpUsername, - SettingKeySmtpPassword, - SettingKeySmtpFrom, - SettingKeySmtpFromName, - SettingKeySmtpUseTLS, + SettingKeySMTPHost, + SettingKeySMTPPort, + SettingKeySMTPUsername, + SettingKeySMTPPassword, + SettingKeySMTPFrom, + SettingKeySMTPFromName, + SettingKeySMTPUseTLS, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -82,34 +82,34 @@ func (s *EmailService) GetSmtpConfig(ctx context.Context) (*SmtpConfig, error) { return nil, fmt.Errorf("get smtp settings: %w", err) } - host := settings[SettingKeySmtpHost] + host := settings[SettingKeySMTPHost] if host == "" { return nil, ErrEmailNotConfigured } port := 587 // 默认端口 - if portStr := settings[SettingKeySmtpPort]; portStr != "" { + if portStr := settings[SettingKeySMTPPort]; portStr != "" { if p, err := strconv.Atoi(portStr); err == nil { port = p } } - useTLS := settings[SettingKeySmtpUseTLS] == "true" + useTLS := settings[SettingKeySMTPUseTLS] == "true" - return &SmtpConfig{ + return &SMTPConfig{ Host: host, Port: port, - Username: settings[SettingKeySmtpUsername], - Password: settings[SettingKeySmtpPassword], - From: settings[SettingKeySmtpFrom], - FromName: settings[SettingKeySmtpFromName], + Username: settings[SettingKeySMTPUsername], + Password: settings[SettingKeySMTPPassword], + From: settings[SettingKeySMTPFrom], + FromName: settings[SettingKeySMTPFromName], UseTLS: useTLS, }, nil } // SendEmail 发送邮件(使用数据库中保存的配置) func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) error { - config, err := s.GetSmtpConfig(ctx) + config, err := s.GetSMTPConfig(ctx) if err != nil { return err } @@ -117,7 +117,7 @@ func (s *EmailService) SendEmail(ctx context.Context, to, subject, body string) } // SendEmailWithConfig 使用指定配置发送邮件 -func (s *EmailService) SendEmailWithConfig(config *SmtpConfig, to, subject, body string) error { +func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error { from := config.From if config.FromName != "" { from = fmt.Sprintf("%s <%s>", config.FromName, config.From) @@ -306,8 +306,8 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string { `, siteName, code) } -// TestSmtpConnectionWithConfig 使用指定配置测试SMTP连接 -func (s *EmailService) TestSmtpConnectionWithConfig(config *SmtpConfig) error { +// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接 +func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { addr := fmt.Sprintf("%s:%d", config.Host, config.Port) if config.UseTLS { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 808a48b2..806d2aef 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -276,7 +276,7 @@ func TestGatewayService_SelectAccountForModelWithPlatform_GeminiOAuthPreference( repo := &mockAccountRepoForPlatform{ accounts: []Account{ - {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeApiKey}, + {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeAPIKey}, {ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeOAuth}, }, accountsByID: map[int64]*Account{}, @@ -617,7 +617,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) { t.Run("混合调度-Gemini优先选择OAuth账户", func(t *testing.T) { repo := &mockAccountRepoForPlatform{ accounts: []Account{ - {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeApiKey}, + {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeAPIKey}, {ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, Type: AccountTypeOAuth}, }, accountsByID: map[int64]*Account{}, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index d78507b6..456cf81d 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -905,7 +905,7 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) ( case AccountTypeOAuth, AccountTypeSetupToken: // Both oauth and setup-token use OAuth token flow return s.getOAuthToken(ctx, account) - case AccountTypeApiKey: + case AccountTypeAPIKey: apiKey := account.GetCredential("api_key") if apiKey == "" { return "", "", errors.New("api_key not found in credentials") @@ -976,7 +976,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 应用模型映射(仅对apikey类型账号) originalModel := reqModel - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { mappedModel := account.GetMappedModel(reqModel) if mappedModel != reqModel { // 替换请求体中的模型名 @@ -1110,7 +1110,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string) (*http.Request, error) { // 确定目标URL targetURL := claudeAPIURL - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { baseURL := account.GetBaseURL() targetURL = baseURL + "/v1/messages" } @@ -1178,10 +1178,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 处理anthropic-beta header(OAuth账号需要特殊处理) if tokenType == "oauth" { req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta"))) - } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForApiKey && req.Header.Get("anthropic-beta") == "" { + } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) if requestNeedsBetaFeatures(body) { - if beta := defaultApiKeyBetaHeader(body); beta != "" { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { req.Header.Set("anthropic-beta", beta) } } @@ -1248,12 +1248,12 @@ func requestNeedsBetaFeatures(body []byte) bool { return false } -func defaultApiKeyBetaHeader(body []byte) string { +func defaultAPIKeyBetaHeader(body []byte) string { modelID := gjson.GetBytes(body, "model").String() if strings.Contains(strings.ToLower(modelID), "haiku") { - return claude.ApiKeyHaikuBetaHeader + return claude.APIKeyHaikuBetaHeader } - return claude.ApiKeyBetaHeader + return claude.APIKeyBetaHeader } func truncateForLog(b []byte, maxBytes int) string { @@ -1630,7 +1630,7 @@ func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toMo // RecordUsageInput 记录使用量的输入参数 type RecordUsageInput struct { Result *ForwardResult - ApiKey *ApiKey + APIKey *APIKey User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 @@ -1639,7 +1639,7 @@ type RecordUsageInput struct { // RecordUsage 记录使用量并扣费(或更新订阅用量) func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error { result := input.Result - apiKey := input.ApiKey + apiKey := input.APIKey user := input.User account := input.Account subscription := input.Subscription @@ -1676,7 +1676,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu durationMs := int(result.Duration.Milliseconds()) usageLog := &UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: result.RequestID, Model: result.Model, @@ -1762,7 +1762,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 应用模型映射(仅对 apikey 类型账号) - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { if reqModel != "" { mappedModel := account.GetMappedModel(reqModel) if mappedModel != reqModel { @@ -1848,7 +1848,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string) (*http.Request, error) { // 确定目标 URL targetURL := claudeAPICountTokensURL - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { baseURL := account.GetBaseURL() targetURL = baseURL + "/v1/messages/count_tokens" } @@ -1910,10 +1910,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth 账号:处理 anthropic-beta header if tokenType == "oauth" { req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta"))) - } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForApiKey && req.Header.Get("anthropic-beta") == "" { + } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { // API-key:与 messages 同步的按需 beta 注入(默认关闭) if requestNeedsBetaFeatures(body) { - if beta := defaultApiKeyBetaHeader(body); beta != "" { + if beta := defaultAPIKeyBetaHeader(body); beta != "" { req.Header.Set("anthropic-beta", beta) } } diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 15d2c16d..079943f1 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -273,7 +273,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont return 999 } switch a.Type { - case AccountTypeApiKey: + case AccountTypeAPIKey: if strings.TrimSpace(a.GetCredential("api_key")) != "" { return 0 } @@ -351,7 +351,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex originalModel := req.Model mappedModel := req.Model - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { mappedModel = account.GetMappedModel(req.Model) } @@ -374,7 +374,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } switch account.Type { - case AccountTypeApiKey: + case AccountTypeAPIKey: buildReq = func(ctx context.Context) (*http.Request, string, error) { apiKey := account.GetCredential("api_key") if strings.TrimSpace(apiKey) == "" { @@ -614,7 +614,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } mappedModel := originalModel - if account.Type == AccountTypeApiKey { + if account.Type == AccountTypeAPIKey { mappedModel = account.GetMappedModel(originalModel) } @@ -636,7 +636,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. var buildReq func(ctx context.Context) (*http.Request, string, error) switch account.Type { - case AccountTypeApiKey: + case AccountTypeAPIKey: buildReq = func(ctx context.Context) (*http.Request, string, error) { apiKey := account.GetCredential("api_key") if strings.TrimSpace(apiKey) == "" { @@ -1758,7 +1758,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac } switch account.Type { - case AccountTypeApiKey: + case AccountTypeAPIKey: apiKey := strings.TrimSpace(account.GetCredential("api_key")) if apiKey == "" { return nil, errors.New("gemini api_key not configured") diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 6ca5052e..5c604f0f 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -275,7 +275,7 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPr repo := &mockAccountRepoForGemini{ accounts: []Account{ - {ID: 1, Platform: PlatformGemini, Type: AccountTypeApiKey, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: nil}, + {ID: 1, Platform: PlatformGemini, Type: AccountTypeAPIKey, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: nil}, {ID: 2, Platform: PlatformGemini, Type: AccountTypeOAuth, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: nil}, }, accountsByID: map[int64]*Account{}, diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go index e9ccae34..e0f484ba 100644 --- a/backend/internal/service/gemini_oauth_service.go +++ b/backend/internal/service/gemini_oauth_service.go @@ -251,7 +251,7 @@ func inferGoogleOneTier(storageBytes int64) string { return TierGoogleOneUnknown } -// fetchGoogleOneTier fetches Google One tier from Drive API +// FetchGoogleOneTier fetches Google One tier from Drive API func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken, proxyURL string) (string, *geminicli.DriveStorageInfo, error) { driveClient := geminicli.NewDriveClient() diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f8eb29bd..b9096715 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -487,8 +487,8 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco return "", "", errors.New("access_token not found in credentials") } return accessToken, "oauth", nil - case AccountTypeApiKey: - apiKey := account.GetOpenAIApiKey() + case AccountTypeAPIKey: + apiKey := account.GetOpenAIAPIKey() if apiKey == "" { return "", "", errors.New("api_key not found in credentials") } @@ -627,7 +627,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeOAuth: // OAuth accounts use ChatGPT internal API targetURL = chatgptCodexURL - case AccountTypeApiKey: + case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() if baseURL != "" { @@ -940,7 +940,7 @@ func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel // OpenAIRecordUsageInput input for recording usage type OpenAIRecordUsageInput struct { Result *OpenAIForwardResult - ApiKey *ApiKey + APIKey *APIKey User *User Account *Account Subscription *UserSubscription @@ -949,7 +949,7 @@ type OpenAIRecordUsageInput struct { // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { result := input.Result - apiKey := input.ApiKey + apiKey := input.APIKey user := input.User account := input.Account subscription := input.Subscription @@ -991,7 +991,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec durationMs := int(result.Duration.Milliseconds()) usageLog := &UsageLog{ UserID: user.ID, - ApiKeyID: apiKey.ID, + APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: result.RequestID, Model: result.Model, diff --git a/backend/internal/service/ops.go b/backend/internal/service/ops.go new file mode 100644 index 00000000..6a44d75c --- /dev/null +++ b/backend/internal/service/ops.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "time" +) + +// ErrorLog represents an ops error log item for list queries. +// +// Field naming matches docs/API-运维监控中心2.0.md (L3 根因追踪 - 错误日志列表). +type ErrorLog struct { + ID int64 `json:"id"` + Timestamp time.Time `json:"timestamp"` + + Level string `json:"level,omitempty"` + RequestID string `json:"request_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + APIPath string `json:"api_path,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + HTTPCode int `json:"http_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + + DurationMs *int `json:"duration_ms,omitempty"` + RetryCount *int `json:"retry_count,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// ErrorLogFilter describes optional filters and pagination for listing ops error logs. +type ErrorLogFilter struct { + StartTime *time.Time + EndTime *time.Time + + ErrorCode *int + Provider string + AccountID *int64 + + Page int + PageSize int +} + +func (f *ErrorLogFilter) normalize() (page, pageSize int) { + page = 1 + pageSize = 20 + if f == nil { + return page, pageSize + } + + if f.Page > 0 { + page = f.Page + } + if f.PageSize > 0 { + pageSize = f.PageSize + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +type ErrorLogListResponse struct { + Errors []*ErrorLog `json:"errors"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *OpsService) GetErrorLogs(ctx context.Context, filter *ErrorLogFilter) (*ErrorLogListResponse, error) { + if s == nil || s.repo == nil { + return &ErrorLogListResponse{ + Errors: []*ErrorLog{}, + Total: 0, + Page: 1, + PageSize: 20, + }, nil + } + + page, pageSize := filter.normalize() + if filter == nil { + filter = &ErrorLogFilter{} + } + filter.Page = page + filter.PageSize = pageSize + + items, total, err := s.repo.ListErrorLogs(ctx, filter) + if err != nil { + return nil, err + } + if items == nil { + items = []*ErrorLog{} + } + + return &ErrorLogListResponse{ + Errors: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} diff --git a/backend/internal/service/ops_alert_service.go b/backend/internal/service/ops_alert_service.go new file mode 100644 index 00000000..afe283af --- /dev/null +++ b/backend/internal/service/ops_alert_service.go @@ -0,0 +1,834 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +type OpsAlertService struct { + opsService *OpsService + userService *UserService + emailService *EmailService + httpClient *http.Client + + interval time.Duration + + startOnce sync.Once + stopOnce sync.Once + stopCtx context.Context + stop context.CancelFunc + wg sync.WaitGroup +} + +// opsAlertEvalInterval defines how often OpsAlertService evaluates alert rules. +// +// Production uses opsMetricsInterval. Tests may override this variable to keep +// integration tests fast without changing production defaults. +var opsAlertEvalInterval = opsMetricsInterval + +func NewOpsAlertService(opsService *OpsService, userService *UserService, emailService *EmailService) *OpsAlertService { + return &OpsAlertService{ + opsService: opsService, + userService: userService, + emailService: emailService, + httpClient: &http.Client{Timeout: 10 * time.Second}, + interval: opsAlertEvalInterval, + } +} + +// Start launches the background alert evaluation loop. +// +// Stop must be called during shutdown to ensure the goroutine exits. +func (s *OpsAlertService) Start() { + s.StartWithContext(context.Background()) +} + +// StartWithContext is like Start but allows the caller to provide a parent context. +// When the parent context is canceled, the service stops automatically. +func (s *OpsAlertService) StartWithContext(ctx context.Context) { + if s == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + + s.startOnce.Do(func() { + if s.interval <= 0 { + s.interval = opsAlertEvalInterval + } + + s.stopCtx, s.stop = context.WithCancel(ctx) + s.wg.Add(1) + go s.run() + }) +} + +// Stop gracefully stops the background goroutine started by Start/StartWithContext. +// It is safe to call Stop multiple times. +func (s *OpsAlertService) Stop() { + if s == nil { + return + } + + s.stopOnce.Do(func() { + if s.stop != nil { + s.stop() + } + }) + s.wg.Wait() +} + +func (s *OpsAlertService) run() { + defer s.wg.Done() + + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.evaluateOnce() + for { + select { + case <-ticker.C: + s.evaluateOnce() + case <-s.stopCtx.Done(): + return + } + } +} + +func (s *OpsAlertService) evaluateOnce() { + ctx, cancel := context.WithTimeout(s.stopCtx, opsAlertEvaluateTimeout) + defer cancel() + + s.Evaluate(ctx, time.Now()) +} + +func (s *OpsAlertService) Evaluate(ctx context.Context, now time.Time) { + if s == nil || s.opsService == nil { + return + } + + rules, err := s.opsService.ListAlertRules(ctx) + if err != nil { + log.Printf("[OpsAlert] failed to list rules: %v", err) + return + } + if len(rules) == 0 { + return + } + + maxSustainedByWindow := make(map[int]int) + for _, rule := range rules { + if !rule.Enabled { + continue + } + window := rule.WindowMinutes + if window <= 0 { + window = 1 + } + sustained := rule.SustainedMinutes + if sustained <= 0 { + sustained = 1 + } + if sustained > maxSustainedByWindow[window] { + maxSustainedByWindow[window] = sustained + } + } + + metricsByWindow := make(map[int][]OpsMetrics) + for window, limit := range maxSustainedByWindow { + metrics, err := s.opsService.ListRecentSystemMetrics(ctx, window, limit) + if err != nil { + log.Printf("[OpsAlert] failed to load metrics window=%dm: %v", window, err) + continue + } + metricsByWindow[window] = metrics + } + + for _, rule := range rules { + if !rule.Enabled { + continue + } + window := rule.WindowMinutes + if window <= 0 { + window = 1 + } + sustained := rule.SustainedMinutes + if sustained <= 0 { + sustained = 1 + } + + metrics := metricsByWindow[window] + selected, ok := selectContiguousMetrics(metrics, sustained, now) + if !ok { + continue + } + + breached, latestValue, ok := evaluateRule(rule, selected) + if !ok { + continue + } + + activeEvent, err := s.opsService.GetActiveAlertEvent(ctx, rule.ID) + if err != nil { + log.Printf("[OpsAlert] failed to get active event (rule=%d): %v", rule.ID, err) + continue + } + + if breached { + if activeEvent != nil { + continue + } + + lastEvent, err := s.opsService.GetLatestAlertEvent(ctx, rule.ID) + if err != nil { + log.Printf("[OpsAlert] failed to get latest event (rule=%d): %v", rule.ID, err) + continue + } + if lastEvent != nil && rule.CooldownMinutes > 0 { + cooldown := time.Duration(rule.CooldownMinutes) * time.Minute + if now.Sub(lastEvent.FiredAt) < cooldown { + continue + } + } + + event := &OpsAlertEvent{ + RuleID: rule.ID, + Severity: rule.Severity, + Status: OpsAlertStatusFiring, + Title: fmt.Sprintf("%s: %s", rule.Severity, rule.Name), + Description: buildAlertDescription(rule, latestValue), + MetricValue: latestValue, + ThresholdValue: rule.Threshold, + FiredAt: now, + CreatedAt: now, + } + + if err := s.opsService.CreateAlertEvent(ctx, event); err != nil { + log.Printf("[OpsAlert] failed to create event (rule=%d): %v", rule.ID, err) + continue + } + + emailSent, webhookSent := s.dispatchNotifications(ctx, rule, event) + if emailSent || webhookSent { + if err := s.opsService.UpdateAlertEventNotifications(ctx, event.ID, emailSent, webhookSent); err != nil { + log.Printf("[OpsAlert] failed to update notification flags (event=%d): %v", event.ID, err) + } + } + } else if activeEvent != nil { + resolvedAt := now + if err := s.opsService.UpdateAlertEventStatus(ctx, activeEvent.ID, OpsAlertStatusResolved, &resolvedAt); err != nil { + log.Printf("[OpsAlert] failed to resolve event (event=%d): %v", activeEvent.ID, err) + } + } + } +} + +const opsMetricsContinuityTolerance = 20 * time.Second + +// selectContiguousMetrics picks the newest N metrics and verifies they are continuous. +// +// This prevents a sustained rule from triggering when metrics sampling has gaps +// (e.g. collector downtime) and avoids evaluating "stale" data. +// +// Assumptions: +// - Metrics are ordered by UpdatedAt DESC (newest first). +// - Metrics are expected to be collected at opsMetricsInterval cadence. +func selectContiguousMetrics(metrics []OpsMetrics, needed int, now time.Time) ([]OpsMetrics, bool) { + if needed <= 0 { + return nil, false + } + if len(metrics) < needed { + return nil, false + } + newest := metrics[0].UpdatedAt + if newest.IsZero() { + return nil, false + } + if now.Sub(newest) > opsMetricsInterval+opsMetricsContinuityTolerance { + return nil, false + } + + selected := metrics[:needed] + for i := 0; i < len(selected)-1; i++ { + a := selected[i].UpdatedAt + b := selected[i+1].UpdatedAt + if a.IsZero() || b.IsZero() { + return nil, false + } + gap := a.Sub(b) + if gap < opsMetricsInterval-opsMetricsContinuityTolerance || gap > opsMetricsInterval+opsMetricsContinuityTolerance { + return nil, false + } + } + return selected, true +} + +func evaluateRule(rule OpsAlertRule, metrics []OpsMetrics) (bool, float64, bool) { + if len(metrics) == 0 { + return false, 0, false + } + + latestValue, ok := metricValue(metrics[0], rule.MetricType) + if !ok { + return false, 0, false + } + + for _, metric := range metrics { + value, ok := metricValue(metric, rule.MetricType) + if !ok || !compareMetric(value, rule.Operator, rule.Threshold) { + return false, latestValue, true + } + } + + return true, latestValue, true +} + +func metricValue(metric OpsMetrics, metricType string) (float64, bool) { + switch metricType { + case OpsMetricSuccessRate: + if metric.RequestCount == 0 { + return 0, false + } + return metric.SuccessRate, true + case OpsMetricErrorRate: + if metric.RequestCount == 0 { + return 0, false + } + return metric.ErrorRate, true + case OpsMetricP95LatencyMs: + return float64(metric.P95LatencyMs), true + case OpsMetricP99LatencyMs: + return float64(metric.P99LatencyMs), true + case OpsMetricHTTP2Errors: + return float64(metric.HTTP2Errors), true + case OpsMetricCPUUsagePercent: + return metric.CPUUsagePercent, true + case OpsMetricMemoryUsagePercent: + return metric.MemoryUsagePercent, true + case OpsMetricQueueDepth: + return float64(metric.ConcurrencyQueueDepth), true + default: + return 0, false + } +} + +func compareMetric(value float64, operator string, threshold float64) bool { + switch operator { + case ">": + return value > threshold + case ">=": + return value >= threshold + case "<": + return value < threshold + case "<=": + return value <= threshold + case "==": + return value == threshold + default: + return false + } +} + +func buildAlertDescription(rule OpsAlertRule, value float64) string { + window := rule.WindowMinutes + if window <= 0 { + window = 1 + } + return fmt.Sprintf("Rule %s triggered: %s %s %.2f (current %.2f) over last %dm", + rule.Name, + rule.MetricType, + rule.Operator, + rule.Threshold, + value, + window, + ) +} + +func (s *OpsAlertService) dispatchNotifications(ctx context.Context, rule OpsAlertRule, event *OpsAlertEvent) (bool, bool) { + emailSent := false + webhookSent := false + + notifyCtx, cancel := s.notificationContext(ctx) + defer cancel() + + if rule.NotifyEmail { + emailSent = s.sendEmailNotification(notifyCtx, rule, event) + } + if rule.NotifyWebhook && rule.WebhookURL != "" { + webhookSent = s.sendWebhookNotification(notifyCtx, rule, event) + } + // Fallback channel: if email is enabled but ultimately fails, try webhook even if the + // webhook toggle is off (as long as a webhook URL is configured). + if rule.NotifyEmail && !emailSent && !rule.NotifyWebhook && rule.WebhookURL != "" { + log.Printf("[OpsAlert] email failed; attempting webhook fallback (rule=%d)", rule.ID) + webhookSent = s.sendWebhookNotification(notifyCtx, rule, event) + } + + return emailSent, webhookSent +} + +const ( + opsAlertEvaluateTimeout = 45 * time.Second + opsAlertNotificationTimeout = 30 * time.Second + opsAlertEmailMaxRetries = 3 +) + +var opsAlertEmailBackoff = []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, +} + +func (s *OpsAlertService) notificationContext(ctx context.Context) (context.Context, context.CancelFunc) { + parent := ctx + if s != nil && s.stopCtx != nil { + parent = s.stopCtx + } + if parent == nil { + parent = context.Background() + } + return context.WithTimeout(parent, opsAlertNotificationTimeout) +} + +var opsAlertSleep = sleepWithContext + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + if ctx == nil { + time.Sleep(d) + return nil + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func retryWithBackoff( + ctx context.Context, + maxRetries int, + backoff []time.Duration, + fn func() error, + onError func(attempt int, total int, nextDelay time.Duration, err error), +) error { + if ctx == nil { + ctx = context.Background() + } + if maxRetries < 0 { + maxRetries = 0 + } + totalAttempts := maxRetries + 1 + + var lastErr error + for attempt := 1; attempt <= totalAttempts; attempt++ { + if attempt > 1 { + backoffIdx := attempt - 2 + if backoffIdx < len(backoff) { + if err := opsAlertSleep(ctx, backoff[backoffIdx]); err != nil { + return err + } + } + } + + if err := ctx.Err(); err != nil { + return err + } + + if err := fn(); err != nil { + lastErr = err + nextDelay := time.Duration(0) + if attempt < totalAttempts { + nextIdx := attempt - 1 + if nextIdx < len(backoff) { + nextDelay = backoff[nextIdx] + } + } + if onError != nil { + onError(attempt, totalAttempts, nextDelay, err) + } + continue + } + return nil + } + + return lastErr +} + +func (s *OpsAlertService) sendEmailNotification(ctx context.Context, rule OpsAlertRule, event *OpsAlertEvent) bool { + if s.emailService == nil || s.userService == nil { + return false + } + + if ctx == nil { + ctx = context.Background() + } + + admin, err := s.userService.GetFirstAdmin(ctx) + if err != nil || admin == nil || admin.Email == "" { + return false + } + + subject := fmt.Sprintf("[Ops Alert][%s] %s", rule.Severity, rule.Name) + body := fmt.Sprintf( + "Alert triggered: %s\n\nMetric: %s\nThreshold: %.2f\nCurrent: %.2f\nWindow: %dm\nStatus: %s\nTime: %s", + rule.Name, + rule.MetricType, + rule.Threshold, + event.MetricValue, + rule.WindowMinutes, + event.Status, + event.FiredAt.Format(time.RFC3339), + ) + + config, err := s.emailService.GetSMTPConfig(ctx) + if err != nil { + log.Printf("[OpsAlert] email config load failed: %v", err) + return false + } + + if err := retryWithBackoff( + ctx, + opsAlertEmailMaxRetries, + opsAlertEmailBackoff, + func() error { + return s.emailService.SendEmailWithConfig(config, admin.Email, subject, body) + }, + func(attempt int, total int, nextDelay time.Duration, err error) { + if attempt < total { + log.Printf("[OpsAlert] email send failed (attempt=%d/%d), retrying in %s: %v", attempt, total, nextDelay, err) + return + } + log.Printf("[OpsAlert] email send failed (attempt=%d/%d), giving up: %v", attempt, total, err) + }, + ); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.Printf("[OpsAlert] email send canceled: %v", err) + } + return false + } + return true +} + +func (s *OpsAlertService) sendWebhookNotification(ctx context.Context, rule OpsAlertRule, event *OpsAlertEvent) bool { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + webhookTarget, err := validateWebhookURL(ctx, rule.WebhookURL) + if err != nil { + log.Printf("[OpsAlert] invalid webhook url (rule=%d): %v", rule.ID, err) + return false + } + + payload := map[string]any{ + "rule_id": rule.ID, + "rule_name": rule.Name, + "severity": rule.Severity, + "status": event.Status, + "metric_type": rule.MetricType, + "metric_value": event.MetricValue, + "threshold_value": rule.Threshold, + "window_minutes": rule.WindowMinutes, + "fired_at": event.FiredAt.Format(time.RFC3339), + } + + body, err := json.Marshal(payload) + if err != nil { + return false + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookTarget.URL.String(), bytes.NewReader(body)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + + resp, err := buildWebhookHTTPClient(s.httpClient, webhookTarget).Do(req) + if err != nil { + log.Printf("[OpsAlert] webhook send failed: %v", err) + return false + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + log.Printf("[OpsAlert] webhook returned status %d", resp.StatusCode) + return false + } + return true +} + +const webhookHTTPClientTimeout = 10 * time.Second + +func buildWebhookHTTPClient(base *http.Client, webhookTarget *validatedWebhookTarget) *http.Client { + var client http.Client + if base != nil { + client = *base + } + if client.Timeout <= 0 { + client.Timeout = webhookHTTPClientTimeout + } + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + if webhookTarget != nil { + client.Transport = buildWebhookTransport(client.Transport, webhookTarget) + } + return &client +} + +var disallowedWebhookIPNets = []net.IPNet{ + // "this host on this network" / unspecified. + mustParseCIDR("0.0.0.0/8"), + mustParseCIDR("127.0.0.0/8"), // loopback (includes 127.0.0.1) + mustParseCIDR("10.0.0.0/8"), // RFC1918 + mustParseCIDR("192.168.0.0/16"), // RFC1918 + mustParseCIDR("172.16.0.0/12"), // RFC1918 (172.16.0.0 - 172.31.255.255) + mustParseCIDR("100.64.0.0/10"), // RFC6598 (carrier-grade NAT) + mustParseCIDR("169.254.0.0/16"), // IPv4 link-local (includes 169.254.169.254 metadata IP on many clouds) + mustParseCIDR("198.18.0.0/15"), // RFC2544 benchmark testing + mustParseCIDR("224.0.0.0/4"), // IPv4 multicast + mustParseCIDR("240.0.0.0/4"), // IPv4 reserved + mustParseCIDR("::/128"), // IPv6 unspecified + mustParseCIDR("::1/128"), // IPv6 loopback + mustParseCIDR("fc00::/7"), // IPv6 unique local + mustParseCIDR("fe80::/10"), // IPv6 link-local + mustParseCIDR("ff00::/8"), // IPv6 multicast +} + +func mustParseCIDR(cidr string) net.IPNet { + _, block, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + return *block +} + +var lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return net.DefaultResolver.LookupIPAddr(ctx, host) +} + +type validatedWebhookTarget struct { + URL *url.URL + + host string + port string + pinnedIPs []net.IP +} + +var webhookBaseDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + dialer := net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + } + return dialer.DialContext(ctx, network, addr) +} + +func buildWebhookTransport(base http.RoundTripper, webhookTarget *validatedWebhookTarget) http.RoundTripper { + if webhookTarget == nil || webhookTarget.URL == nil { + return base + } + + var transport *http.Transport + switch typed := base.(type) { + case *http.Transport: + if typed != nil { + transport = typed.Clone() + } + } + if transport == nil { + if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok && defaultTransport != nil { + transport = defaultTransport.Clone() + } else { + transport = (&http.Transport{}).Clone() + } + } + + webhookHost := webhookTarget.host + webhookPort := webhookTarget.port + pinnedIPs := append([]net.IP(nil), webhookTarget.pinnedIPs...) + + transport.Proxy = nil + transport.DialTLSContext = nil + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil || host == "" || port == "" { + return nil, fmt.Errorf("webhook dial target is invalid: %q", addr) + } + + canonicalHost := strings.TrimSuffix(strings.ToLower(host), ".") + if canonicalHost != webhookHost || port != webhookPort { + return nil, fmt.Errorf("webhook dial target mismatch: %q", addr) + } + + var lastErr error + for _, ip := range pinnedIPs { + if isDisallowedWebhookIP(ip) { + lastErr = fmt.Errorf("webhook target resolves to a disallowed ip") + continue + } + + dialAddr := net.JoinHostPort(ip.String(), port) + conn, err := webhookBaseDialContext(ctx, network, dialAddr) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("webhook target has no resolved addresses") + } + return nil, lastErr + } + + return transport +} + +func validateWebhookURL(ctx context.Context, raw string) (*validatedWebhookTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("webhook url is empty") + } + // Avoid request smuggling / header injection vectors. + if strings.ContainsAny(raw, "\r\n") { + return nil, errors.New("webhook url contains invalid characters") + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, errors.New("webhook url format is invalid") + } + if !strings.EqualFold(parsed.Scheme, "https") { + return nil, errors.New("webhook url scheme must be https") + } + parsed.Scheme = "https" + if parsed.Host == "" || parsed.Hostname() == "" { + return nil, errors.New("webhook url must include host") + } + if parsed.User != nil { + return nil, errors.New("webhook url must not include userinfo") + } + if parsed.Port() != "" { + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return nil, errors.New("webhook url port is invalid") + } + } + + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "localhost" { + return nil, errors.New("webhook url host must not be localhost") + } + + if ip := net.ParseIP(host); ip != nil { + if isDisallowedWebhookIP(ip) { + return nil, errors.New("webhook url host resolves to a disallowed ip") + } + return &validatedWebhookTarget{ + URL: parsed, + host: host, + port: portForScheme(parsed), + pinnedIPs: []net.IP{ip}, + }, nil + } + + if ctx == nil { + ctx = context.Background() + } + ips, err := lookupIPAddrs(ctx, host) + if err != nil || len(ips) == 0 { + return nil, errors.New("webhook url host cannot be resolved") + } + pinned := make([]net.IP, 0, len(ips)) + for _, addr := range ips { + if isDisallowedWebhookIP(addr.IP) { + return nil, errors.New("webhook url host resolves to a disallowed ip") + } + if addr.IP != nil { + pinned = append(pinned, addr.IP) + } + } + + if len(pinned) == 0 { + return nil, errors.New("webhook url host cannot be resolved") + } + + return &validatedWebhookTarget{ + URL: parsed, + host: host, + port: portForScheme(parsed), + pinnedIPs: uniqueResolvedIPs(pinned), + }, nil +} + +func isDisallowedWebhookIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip4 := ip.To4(); ip4 != nil { + ip = ip4 + } else if ip16 := ip.To16(); ip16 != nil { + ip = ip16 + } else { + return false + } + + // Disallow non-public addresses even if they're not explicitly covered by the CIDR list. + // This provides defense-in-depth against SSRF targets such as link-local, multicast, and + // unspecified addresses, and ensures any "pinned" IP is still blocked at dial time. + if ip.IsUnspecified() || + ip.IsLoopback() || + ip.IsMulticast() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsPrivate() { + return true + } + + for _, block := range disallowedWebhookIPNets { + if block.Contains(ip) { + return true + } + } + return false +} + +func portForScheme(u *url.URL) string { + if u != nil && u.Port() != "" { + return u.Port() + } + return "443" +} + +func uniqueResolvedIPs(ips []net.IP) []net.IP { + seen := make(map[string]struct{}, len(ips)) + out := make([]net.IP, 0, len(ips)) + for _, ip := range ips { + if ip == nil { + continue + } + key := ip.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ip) + } + return out +} diff --git a/backend/internal/service/ops_alert_service_integration_test.go b/backend/internal/service/ops_alert_service_integration_test.go new file mode 100644 index 00000000..695cd2e5 --- /dev/null +++ b/backend/internal/service/ops_alert_service_integration_test.go @@ -0,0 +1,271 @@ +//go:build integration + +package service + +import ( + "context" + "database/sql" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// This integration test protects the DI startup contract for OpsAlertService. +// +// Background: +// - OpsMetricsCollector previously called alertService.Start()/Evaluate() directly. +// - Those direct calls were removed, so OpsAlertService must now start via DI +// (ProvideOpsAlertService in wire.go) and run its own evaluation ticker. +// +// What we validate here: +// 1. When we construct via the Wire provider functions (ProvideOpsAlertService + +// ProvideOpsMetricsCollector), OpsAlertService starts automatically. +// 2. Its evaluation loop continues to tick even if OpsMetricsCollector is stopped, +// proving the alert evaluator is independent. +// 3. The evaluation path can trigger alert logic (CreateAlertEvent called). +func TestOpsAlertService_StartedViaWireProviders_RunsIndependentTicker(t *testing.T) { + oldInterval := opsAlertEvalInterval + opsAlertEvalInterval = 25 * time.Millisecond + t.Cleanup(func() { opsAlertEvalInterval = oldInterval }) + + repo := newFakeOpsRepository() + opsService := NewOpsService(repo, nil) + + // Start via the Wire provider function (the production DI path). + alertService := ProvideOpsAlertService(opsService, nil, nil) + t.Cleanup(alertService.Stop) + + // Construct via ProvideOpsMetricsCollector (wire.go). Stop immediately to ensure + // the alert ticker keeps running without the metrics collector. + collector := ProvideOpsMetricsCollector(opsService, NewConcurrencyService(nil)) + collector.Stop() + + // Wait for at least one evaluation (run() calls evaluateOnce immediately). + require.Eventually(t, func() bool { + return repo.listRulesCalls.Load() >= 1 + }, 1*time.Second, 5*time.Millisecond) + + // Confirm the evaluation loop keeps ticking after the metrics collector is stopped. + callsAfterCollectorStop := repo.listRulesCalls.Load() + require.Eventually(t, func() bool { + return repo.listRulesCalls.Load() >= callsAfterCollectorStop+2 + }, 1*time.Second, 5*time.Millisecond) + + // Confirm the evaluation logic actually fires an alert event at least once. + select { + case <-repo.eventCreatedCh: + // ok + case <-time.After(2 * time.Second): + t.Fatalf("expected OpsAlertService to create an alert event, but none was created (ListAlertRules calls=%d)", repo.listRulesCalls.Load()) + } +} + +func newFakeOpsRepository() *fakeOpsRepository { + return &fakeOpsRepository{ + eventCreatedCh: make(chan struct{}), + } +} + +// fakeOpsRepository is a lightweight in-memory stub of OpsRepository for integration tests. +// It avoids real DB/Redis usage and provides deterministic responses fast. +type fakeOpsRepository struct { + listRulesCalls atomic.Int64 + + mu sync.Mutex + activeEvent *OpsAlertEvent + latestEvent *OpsAlertEvent + nextEventID int64 + eventCreatedCh chan struct{} + eventOnce sync.Once +} + +func (r *fakeOpsRepository) CreateErrorLog(ctx context.Context, log *OpsErrorLog) error { + return nil +} + +func (r *fakeOpsRepository) ListErrorLogsLegacy(ctx context.Context, filters OpsErrorLogFilters) ([]OpsErrorLog, error) { + return nil, nil +} + +func (r *fakeOpsRepository) ListErrorLogs(ctx context.Context, filter *ErrorLogFilter) ([]*ErrorLog, int64, error) { + return nil, 0, nil +} + +func (r *fakeOpsRepository) GetLatestSystemMetric(ctx context.Context) (*OpsMetrics, error) { + return &OpsMetrics{WindowMinutes: 1}, sql.ErrNoRows +} + +func (r *fakeOpsRepository) CreateSystemMetric(ctx context.Context, metric *OpsMetrics) error { + return nil +} + +func (r *fakeOpsRepository) GetWindowStats(ctx context.Context, startTime, endTime time.Time) (*OpsWindowStats, error) { + return &OpsWindowStats{}, nil +} + +func (r *fakeOpsRepository) GetProviderStats(ctx context.Context, startTime, endTime time.Time) ([]*ProviderStats, error) { + return nil, nil +} + +func (r *fakeOpsRepository) GetLatencyHistogram(ctx context.Context, startTime, endTime time.Time) ([]*LatencyHistogramItem, error) { + return nil, nil +} + +func (r *fakeOpsRepository) GetErrorDistribution(ctx context.Context, startTime, endTime time.Time) ([]*ErrorDistributionItem, error) { + return nil, nil +} + +func (r *fakeOpsRepository) ListRecentSystemMetrics(ctx context.Context, windowMinutes, limit int) ([]OpsMetrics, error) { + if limit <= 0 { + limit = 1 + } + now := time.Now() + metrics := make([]OpsMetrics, 0, limit) + for i := 0; i < limit; i++ { + metrics = append(metrics, OpsMetrics{ + WindowMinutes: windowMinutes, + CPUUsagePercent: 99, + UpdatedAt: now.Add(-time.Duration(i) * opsMetricsInterval), + }) + } + return metrics, nil +} + +func (r *fakeOpsRepository) ListSystemMetricsRange(ctx context.Context, windowMinutes int, startTime, endTime time.Time, limit int) ([]OpsMetrics, error) { + return nil, nil +} + +func (r *fakeOpsRepository) ListAlertRules(ctx context.Context) ([]OpsAlertRule, error) { + call := r.listRulesCalls.Add(1) + // Delay enabling rules slightly so the test can stop OpsMetricsCollector first, + // then observe the alert evaluator ticking independently. + if call < 5 { + return nil, nil + } + return []OpsAlertRule{ + { + ID: 1, + Name: "cpu too high (test)", + Enabled: true, + MetricType: OpsMetricCPUUsagePercent, + Operator: ">", + Threshold: 0, + WindowMinutes: 1, + SustainedMinutes: 1, + Severity: "P1", + NotifyEmail: false, + NotifyWebhook: false, + CooldownMinutes: 0, + }, + }, nil +} + +func (r *fakeOpsRepository) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.activeEvent == nil { + return nil, nil + } + if r.activeEvent.RuleID != ruleID { + return nil, nil + } + if r.activeEvent.Status != OpsAlertStatusFiring { + return nil, nil + } + clone := *r.activeEvent + return &clone, nil +} + +func (r *fakeOpsRepository) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.latestEvent == nil || r.latestEvent.RuleID != ruleID { + return nil, nil + } + clone := *r.latestEvent + return &clone, nil +} + +func (r *fakeOpsRepository) CreateAlertEvent(ctx context.Context, event *OpsAlertEvent) error { + if event == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + + r.nextEventID++ + event.ID = r.nextEventID + + clone := *event + r.latestEvent = &clone + if clone.Status == OpsAlertStatusFiring { + r.activeEvent = &clone + } + + r.eventOnce.Do(func() { close(r.eventCreatedCh) }) + return nil +} + +func (r *fakeOpsRepository) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.activeEvent != nil && r.activeEvent.ID == eventID { + r.activeEvent.Status = status + r.activeEvent.ResolvedAt = resolvedAt + } + if r.latestEvent != nil && r.latestEvent.ID == eventID { + r.latestEvent.Status = status + r.latestEvent.ResolvedAt = resolvedAt + } + return nil +} + +func (r *fakeOpsRepository) UpdateAlertEventNotifications(ctx context.Context, eventID int64, emailSent, webhookSent bool) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.activeEvent != nil && r.activeEvent.ID == eventID { + r.activeEvent.EmailSent = emailSent + r.activeEvent.WebhookSent = webhookSent + } + if r.latestEvent != nil && r.latestEvent.ID == eventID { + r.latestEvent.EmailSent = emailSent + r.latestEvent.WebhookSent = webhookSent + } + return nil +} + +func (r *fakeOpsRepository) CountActiveAlerts(ctx context.Context) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.activeEvent == nil { + return 0, nil + } + return 1, nil +} + +func (r *fakeOpsRepository) GetOverviewStats(ctx context.Context, startTime, endTime time.Time) (*OverviewStats, error) { + return &OverviewStats{}, nil +} + +func (r *fakeOpsRepository) GetCachedLatestSystemMetric(ctx context.Context) (*OpsMetrics, error) { + return nil, nil +} + +func (r *fakeOpsRepository) SetCachedLatestSystemMetric(ctx context.Context, metric *OpsMetrics) error { + return nil +} + +func (r *fakeOpsRepository) GetCachedDashboardOverview(ctx context.Context, timeRange string) (*DashboardOverviewData, error) { + return nil, nil +} + +func (r *fakeOpsRepository) SetCachedDashboardOverview(ctx context.Context, timeRange string, data *DashboardOverviewData, ttl time.Duration) error { + return nil +} + +func (r *fakeOpsRepository) PingRedis(ctx context.Context) error { + return nil +} diff --git a/backend/internal/service/ops_alert_service_test.go b/backend/internal/service/ops_alert_service_test.go new file mode 100644 index 00000000..ec20d81c --- /dev/null +++ b/backend/internal/service/ops_alert_service_test.go @@ -0,0 +1,315 @@ +//go:build unit || opsalert_unit + +package service + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSelectContiguousMetrics_Contiguous(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + metrics := []OpsMetrics{ + {UpdatedAt: now}, + {UpdatedAt: now.Add(-1 * time.Minute)}, + {UpdatedAt: now.Add(-2 * time.Minute)}, + } + + selected, ok := selectContiguousMetrics(metrics, 3, now) + require.True(t, ok) + require.Len(t, selected, 3) +} + +func TestSelectContiguousMetrics_GapFails(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + metrics := []OpsMetrics{ + {UpdatedAt: now}, + // Missing the -1m sample (gap ~=2m). + {UpdatedAt: now.Add(-2 * time.Minute)}, + {UpdatedAt: now.Add(-3 * time.Minute)}, + } + + _, ok := selectContiguousMetrics(metrics, 3, now) + require.False(t, ok) +} + +func TestSelectContiguousMetrics_StaleNewestFails(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 10, 0, 0, time.UTC) + metrics := []OpsMetrics{ + {UpdatedAt: now.Add(-10 * time.Minute)}, + {UpdatedAt: now.Add(-11 * time.Minute)}, + } + + _, ok := selectContiguousMetrics(metrics, 2, now) + require.False(t, ok) +} + +func TestMetricValue_SuccessRate_NoTrafficIsNoData(t *testing.T) { + metric := OpsMetrics{ + RequestCount: 0, + SuccessRate: 0, + } + value, ok := metricValue(metric, OpsMetricSuccessRate) + require.False(t, ok) + require.Equal(t, 0.0, value) +} + +func TestOpsAlertService_StopWithoutStart_NoPanic(t *testing.T) { + s := NewOpsAlertService(nil, nil, nil) + require.NotPanics(t, func() { s.Stop() }) +} + +func TestOpsAlertService_StartStop_Graceful(t *testing.T) { + s := NewOpsAlertService(nil, nil, nil) + s.interval = 5 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s.StartWithContext(ctx) + + done := make(chan struct{}) + go func() { + s.Stop() + close(done) + }() + + select { + case <-done: + // ok + case <-time.After(1 * time.Second): + t.Fatal("Stop did not return; background goroutine likely stuck") + } + + require.NotPanics(t, func() { s.Stop() }) +} + +func TestBuildWebhookHTTPClient_DefaultTimeout(t *testing.T) { + client := buildWebhookHTTPClient(nil, nil) + require.Equal(t, webhookHTTPClientTimeout, client.Timeout) + require.NotNil(t, client.CheckRedirect) + require.ErrorIs(t, client.CheckRedirect(nil, nil), http.ErrUseLastResponse) + + base := &http.Client{} + client = buildWebhookHTTPClient(base, nil) + require.Equal(t, webhookHTTPClientTimeout, client.Timeout) + require.NotNil(t, client.CheckRedirect) + + base = &http.Client{Timeout: 2 * time.Second} + client = buildWebhookHTTPClient(base, nil) + require.Equal(t, 2*time.Second, client.Timeout) + require.NotNil(t, client.CheckRedirect) +} + +func TestValidateWebhookURL_RequiresHTTPS(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + + _, err := validateWebhookURL(context.Background(), "http://example.com/webhook") + require.Error(t, err) +} + +func TestValidateWebhookURL_InvalidFormatRejected(t *testing.T) { + _, err := validateWebhookURL(context.Background(), "https://[::1") + require.Error(t, err) +} + +func TestValidateWebhookURL_RejectsUserinfo(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + + _, err := validateWebhookURL(context.Background(), "https://user:pass@example.com/webhook") + require.Error(t, err) +} + +func TestValidateWebhookURL_RejectsLocalhost(t *testing.T) { + _, err := validateWebhookURL(context.Background(), "https://localhost/webhook") + require.Error(t, err) +} + +func TestValidateWebhookURL_RejectsPrivateIPLiteral(t *testing.T) { + cases := []string{ + "https://0.0.0.0/webhook", + "https://127.0.0.1/webhook", + "https://10.0.0.1/webhook", + "https://192.168.1.2/webhook", + "https://172.16.0.1/webhook", + "https://172.31.255.255/webhook", + "https://100.64.0.1/webhook", + "https://169.254.169.254/webhook", + "https://198.18.0.1/webhook", + "https://224.0.0.1/webhook", + "https://240.0.0.1/webhook", + "https://[::]/webhook", + "https://[::1]/webhook", + "https://[ff02::1]/webhook", + } + for _, tc := range cases { + t.Run(tc, func(t *testing.T) { + _, err := validateWebhookURL(context.Background(), tc) + require.Error(t, err) + }) + } +} + +func TestValidateWebhookURL_RejectsPrivateIPViaDNS(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + require.Equal(t, "internal.example", host) + return []net.IPAddr{{IP: net.ParseIP("10.0.0.2")}}, nil + } + + _, err := validateWebhookURL(context.Background(), "https://internal.example/webhook") + require.Error(t, err) +} + +func TestValidateWebhookURL_RejectsLinkLocalIPViaDNS(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + require.Equal(t, "metadata.example", host) + return []net.IPAddr{{IP: net.ParseIP("169.254.169.254")}}, nil + } + + _, err := validateWebhookURL(context.Background(), "https://metadata.example/webhook") + require.Error(t, err) +} + +func TestValidateWebhookURL_AllowsPublicHostViaDNS(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + require.Equal(t, "example.com", host) + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + + target, err := validateWebhookURL(context.Background(), "https://example.com:443/webhook") + require.NoError(t, err) + require.Equal(t, "https", target.URL.Scheme) + require.Equal(t, "example.com", target.URL.Hostname()) + require.Equal(t, "443", target.URL.Port()) +} + +func TestValidateWebhookURL_RejectsInvalidPort(t *testing.T) { + oldLookup := lookupIPAddrs + t.Cleanup(func() { lookupIPAddrs = oldLookup }) + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + + _, err := validateWebhookURL(context.Background(), "https://example.com:99999/webhook") + require.Error(t, err) +} + +func TestWebhookTransport_UsesPinnedIP_NoDNSRebinding(t *testing.T) { + oldLookup := lookupIPAddrs + oldDial := webhookBaseDialContext + t.Cleanup(func() { + lookupIPAddrs = oldLookup + webhookBaseDialContext = oldDial + }) + + lookupCalls := 0 + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + lookupCalls++ + require.Equal(t, "example.com", host) + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + + target, err := validateWebhookURL(context.Background(), "https://example.com/webhook") + require.NoError(t, err) + require.Equal(t, 1, lookupCalls) + + lookupIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) { + lookupCalls++ + return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil + } + + var dialAddrs []string + webhookBaseDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + dialAddrs = append(dialAddrs, addr) + return nil, errors.New("dial blocked in test") + } + + client := buildWebhookHTTPClient(nil, target) + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + + _, err = transport.DialContext(context.Background(), "tcp", "example.com:443") + require.Error(t, err) + require.Equal(t, []string{"93.184.216.34:443"}, dialAddrs) + require.Equal(t, 1, lookupCalls, "dial path must not re-resolve DNS") +} + +func TestRetryWithBackoff_SucceedsAfterRetries(t *testing.T) { + oldSleep := opsAlertSleep + t.Cleanup(func() { opsAlertSleep = oldSleep }) + + var slept []time.Duration + opsAlertSleep = func(ctx context.Context, d time.Duration) error { + slept = append(slept, d) + return nil + } + + attempts := 0 + err := retryWithBackoff( + context.Background(), + 3, + []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, + func() error { + attempts++ + if attempts <= 3 { + return errors.New("send failed") + } + return nil + }, + nil, + ) + require.NoError(t, err) + require.Equal(t, 4, attempts) + require.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, slept) +} + +func TestRetryWithBackoff_ContextCanceledStopsRetries(t *testing.T) { + oldSleep := opsAlertSleep + t.Cleanup(func() { opsAlertSleep = oldSleep }) + + var slept []time.Duration + opsAlertSleep = func(ctx context.Context, d time.Duration) error { + slept = append(slept, d) + return ctx.Err() + } + + ctx, cancel := context.WithCancel(context.Background()) + attempts := 0 + err := retryWithBackoff( + ctx, + 3, + []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, + func() error { + attempts++ + return errors.New("send failed") + }, + func(attempt int, total int, nextDelay time.Duration, err error) { + if attempt == 1 { + cancel() + } + }, + ) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, attempts) + require.Equal(t, []time.Duration{time.Second}, slept) +} diff --git a/backend/internal/service/ops_alerts.go b/backend/internal/service/ops_alerts.go new file mode 100644 index 00000000..0a239864 --- /dev/null +++ b/backend/internal/service/ops_alerts.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "time" +) + +const ( + OpsAlertStatusFiring = "firing" + OpsAlertStatusResolved = "resolved" +) + +const ( + OpsMetricSuccessRate = "success_rate" + OpsMetricErrorRate = "error_rate" + OpsMetricP95LatencyMs = "p95_latency_ms" + OpsMetricP99LatencyMs = "p99_latency_ms" + OpsMetricHTTP2Errors = "http2_errors" + OpsMetricCPUUsagePercent = "cpu_usage_percent" + OpsMetricMemoryUsagePercent = "memory_usage_percent" + OpsMetricQueueDepth = "concurrency_queue_depth" +) + +type OpsAlertRule struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + MetricType string `json:"metric_type"` + Operator string `json:"operator"` + Threshold float64 `json:"threshold"` + WindowMinutes int `json:"window_minutes"` + SustainedMinutes int `json:"sustained_minutes"` + Severity string `json:"severity"` + NotifyEmail bool `json:"notify_email"` + NotifyWebhook bool `json:"notify_webhook"` + WebhookURL string `json:"webhook_url"` + CooldownMinutes int `json:"cooldown_minutes"` + DimensionFilters map[string]any `json:"dimension_filters,omitempty"` + NotifyChannels []string `json:"notify_channels,omitempty"` + NotifyConfig map[string]any `json:"notify_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OpsAlertEvent struct { + ID int64 `json:"id"` + RuleID int64 `json:"rule_id"` + Severity string `json:"severity"` + Status string `json:"status"` + Title string `json:"title"` + Description string `json:"description"` + MetricValue float64 `json:"metric_value"` + ThresholdValue float64 `json:"threshold_value"` + FiredAt time.Time `json:"fired_at"` + ResolvedAt *time.Time `json:"resolved_at"` + EmailSent bool `json:"email_sent"` + WebhookSent bool `json:"webhook_sent"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *OpsService) ListAlertRules(ctx context.Context) ([]OpsAlertRule, error) { + return s.repo.ListAlertRules(ctx) +} + +func (s *OpsService) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) { + return s.repo.GetActiveAlertEvent(ctx, ruleID) +} + +func (s *OpsService) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) { + return s.repo.GetLatestAlertEvent(ctx, ruleID) +} + +func (s *OpsService) CreateAlertEvent(ctx context.Context, event *OpsAlertEvent) error { + return s.repo.CreateAlertEvent(ctx, event) +} + +func (s *OpsService) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error { + return s.repo.UpdateAlertEventStatus(ctx, eventID, status, resolvedAt) +} + +func (s *OpsService) UpdateAlertEventNotifications(ctx context.Context, eventID int64, emailSent, webhookSent bool) error { + return s.repo.UpdateAlertEventNotifications(ctx, eventID, emailSent, webhookSent) +} + +func (s *OpsService) ListRecentSystemMetrics(ctx context.Context, windowMinutes, limit int) ([]OpsMetrics, error) { + return s.repo.ListRecentSystemMetrics(ctx, windowMinutes, limit) +} + +func (s *OpsService) CountActiveAlerts(ctx context.Context) (int, error) { + return s.repo.CountActiveAlerts(ctx) +} diff --git a/backend/internal/service/ops_metrics_collector.go b/backend/internal/service/ops_metrics_collector.go new file mode 100644 index 00000000..01bd4596 --- /dev/null +++ b/backend/internal/service/ops_metrics_collector.go @@ -0,0 +1,203 @@ +package service + +import ( + "context" + "log" + "runtime" + "sync" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/mem" +) + +const ( + opsMetricsInterval = 1 * time.Minute + opsMetricsCollectTimeout = 10 * time.Second + + opsMetricsWindowShortMinutes = 1 + opsMetricsWindowLongMinutes = 5 + + bytesPerMB = 1024 * 1024 + cpuUsageSampleInterval = 0 * time.Second + + percentScale = 100 +) + +type OpsMetricsCollector struct { + opsService *OpsService + concurrencyService *ConcurrencyService + interval time.Duration + lastGCPauseTotal uint64 + lastGCPauseMu sync.Mutex + stopCh chan struct{} + startOnce sync.Once + stopOnce sync.Once +} + +func NewOpsMetricsCollector(opsService *OpsService, concurrencyService *ConcurrencyService) *OpsMetricsCollector { + return &OpsMetricsCollector{ + opsService: opsService, + concurrencyService: concurrencyService, + interval: opsMetricsInterval, + } +} + +func (c *OpsMetricsCollector) Start() { + if c == nil { + return + } + c.startOnce.Do(func() { + if c.stopCh == nil { + c.stopCh = make(chan struct{}) + } + go c.run() + }) +} + +func (c *OpsMetricsCollector) Stop() { + if c == nil { + return + } + c.stopOnce.Do(func() { + if c.stopCh != nil { + close(c.stopCh) + } + }) +} + +func (c *OpsMetricsCollector) run() { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + c.collectOnce() + for { + select { + case <-ticker.C: + c.collectOnce() + case <-c.stopCh: + return + } + } +} + +func (c *OpsMetricsCollector) collectOnce() { + if c.opsService == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), opsMetricsCollectTimeout) + defer cancel() + + now := time.Now() + systemStats := c.collectSystemStats(ctx) + queueDepth := c.collectQueueDepth(ctx) + activeAlerts := c.collectActiveAlerts(ctx) + + for _, window := range []int{opsMetricsWindowShortMinutes, opsMetricsWindowLongMinutes} { + startTime := now.Add(-time.Duration(window) * time.Minute) + windowStats, err := c.opsService.GetWindowStats(ctx, startTime, now) + if err != nil { + log.Printf("[OpsMetrics] failed to get window stats (%dm): %v", window, err) + continue + } + + successRate, errorRate := computeRates(windowStats.SuccessCount, windowStats.ErrorCount) + requestCount := windowStats.SuccessCount + windowStats.ErrorCount + metric := &OpsMetrics{ + WindowMinutes: window, + RequestCount: requestCount, + SuccessCount: windowStats.SuccessCount, + ErrorCount: windowStats.ErrorCount, + SuccessRate: successRate, + ErrorRate: errorRate, + P95LatencyMs: windowStats.P95LatencyMs, + P99LatencyMs: windowStats.P99LatencyMs, + HTTP2Errors: windowStats.HTTP2Errors, + ActiveAlerts: activeAlerts, + CPUUsagePercent: systemStats.cpuUsage, + MemoryUsedMB: systemStats.memoryUsedMB, + MemoryTotalMB: systemStats.memoryTotalMB, + MemoryUsagePercent: systemStats.memoryUsagePercent, + HeapAllocMB: systemStats.heapAllocMB, + GCPauseMs: systemStats.gcPauseMs, + ConcurrencyQueueDepth: queueDepth, + UpdatedAt: now, + } + + if err := c.opsService.RecordMetrics(ctx, metric); err != nil { + log.Printf("[OpsMetrics] failed to record metrics (%dm): %v", window, err) + } + } + +} + +func computeRates(successCount, errorCount int64) (float64, float64) { + total := successCount + errorCount + if total == 0 { + // No traffic => no data. Rates are kept at 0 and request_count will be 0. + // The UI should render this as N/A instead of "100% success". + return 0, 0 + } + successRate := float64(successCount) / float64(total) * percentScale + errorRate := float64(errorCount) / float64(total) * percentScale + return successRate, errorRate +} + +type opsSystemStats struct { + cpuUsage float64 + memoryUsedMB int64 + memoryTotalMB int64 + memoryUsagePercent float64 + heapAllocMB int64 + gcPauseMs float64 +} + +func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) opsSystemStats { + stats := opsSystemStats{} + + if percents, err := cpu.PercentWithContext(ctx, cpuUsageSampleInterval, false); err == nil && len(percents) > 0 { + stats.cpuUsage = percents[0] + } + + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + stats.memoryUsedMB = int64(vm.Used / bytesPerMB) + stats.memoryTotalMB = int64(vm.Total / bytesPerMB) + stats.memoryUsagePercent = vm.UsedPercent + } + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + stats.heapAllocMB = int64(memStats.HeapAlloc / bytesPerMB) + c.lastGCPauseMu.Lock() + if c.lastGCPauseTotal != 0 && memStats.PauseTotalNs >= c.lastGCPauseTotal { + stats.gcPauseMs = float64(memStats.PauseTotalNs-c.lastGCPauseTotal) / float64(time.Millisecond) + } + c.lastGCPauseTotal = memStats.PauseTotalNs + c.lastGCPauseMu.Unlock() + + return stats +} + +func (c *OpsMetricsCollector) collectQueueDepth(ctx context.Context) int { + if c.concurrencyService == nil { + return 0 + } + depth, err := c.concurrencyService.GetTotalWaitCount(ctx) + if err != nil { + log.Printf("[OpsMetrics] failed to get queue depth: %v", err) + return 0 + } + return depth +} + +func (c *OpsMetricsCollector) collectActiveAlerts(ctx context.Context) int { + if c.opsService == nil { + return 0 + } + count, err := c.opsService.CountActiveAlerts(ctx) + if err != nil { + return 0 + } + return count +} diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go new file mode 100644 index 00000000..63a539d4 --- /dev/null +++ b/backend/internal/service/ops_service.go @@ -0,0 +1,1020 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "math" + "runtime" + "strings" + "sync" + "time" + + "github.com/shirou/gopsutil/v4/disk" +) + +type OpsMetrics struct { + WindowMinutes int `json:"window_minutes"` + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + ErrorCount int64 `json:"error_count"` + SuccessRate float64 `json:"success_rate"` + ErrorRate float64 `json:"error_rate"` + P95LatencyMs int `json:"p95_latency_ms"` + P99LatencyMs int `json:"p99_latency_ms"` + HTTP2Errors int `json:"http2_errors"` + ActiveAlerts int `json:"active_alerts"` + CPUUsagePercent float64 `json:"cpu_usage_percent"` + MemoryUsedMB int64 `json:"memory_used_mb"` + MemoryTotalMB int64 `json:"memory_total_mb"` + MemoryUsagePercent float64 `json:"memory_usage_percent"` + HeapAllocMB int64 `json:"heap_alloc_mb"` + GCPauseMs float64 `json:"gc_pause_ms"` + ConcurrencyQueueDepth int `json:"concurrency_queue_depth"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type OpsErrorLog struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at"` + Phase string `json:"phase"` + Type string `json:"type"` + Severity string `json:"severity"` + StatusCode int `json:"status_code"` + Platform string `json:"platform"` + Model string `json:"model"` + LatencyMs *int `json:"latency_ms"` + RequestID string `json:"request_id"` + Message string `json:"message"` + + UserID *int64 `json:"user_id,omitempty"` + APIKeyID *int64 `json:"api_key_id,omitempty"` + AccountID *int64 `json:"account_id,omitempty"` + GroupID *int64 `json:"group_id,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + RequestPath string `json:"request_path,omitempty"` + Stream bool `json:"stream"` +} + +type OpsErrorLogFilters struct { + StartTime *time.Time + EndTime *time.Time + Platform string + Phase string + Severity string + Query string + Limit int +} + +type OpsWindowStats struct { + SuccessCount int64 + ErrorCount int64 + P95LatencyMs int + P99LatencyMs int + HTTP2Errors int +} + +type ProviderStats struct { + Platform string + + RequestCount int64 + SuccessCount int64 + ErrorCount int64 + + AvgLatencyMs int + P99LatencyMs int + + Error4xxCount int64 + Error5xxCount int64 + TimeoutCount int64 +} + +type ProviderHealthErrorsByType struct { + HTTP4xx int64 `json:"4xx"` + HTTP5xx int64 `json:"5xx"` + Timeout int64 `json:"timeout"` +} + +type ProviderHealthData struct { + Name string `json:"name"` + RequestCount int64 `json:"request_count"` + SuccessRate float64 `json:"success_rate"` + ErrorRate float64 `json:"error_rate"` + LatencyAvg int `json:"latency_avg"` + LatencyP99 int `json:"latency_p99"` + Status string `json:"status"` + ErrorsByType ProviderHealthErrorsByType `json:"errors_by_type"` +} + +type LatencyHistogramItem struct { + Range string `json:"range"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` +} + +type ErrorDistributionItem struct { + Code string `json:"code"` + Message string `json:"message"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` +} + +type OpsRepository interface { + CreateErrorLog(ctx context.Context, log *OpsErrorLog) error + // ListErrorLogsLegacy keeps the original non-paginated query API used by the + // existing /api/v1/admin/ops/error-logs endpoint (limit is capped at 500; for + // stable pagination use /api/v1/admin/ops/errors). + ListErrorLogsLegacy(ctx context.Context, filters OpsErrorLogFilters) ([]OpsErrorLog, error) + + // ListErrorLogs provides a paginated error-log query API (with total count). + ListErrorLogs(ctx context.Context, filter *ErrorLogFilter) ([]*ErrorLog, int64, error) + GetLatestSystemMetric(ctx context.Context) (*OpsMetrics, error) + CreateSystemMetric(ctx context.Context, metric *OpsMetrics) error + GetWindowStats(ctx context.Context, startTime, endTime time.Time) (*OpsWindowStats, error) + GetProviderStats(ctx context.Context, startTime, endTime time.Time) ([]*ProviderStats, error) + GetLatencyHistogram(ctx context.Context, startTime, endTime time.Time) ([]*LatencyHistogramItem, error) + GetErrorDistribution(ctx context.Context, startTime, endTime time.Time) ([]*ErrorDistributionItem, error) + ListRecentSystemMetrics(ctx context.Context, windowMinutes, limit int) ([]OpsMetrics, error) + ListSystemMetricsRange(ctx context.Context, windowMinutes int, startTime, endTime time.Time, limit int) ([]OpsMetrics, error) + ListAlertRules(ctx context.Context) ([]OpsAlertRule, error) + GetActiveAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) + GetLatestAlertEvent(ctx context.Context, ruleID int64) (*OpsAlertEvent, error) + CreateAlertEvent(ctx context.Context, event *OpsAlertEvent) error + UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error + UpdateAlertEventNotifications(ctx context.Context, eventID int64, emailSent, webhookSent bool) error + CountActiveAlerts(ctx context.Context) (int, error) + GetOverviewStats(ctx context.Context, startTime, endTime time.Time) (*OverviewStats, error) + + // Redis-backed cache/health (best-effort; implementation lives in repository layer). + GetCachedLatestSystemMetric(ctx context.Context) (*OpsMetrics, error) + SetCachedLatestSystemMetric(ctx context.Context, metric *OpsMetrics) error + GetCachedDashboardOverview(ctx context.Context, timeRange string) (*DashboardOverviewData, error) + SetCachedDashboardOverview(ctx context.Context, timeRange string, data *DashboardOverviewData, ttl time.Duration) error + PingRedis(ctx context.Context) error +} + +type OpsService struct { + repo OpsRepository + sqlDB *sql.DB + + redisNilWarnOnce sync.Once + dbNilWarnOnce sync.Once +} + +const opsDBQueryTimeout = 5 * time.Second + +func NewOpsService(repo OpsRepository, sqlDB *sql.DB) *OpsService { + svc := &OpsService{repo: repo, sqlDB: sqlDB} + + // Best-effort startup health checks: log warnings if Redis/DB is unavailable, + // but never fail service startup (graceful degradation). + log.Printf("[OpsService] Performing startup health checks...") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + redisStatus := svc.checkRedisHealth(ctx) + dbStatus := svc.checkDatabaseHealth(ctx) + + log.Printf("[OpsService] Startup health check complete: Redis=%s, Database=%s", redisStatus, dbStatus) + if redisStatus == "critical" || dbStatus == "critical" { + log.Printf("[OpsService][WARN] Service starting with degraded dependencies - some features may be unavailable") + } + + return svc +} + +func (s *OpsService) RecordError(ctx context.Context, log *OpsErrorLog) error { + if log == nil { + return nil + } + if log.CreatedAt.IsZero() { + log.CreatedAt = time.Now() + } + if log.Severity == "" { + log.Severity = "P2" + } + if log.Phase == "" { + log.Phase = "internal" + } + if log.Type == "" { + log.Type = "unknown_error" + } + if log.Message == "" { + log.Message = "Unknown error" + } + + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + return s.repo.CreateErrorLog(ctxDB, log) +} + +func (s *OpsService) RecordMetrics(ctx context.Context, metric *OpsMetrics) error { + if metric == nil { + return nil + } + if metric.UpdatedAt.IsZero() { + metric.UpdatedAt = time.Now() + } + + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + if err := s.repo.CreateSystemMetric(ctxDB, metric); err != nil { + return err + } + + // Latest metrics snapshot is queried frequently by the ops dashboard; keep a short-lived cache + // to avoid unnecessary DB pressure. Only cache the default (1-minute) window metrics. + windowMinutes := metric.WindowMinutes + if windowMinutes == 0 { + windowMinutes = 1 + } + if windowMinutes == 1 { + if repo := s.repo; repo != nil { + _ = repo.SetCachedLatestSystemMetric(ctx, metric) + } + } + return nil +} + +func (s *OpsService) ListErrorLogs(ctx context.Context, filters OpsErrorLogFilters) ([]OpsErrorLog, int, error) { + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + logs, err := s.repo.ListErrorLogsLegacy(ctxDB, filters) + if err != nil { + return nil, 0, err + } + return logs, len(logs), nil +} + +func (s *OpsService) GetWindowStats(ctx context.Context, startTime, endTime time.Time) (*OpsWindowStats, error) { + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + return s.repo.GetWindowStats(ctxDB, startTime, endTime) +} + +func (s *OpsService) GetLatestMetrics(ctx context.Context) (*OpsMetrics, error) { + // Cache first (best-effort): cache errors should not break the dashboard. + if s != nil { + if repo := s.repo; repo != nil { + if cached, err := repo.GetCachedLatestSystemMetric(ctx); err == nil && cached != nil { + if cached.WindowMinutes == 0 { + cached.WindowMinutes = 1 + } + return cached, nil + } + } + } + + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + metric, err := s.repo.GetLatestSystemMetric(ctxDB) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &OpsMetrics{WindowMinutes: 1}, nil + } + return nil, err + } + if metric == nil { + return &OpsMetrics{WindowMinutes: 1}, nil + } + if metric.WindowMinutes == 0 { + metric.WindowMinutes = 1 + } + + // Backfill cache (best-effort). + if s != nil { + if repo := s.repo; repo != nil { + _ = repo.SetCachedLatestSystemMetric(ctx, metric) + } + } + return metric, nil +} + +func (s *OpsService) ListMetricsHistory(ctx context.Context, windowMinutes int, startTime, endTime time.Time, limit int) ([]OpsMetrics, error) { + if s == nil || s.repo == nil { + return nil, nil + } + if windowMinutes <= 0 { + windowMinutes = 1 + } + if limit <= 0 || limit > 5000 { + limit = 300 + } + if endTime.IsZero() { + endTime = time.Now() + } + if startTime.IsZero() { + startTime = endTime.Add(-time.Duration(limit) * opsMetricsInterval) + } + if startTime.After(endTime) { + startTime, endTime = endTime, startTime + } + + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + return s.repo.ListSystemMetricsRange(ctxDB, windowMinutes, startTime, endTime, limit) +} + +// DashboardOverviewData represents aggregated metrics for the ops dashboard overview. +type DashboardOverviewData struct { + Timestamp time.Time `json:"timestamp"` + HealthScore int `json:"health_score"` + SLA SLAData `json:"sla"` + QPS QPSData `json:"qps"` + TPS TPSData `json:"tps"` + Latency LatencyData `json:"latency"` + Errors ErrorData `json:"errors"` + Resources ResourceData `json:"resources"` + SystemStatus SystemStatusData `json:"system_status"` +} + +type SLAData struct { + Current float64 `json:"current"` + Threshold float64 `json:"threshold"` + Status string `json:"status"` + Trend string `json:"trend"` + Change24h float64 `json:"change_24h"` +} + +type QPSData struct { + Current float64 `json:"current"` + Peak1h float64 `json:"peak_1h"` + Avg1h float64 `json:"avg_1h"` + ChangeVsYesterday float64 `json:"change_vs_yesterday"` +} + +type TPSData struct { + Current float64 `json:"current"` + Peak1h float64 `json:"peak_1h"` + Avg1h float64 `json:"avg_1h"` +} + +type LatencyData struct { + P50 int `json:"p50"` + P95 int `json:"p95"` + P99 int `json:"p99"` + P999 int `json:"p999"` + Avg int `json:"avg"` + Max int `json:"max"` + ThresholdP99 int `json:"threshold_p99"` + Status string `json:"status"` +} + +type ErrorData struct { + TotalCount int64 `json:"total_count"` + ErrorRate float64 `json:"error_rate"` + Count4xx int64 `json:"4xx_count"` + Count5xx int64 `json:"5xx_count"` + TimeoutCount int64 `json:"timeout_count"` + TopError *TopError `json:"top_error,omitempty"` +} + +type TopError struct { + Code string `json:"code"` + Message string `json:"message"` + Count int64 `json:"count"` +} + +type ResourceData struct { + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Goroutines int `json:"goroutines"` + DBConnections DBConnectionsData `json:"db_connections"` +} + +type DBConnectionsData struct { + Active int `json:"active"` + Idle int `json:"idle"` + Waiting int `json:"waiting"` + Max int `json:"max"` +} + +type SystemStatusData struct { + Redis string `json:"redis"` + Database string `json:"database"` + BackgroundJobs string `json:"background_jobs"` +} + +type OverviewStats struct { + RequestCount int64 + SuccessCount int64 + ErrorCount int64 + Error4xxCount int64 + Error5xxCount int64 + TimeoutCount int64 + LatencyP50 int + LatencyP95 int + LatencyP99 int + LatencyP999 int + LatencyAvg int + LatencyMax int + TopErrorCode string + TopErrorMsg string + TopErrorCount int64 + CPUUsage float64 + MemoryUsage float64 + MemoryUsedMB int64 + MemoryTotalMB int64 + ConcurrencyQueueDepth int +} + +func (s *OpsService) GetDashboardOverview(ctx context.Context, timeRange string) (*DashboardOverviewData, error) { + if s == nil { + return nil, errors.New("ops service not initialized") + } + repo := s.repo + if repo == nil { + return nil, errors.New("ops repository not initialized") + } + if s.sqlDB == nil { + return nil, errors.New("ops service not initialized") + } + if strings.TrimSpace(timeRange) == "" { + timeRange = "1h" + } + + duration, err := parseTimeRange(timeRange) + if err != nil { + return nil, err + } + + if cached, err := repo.GetCachedDashboardOverview(ctx, timeRange); err == nil && cached != nil { + return cached, nil + } + + now := time.Now().UTC() + startTime := now.Add(-duration) + + ctxStats, cancelStats := context.WithTimeout(ctx, opsDBQueryTimeout) + stats, err := repo.GetOverviewStats(ctxStats, startTime, now) + cancelStats() + if err != nil { + return nil, fmt.Errorf("get overview stats: %w", err) + } + if stats == nil { + return nil, errors.New("get overview stats returned nil") + } + + var statsYesterday *OverviewStats + { + yesterdayEnd := now.Add(-24 * time.Hour) + yesterdayStart := yesterdayEnd.Add(-duration) + ctxYesterday, cancelYesterday := context.WithTimeout(ctx, opsDBQueryTimeout) + ys, err := repo.GetOverviewStats(ctxYesterday, yesterdayStart, yesterdayEnd) + cancelYesterday() + if err != nil { + // Best-effort: overview should still work when historical comparison fails. + log.Printf("[OpsOverview] get yesterday overview stats failed: %v", err) + } else { + statsYesterday = ys + } + } + + totalReqs := stats.SuccessCount + stats.ErrorCount + successRate, errorRate := calculateRates(stats.SuccessCount, stats.ErrorCount, totalReqs) + + successRateYesterday := 0.0 + totalReqsYesterday := int64(0) + if statsYesterday != nil { + totalReqsYesterday = statsYesterday.SuccessCount + statsYesterday.ErrorCount + successRateYesterday, _ = calculateRates(statsYesterday.SuccessCount, statsYesterday.ErrorCount, totalReqsYesterday) + } + + slaThreshold := 99.9 + slaChange24h := roundTo2DP(successRate - successRateYesterday) + slaTrend := classifyTrend(slaChange24h, 0.05) + slaStatus := classifySLAStatus(successRate, slaThreshold) + + latencyThresholdP99 := 1000 + latencyStatus := classifyLatencyStatus(stats.LatencyP99, latencyThresholdP99) + + qpsCurrent := 0.0 + { + ctxWindow, cancelWindow := context.WithTimeout(ctx, opsDBQueryTimeout) + windowStats, err := repo.GetWindowStats(ctxWindow, now.Add(-1*time.Minute), now) + cancelWindow() + if err == nil && windowStats != nil { + qpsCurrent = roundTo1DP(float64(windowStats.SuccessCount+windowStats.ErrorCount) / 60) + } else if err != nil { + log.Printf("[OpsOverview] get realtime qps failed: %v", err) + } + } + + qpsAvg := roundTo1DP(safeDivide(float64(totalReqs), duration.Seconds())) + qpsPeak := qpsAvg + { + limit := int(duration.Minutes()) + 5 + if limit < 10 { + limit = 10 + } + if limit > 5000 { + limit = 5000 + } + ctxMetrics, cancelMetrics := context.WithTimeout(ctx, opsDBQueryTimeout) + items, err := repo.ListSystemMetricsRange(ctxMetrics, 1, startTime, now, limit) + cancelMetrics() + if err != nil { + log.Printf("[OpsOverview] get metrics range for peak qps failed: %v", err) + } else { + maxQPS := 0.0 + for _, item := range items { + v := float64(item.RequestCount) / 60 + if v > maxQPS { + maxQPS = v + } + } + if maxQPS > 0 { + qpsPeak = roundTo1DP(maxQPS) + } + } + } + + qpsAvgYesterday := 0.0 + if duration.Seconds() > 0 && totalReqsYesterday > 0 { + qpsAvgYesterday = float64(totalReqsYesterday) / duration.Seconds() + } + qpsChangeVsYesterday := roundTo1DP(percentChange(qpsAvgYesterday, float64(totalReqs)/duration.Seconds())) + + tpsCurrent, tpsPeak, tpsAvg := 0.0, 0.0, 0.0 + if current, peak, avg, err := s.getTokenTPS(ctx, now, startTime, duration); err != nil { + log.Printf("[OpsOverview] get token tps failed: %v", err) + } else { + tpsCurrent, tpsPeak, tpsAvg = roundTo1DP(current), roundTo1DP(peak), roundTo1DP(avg) + } + + diskUsage := 0.0 + if v, err := getDiskUsagePercent(ctx, "/"); err != nil { + log.Printf("[OpsOverview] get disk usage failed: %v", err) + } else { + diskUsage = roundTo1DP(v) + } + + redisStatus := s.checkRedisHealth(ctx) + dbStatus := s.checkDatabaseHealth(ctx) + healthScore := calculateHealthScore(successRate, stats.LatencyP99, errorRate, redisStatus, dbStatus) + + data := &DashboardOverviewData{ + Timestamp: now, + HealthScore: healthScore, + SLA: SLAData{ + Current: successRate, + Threshold: slaThreshold, + Status: slaStatus, + Trend: slaTrend, + Change24h: slaChange24h, + }, + QPS: QPSData{ + Current: qpsCurrent, + Peak1h: qpsPeak, + Avg1h: qpsAvg, + ChangeVsYesterday: qpsChangeVsYesterday, + }, + TPS: TPSData{ + Current: tpsCurrent, + Peak1h: tpsPeak, + Avg1h: tpsAvg, + }, + Latency: LatencyData{ + P50: stats.LatencyP50, + P95: stats.LatencyP95, + P99: stats.LatencyP99, + P999: stats.LatencyP999, + Avg: stats.LatencyAvg, + Max: stats.LatencyMax, + ThresholdP99: latencyThresholdP99, + Status: latencyStatus, + }, + Errors: ErrorData{ + TotalCount: stats.ErrorCount, + ErrorRate: errorRate, + Count4xx: stats.Error4xxCount, + Count5xx: stats.Error5xxCount, + TimeoutCount: stats.TimeoutCount, + }, + Resources: ResourceData{ + CPUUsage: roundTo1DP(stats.CPUUsage), + MemoryUsage: roundTo1DP(stats.MemoryUsage), + DiskUsage: diskUsage, + Goroutines: runtime.NumGoroutine(), + DBConnections: s.getDBConnections(), + }, + SystemStatus: SystemStatusData{ + Redis: redisStatus, + Database: dbStatus, + BackgroundJobs: "healthy", + }, + } + + if stats.TopErrorCount > 0 { + data.Errors.TopError = &TopError{ + Code: stats.TopErrorCode, + Message: stats.TopErrorMsg, + Count: stats.TopErrorCount, + } + } + + _ = repo.SetCachedDashboardOverview(ctx, timeRange, data, 10*time.Second) + + return data, nil +} + +func (s *OpsService) GetProviderHealth(ctx context.Context, timeRange string) ([]*ProviderHealthData, error) { + if s == nil || s.repo == nil { + return nil, nil + } + + if strings.TrimSpace(timeRange) == "" { + timeRange = "1h" + } + window, err := parseTimeRange(timeRange) + if err != nil { + return nil, err + } + + endTime := time.Now() + startTime := endTime.Add(-window) + + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + stats, err := s.repo.GetProviderStats(ctxDB, startTime, endTime) + cancel() + if err != nil { + return nil, err + } + + results := make([]*ProviderHealthData, 0, len(stats)) + for _, item := range stats { + if item == nil { + continue + } + + successRate, errorRate := calculateRates(item.SuccessCount, item.ErrorCount, item.RequestCount) + + results = append(results, &ProviderHealthData{ + Name: formatPlatformName(item.Platform), + RequestCount: item.RequestCount, + SuccessRate: successRate, + ErrorRate: errorRate, + LatencyAvg: item.AvgLatencyMs, + LatencyP99: item.P99LatencyMs, + Status: classifyProviderStatus(successRate, item.P99LatencyMs, item.TimeoutCount, item.RequestCount), + ErrorsByType: ProviderHealthErrorsByType{ + HTTP4xx: item.Error4xxCount, + HTTP5xx: item.Error5xxCount, + Timeout: item.TimeoutCount, + }, + }) + } + + return results, nil +} + +func (s *OpsService) GetLatencyHistogram(ctx context.Context, timeRange string) ([]*LatencyHistogramItem, error) { + if s == nil || s.repo == nil { + return nil, nil + } + duration, err := parseTimeRange(timeRange) + if err != nil { + return nil, err + } + endTime := time.Now() + startTime := endTime.Add(-duration) + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + return s.repo.GetLatencyHistogram(ctxDB, startTime, endTime) +} + +func (s *OpsService) GetErrorDistribution(ctx context.Context, timeRange string) ([]*ErrorDistributionItem, error) { + if s == nil || s.repo == nil { + return nil, nil + } + duration, err := parseTimeRange(timeRange) + if err != nil { + return nil, err + } + endTime := time.Now() + startTime := endTime.Add(-duration) + ctxDB, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + defer cancel() + return s.repo.GetErrorDistribution(ctxDB, startTime, endTime) +} + +func parseTimeRange(timeRange string) (time.Duration, error) { + value := strings.TrimSpace(timeRange) + if value == "" { + return 0, errors.New("invalid time range") + } + + // Support "7d" style day ranges for convenience. + if strings.HasSuffix(value, "d") { + numberPart := strings.TrimSuffix(value, "d") + if numberPart == "" { + return 0, errors.New("invalid time range") + } + days := 0 + for _, ch := range numberPart { + if ch < '0' || ch > '9' { + return 0, errors.New("invalid time range") + } + days = days*10 + int(ch-'0') + } + if days <= 0 { + return 0, errors.New("invalid time range") + } + return time.Duration(days) * 24 * time.Hour, nil + } + + dur, err := time.ParseDuration(value) + if err != nil || dur <= 0 { + return 0, errors.New("invalid time range") + } + + // Cap to avoid unbounded queries. + const maxWindow = 30 * 24 * time.Hour + if dur > maxWindow { + dur = maxWindow + } + + return dur, nil +} + +func calculateHealthScore(successRate float64, p99Latency int, errorRate float64, redisStatus, dbStatus string) int { + score := 100.0 + + // SLA impact (max -45 points) + if successRate < 99.9 { + score -= math.Min(45, (99.9-successRate)*12) + } + + // Latency impact (max -35 points) + if p99Latency > 1000 { + score -= math.Min(35, float64(p99Latency-1000)/80) + } + + // Error rate impact (max -20 points) + if errorRate > 0.1 { + score -= math.Min(20, (errorRate-0.1)*60) + } + + // Infra status impact + if redisStatus != "healthy" { + score -= 15 + } + if dbStatus != "healthy" { + score -= 20 + } + + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + + return int(math.Round(score)) +} + +func calculateRates(successCount, errorCount, requestCount int64) (successRate float64, errorRate float64) { + if requestCount <= 0 { + return 0, 0 + } + successRate = (float64(successCount) / float64(requestCount)) * 100 + errorRate = (float64(errorCount) / float64(requestCount)) * 100 + return roundTo2DP(successRate), roundTo2DP(errorRate) +} + +func roundTo2DP(v float64) float64 { + return math.Round(v*100) / 100 +} + +func roundTo1DP(v float64) float64 { + return math.Round(v*10) / 10 +} + +func safeDivide(numerator float64, denominator float64) float64 { + if denominator <= 0 { + return 0 + } + return numerator / denominator +} + +func percentChange(previous float64, current float64) float64 { + if previous == 0 { + if current > 0 { + return 100.0 + } + return 0 + } + return (current - previous) / previous * 100 +} + +func classifyTrend(delta float64, deadband float64) string { + if delta > deadband { + return "up" + } + if delta < -deadband { + return "down" + } + return "stable" +} + +func classifySLAStatus(successRate float64, threshold float64) string { + if successRate >= threshold { + return "healthy" + } + if successRate >= threshold-0.5 { + return "warning" + } + return "critical" +} + +func classifyLatencyStatus(p99LatencyMs int, thresholdP99 int) string { + if thresholdP99 <= 0 { + return "healthy" + } + if p99LatencyMs <= thresholdP99 { + return "healthy" + } + if p99LatencyMs <= thresholdP99*2 { + return "warning" + } + return "critical" +} + +func getDiskUsagePercent(ctx context.Context, path string) (float64, error) { + usage, err := disk.UsageWithContext(ctx, path) + if err != nil { + return 0, err + } + if usage == nil { + return 0, nil + } + return usage.UsedPercent, nil +} + +func (s *OpsService) checkRedisHealth(ctx context.Context) string { + if s == nil { + log.Printf("[OpsOverview][WARN] ops service is nil; redis health check skipped") + return "critical" + } + if s.repo == nil { + s.redisNilWarnOnce.Do(func() { + log.Printf("[OpsOverview][WARN] ops repository is nil; redis health check skipped") + }) + return "critical" + } + + ctxPing, cancel := context.WithTimeout(ctx, 800*time.Millisecond) + defer cancel() + + if err := s.repo.PingRedis(ctxPing); err != nil { + log.Printf("[OpsOverview][WARN] redis ping failed: %v", err) + return "critical" + } + return "healthy" +} + +func (s *OpsService) checkDatabaseHealth(ctx context.Context) string { + if s == nil { + log.Printf("[OpsOverview][WARN] ops service is nil; db health check skipped") + return "critical" + } + if s.sqlDB == nil { + s.dbNilWarnOnce.Do(func() { + log.Printf("[OpsOverview][WARN] database is nil; db health check skipped") + }) + return "critical" + } + + ctxPing, cancel := context.WithTimeout(ctx, 800*time.Millisecond) + defer cancel() + + if err := s.sqlDB.PingContext(ctxPing); err != nil { + log.Printf("[OpsOverview][WARN] db ping failed: %v", err) + return "critical" + } + return "healthy" +} + +func (s *OpsService) getDBConnections() DBConnectionsData { + if s == nil || s.sqlDB == nil { + return DBConnectionsData{} + } + + stats := s.sqlDB.Stats() + maxOpen := stats.MaxOpenConnections + if maxOpen < 0 { + maxOpen = 0 + } + + return DBConnectionsData{ + Active: stats.InUse, + Idle: stats.Idle, + Waiting: 0, + Max: maxOpen, + } +} + +func (s *OpsService) getTokenTPS(ctx context.Context, endTime time.Time, startTime time.Time, duration time.Duration) (current float64, peak float64, avg float64, err error) { + if s == nil || s.sqlDB == nil { + return 0, 0, 0, nil + } + + if duration <= 0 { + return 0, 0, 0, nil + } + + // Current TPS: last 1 minute. + var tokensLastMinute int64 + { + lastMinuteStart := endTime.Add(-1 * time.Minute) + ctxQuery, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + row := s.sqlDB.QueryRowContext(ctxQuery, ` + SELECT COALESCE(SUM(input_tokens + output_tokens), 0) + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + `, lastMinuteStart, endTime) + scanErr := row.Scan(&tokensLastMinute) + cancel() + if scanErr != nil { + return 0, 0, 0, scanErr + } + } + + var totalTokens int64 + var maxTokensPerMinute int64 + { + ctxQuery, cancel := context.WithTimeout(ctx, opsDBQueryTimeout) + row := s.sqlDB.QueryRowContext(ctxQuery, ` + WITH buckets AS ( + SELECT + date_trunc('minute', created_at) AS bucket, + SUM(input_tokens + output_tokens) AS tokens + FROM usage_logs + WHERE created_at >= $1 AND created_at < $2 + GROUP BY 1 + ) + SELECT + COALESCE(SUM(tokens), 0) AS total_tokens, + COALESCE(MAX(tokens), 0) AS max_tokens_per_minute + FROM buckets + `, startTime, endTime) + scanErr := row.Scan(&totalTokens, &maxTokensPerMinute) + cancel() + if scanErr != nil { + return 0, 0, 0, scanErr + } + } + + current = safeDivide(float64(tokensLastMinute), 60) + peak = safeDivide(float64(maxTokensPerMinute), 60) + avg = safeDivide(float64(totalTokens), duration.Seconds()) + return current, peak, avg, nil +} + +func formatPlatformName(platform string) string { + switch strings.ToLower(strings.TrimSpace(platform)) { + case PlatformOpenAI: + return "OpenAI" + case PlatformAnthropic: + return "Anthropic" + case PlatformGemini: + return "Gemini" + case PlatformAntigravity: + return "Antigravity" + default: + if platform == "" { + return "Unknown" + } + if len(platform) == 1 { + return strings.ToUpper(platform) + } + return strings.ToUpper(platform[:1]) + platform[1:] + } +} + +func classifyProviderStatus(successRate float64, p99LatencyMs int, timeoutCount int64, requestCount int64) string { + if requestCount <= 0 { + return "healthy" + } + + if successRate < 98 { + return "critical" + } + if successRate < 99.5 { + return "warning" + } + + // Heavy timeout volume should be highlighted even if the overall success rate is okay. + if timeoutCount >= 10 && requestCount >= 100 { + return "warning" + } + + if p99LatencyMs > 0 && p99LatencyMs >= 5000 { + return "warning" + } + + return "healthy" +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index b5786ece..4c993871 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -61,9 +61,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeySiteName, SettingKeySiteLogo, SettingKeySiteSubtitle, - SettingKeyApiBaseUrl, + SettingKeyAPIBaseURL, SettingKeyContactInfo, - SettingKeyDocUrl, + SettingKeyDocURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -79,9 +79,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - ApiBaseUrl: settings[SettingKeyApiBaseUrl], + APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], - DocUrl: settings[SettingKeyDocUrl], + DocURL: settings[SettingKeyDocURL], }, nil } @@ -94,15 +94,15 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) // 邮件服务设置(只有非空才更新密码) - updates[SettingKeySmtpHost] = settings.SmtpHost - updates[SettingKeySmtpPort] = strconv.Itoa(settings.SmtpPort) - updates[SettingKeySmtpUsername] = settings.SmtpUsername - if settings.SmtpPassword != "" { - updates[SettingKeySmtpPassword] = settings.SmtpPassword + updates[SettingKeySMTPHost] = settings.SMTPHost + updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort) + updates[SettingKeySMTPUsername] = settings.SMTPUsername + if settings.SMTPPassword != "" { + updates[SettingKeySMTPPassword] = settings.SMTPPassword } - updates[SettingKeySmtpFrom] = settings.SmtpFrom - updates[SettingKeySmtpFromName] = settings.SmtpFromName - updates[SettingKeySmtpUseTLS] = strconv.FormatBool(settings.SmtpUseTLS) + updates[SettingKeySMTPFrom] = settings.SMTPFrom + updates[SettingKeySMTPFromName] = settings.SMTPFromName + updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS) // Cloudflare Turnstile 设置(只有非空才更新密钥) updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled) @@ -115,9 +115,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo updates[SettingKeySiteSubtitle] = settings.SiteSubtitle - updates[SettingKeyApiBaseUrl] = settings.ApiBaseUrl + updates[SettingKeyAPIBaseURL] = settings.APIBaseURL updates[SettingKeyContactInfo] = settings.ContactInfo - updates[SettingKeyDocUrl] = settings.DocUrl + updates[SettingKeyDocURL] = settings.DocURL // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -198,8 +198,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeySiteLogo: "", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), - SettingKeySmtpPort: "587", - SettingKeySmtpUseTLS: "false", + SettingKeySMTPPort: "587", + SettingKeySMTPUseTLS: "false", } return s.settingRepo.SetMultiple(ctx, defaults) @@ -210,26 +210,26 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", - SmtpHost: settings[SettingKeySmtpHost], - SmtpUsername: settings[SettingKeySmtpUsername], - SmtpFrom: settings[SettingKeySmtpFrom], - SmtpFromName: settings[SettingKeySmtpFromName], - SmtpUseTLS: settings[SettingKeySmtpUseTLS] == "true", + SMTPHost: settings[SettingKeySMTPHost], + SMTPUsername: settings[SettingKeySMTPUsername], + SMTPFrom: settings[SettingKeySMTPFrom], + SMTPFromName: settings[SettingKeySMTPFromName], + SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - ApiBaseUrl: settings[SettingKeyApiBaseUrl], + APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], - DocUrl: settings[SettingKeyDocUrl], + DocURL: settings[SettingKeyDocURL], } // 解析整数类型 - if port, err := strconv.Atoi(settings[SettingKeySmtpPort]); err == nil { - result.SmtpPort = port + if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil { + result.SMTPPort = port } else { - result.SmtpPort = 587 + result.SMTPPort = 587 } if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil { @@ -245,8 +245,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.DefaultBalance = s.cfg.Default.UserBalance } - // 敏感信息直接返回,方便测试连接时使用 - result.SmtpPassword = settings[SettingKeySmtpPassword] + // 敏感信息直接返回,方便测试连接时使用 + result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] return result @@ -278,28 +278,28 @@ func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { return value } -// GenerateAdminApiKey 生成新的管理员 API Key -func (s *SettingService) GenerateAdminApiKey(ctx context.Context) (string, error) { +// GenerateAdminAPIKey 生成新的管理员 API Key +func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { // 生成 32 字节随机数 = 64 位十六进制字符 bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("generate random bytes: %w", err) } - key := AdminApiKeyPrefix + hex.EncodeToString(bytes) + key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) // 存储到 settings 表 - if err := s.settingRepo.Set(ctx, SettingKeyAdminApiKey, key); err != nil { + if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { return "", fmt.Errorf("save admin api key: %w", err) } return key, nil } -// GetAdminApiKeyStatus 获取管理员 API Key 状态 +// GetAdminAPIKeyStatus 获取管理员 API Key 状态 // 返回脱敏的 key、是否存在、错误 -func (s *SettingService) GetAdminApiKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { - key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminApiKey) +func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) { + key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", false, nil @@ -320,10 +320,10 @@ func (s *SettingService) GetAdminApiKeyStatus(ctx context.Context) (maskedKey st return maskedKey, true, nil } -// GetAdminApiKey 获取完整的管理员 API Key(仅供内部验证使用) +// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用) // 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error -func (s *SettingService) GetAdminApiKey(ctx context.Context) (string, error) { - key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminApiKey) +func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) { + key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey) if err != nil { if errors.Is(err, ErrSettingNotFound) { return "", nil // 未配置,返回空字符串 @@ -333,7 +333,7 @@ func (s *SettingService) GetAdminApiKey(ctx context.Context) (string, error) { return key, nil } -// DeleteAdminApiKey 删除管理员 API Key -func (s *SettingService) DeleteAdminApiKey(ctx context.Context) error { - return s.settingRepo.Delete(ctx, SettingKeyAdminApiKey) +// DeleteAdminAPIKey 删除管理员 API Key +func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error { + return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey) } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index cb9751d1..83e139e7 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -4,13 +4,13 @@ type SystemSettings struct { RegistrationEnabled bool EmailVerifyEnabled bool - SmtpHost string - SmtpPort int - SmtpUsername string - SmtpPassword string - SmtpFrom string - SmtpFromName string - SmtpUseTLS bool + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPFrom string + SMTPFromName string + SMTPUseTLS bool TurnstileEnabled bool TurnstileSiteKey string @@ -19,9 +19,9 @@ type SystemSettings struct { SiteName string SiteLogo string SiteSubtitle string - ApiBaseUrl string + APIBaseURL string ContactInfo string - DocUrl string + DocURL string DefaultConcurrency int DefaultBalance float64 @@ -35,8 +35,8 @@ type PublicSettings struct { SiteName string SiteLogo string SiteSubtitle string - ApiBaseUrl string + APIBaseURL string ContactInfo string - DocUrl string + DocURL string Version string } diff --git a/backend/internal/service/token_refresher_test.go b/backend/internal/service/token_refresher_test.go index 0a5135ac..c7505037 100644 --- a/backend/internal/service/token_refresher_test.go +++ b/backend/internal/service/token_refresher_test.go @@ -197,7 +197,7 @@ func TestClaudeTokenRefresher_CanRefresh(t *testing.T) { { name: "anthropic api-key - cannot refresh", platform: PlatformAnthropic, - accType: AccountTypeApiKey, + accType: AccountTypeAPIKey, want: false, }, { diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go index 0c7e5a20..34ad4610 100644 --- a/backend/internal/service/update_service.go +++ b/backend/internal/service/update_service.go @@ -79,7 +79,7 @@ type ReleaseInfo struct { Name string `json:"name"` Body string `json:"body"` PublishedAt string `json:"published_at"` - HtmlURL string `json:"html_url"` + HTMLURL string `json:"html_url"` Assets []Asset `json:"assets,omitempty"` } @@ -96,13 +96,13 @@ type GitHubRelease struct { Name string `json:"name"` Body string `json:"body"` PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` + HTMLURL string `json:"html_url"` Assets []GitHubAsset `json:"assets"` } type GitHubAsset struct { Name string `json:"name"` - BrowserDownloadUrl string `json:"browser_download_url"` + BrowserDownloadURL string `json:"browser_download_url"` Size int64 `json:"size"` } @@ -285,7 +285,7 @@ func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, er for i, a := range release.Assets { assets[i] = Asset{ Name: a.Name, - DownloadURL: a.BrowserDownloadUrl, + DownloadURL: a.BrowserDownloadURL, Size: a.Size, } } @@ -298,7 +298,7 @@ func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, er Name: release.Name, Body: release.Body, PublishedAt: release.PublishedAt, - HtmlURL: release.HtmlUrl, + HTMLURL: release.HTMLURL, Assets: assets, }, Cached: false, diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e822cd95..ed0a8eb7 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -10,7 +10,7 @@ const ( type UsageLog struct { ID int64 UserID int64 - ApiKeyID int64 + APIKeyID int64 AccountID int64 RequestID string Model string @@ -42,7 +42,7 @@ type UsageLog struct { CreatedAt time.Time User *User - ApiKey *ApiKey + APIKey *APIKey Account *Account Group *Group Subscription *UserSubscription diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index e1e97671..ddb88bcf 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -17,7 +17,7 @@ var ( // CreateUsageLogRequest 创建使用日志请求 type CreateUsageLogRequest struct { UserID int64 `json:"user_id"` - ApiKeyID int64 `json:"api_key_id"` + APIKeyID int64 `json:"api_key_id"` AccountID int64 `json:"account_id"` RequestID string `json:"request_id"` Model string `json:"model"` @@ -75,7 +75,7 @@ func (s *UsageService) Create(ctx context.Context, req CreateUsageLogRequest) (* // 创建使用日志 usageLog := &UsageLog{ UserID: req.UserID, - ApiKeyID: req.ApiKeyID, + APIKeyID: req.APIKeyID, AccountID: req.AccountID, RequestID: req.RequestID, Model: req.Model, @@ -128,9 +128,9 @@ func (s *UsageService) ListByUser(ctx context.Context, userID int64, params pagi return logs, pagination, nil } -// ListByApiKey 获取API Key的使用日志列表 -func (s *UsageService) ListByApiKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) { - logs, pagination, err := s.usageRepo.ListByApiKey(ctx, apiKeyID, params) +// ListByAPIKey 获取API Key的使用日志列表 +func (s *UsageService) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) { + logs, pagination, err := s.usageRepo.ListByAPIKey(ctx, apiKeyID, params) if err != nil { return nil, nil, fmt.Errorf("list usage logs: %w", err) } @@ -165,9 +165,9 @@ func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTi }, nil } -// GetStatsByApiKey 获取API Key的使用统计 -func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) { - stats, err := s.usageRepo.GetApiKeyStatsAggregated(ctx, apiKeyID, startTime, endTime) +// GetStatsByAPIKey 获取API Key的使用统计 +func (s *UsageService) GetStatsByAPIKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) { + stats, err := s.usageRepo.GetAPIKeyStatsAggregated(ctx, apiKeyID, startTime, endTime) if err != nil { return nil, fmt.Errorf("get api key stats: %w", err) } @@ -270,9 +270,9 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star return stats, nil } -// GetBatchApiKeyUsageStats returns today/total actual_cost for given api keys. -func (s *UsageService) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) { - stats, err := s.usageRepo.GetBatchApiKeyUsageStats(ctx, apiKeyIDs) +// GetBatchAPIKeyUsageStats returns today/total actual_cost for given api keys. +func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) { + stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs) if err != nil { return nil, fmt.Errorf("get batch api key usage stats: %w", err) } diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 894243df..c565607e 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -21,7 +21,7 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time - ApiKeys []ApiKey + APIKeys []APIKey Subscriptions []UserSubscription } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 7971f041..f3cf5d0c 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -73,6 +73,20 @@ func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWh return svc } +// ProvideOpsMetricsCollector creates and starts OpsMetricsCollector. +func ProvideOpsMetricsCollector(opsService *OpsService, concurrencyService *ConcurrencyService) *OpsMetricsCollector { + svc := NewOpsMetricsCollector(opsService, concurrencyService) + svc.Start() + return svc +} + +// ProvideOpsAlertService creates and starts OpsAlertService. +func ProvideOpsAlertService(opsService *OpsService, userService *UserService, emailService *EmailService) *OpsAlertService { + svc := NewOpsAlertService(opsService, userService, emailService) + svc.Start() + return svc +} + // ProvideConcurrencyService creates ConcurrencyService and starts slot cleanup worker. func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountRepository, cfg *config.Config) *ConcurrencyService { svc := NewConcurrencyService(cache) @@ -87,13 +101,14 @@ var ProviderSet = wire.NewSet( // Core services NewAuthService, NewUserService, - NewApiKeyService, + NewAPIKeyService, NewGroupService, NewAccountService, NewProxyService, NewRedeemService, NewUsageService, NewDashboardService, + NewOpsService, ProvidePricingService, NewBillingService, NewBillingCacheService, @@ -125,5 +140,7 @@ var ProviderSet = wire.NewSet( ProvideTimingWheelService, ProvideDeferredService, ProvideAntigravityQuotaRefresher, + ProvideOpsMetricsCollector, + ProvideOpsAlertService, NewUserAttributeService, ) diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 0d57d93f..ca13775d 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -1,3 +1,4 @@ +// Package setup provides CLI-based installation wizard for initial system configuration. package setup import ( diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 230d016f..435f6289 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -345,7 +345,7 @@ func writeConfigFile(cfg *SetupConfig) error { Default struct { UserConcurrency int `yaml:"user_concurrency"` UserBalance float64 `yaml:"user_balance"` - ApiKeyPrefix string `yaml:"api_key_prefix"` + APIKeyPrefix string `yaml:"api_key_prefix"` RateMultiplier float64 `yaml:"rate_multiplier"` } `yaml:"default"` RateLimit struct { @@ -367,12 +367,12 @@ func writeConfigFile(cfg *SetupConfig) error { Default: struct { UserConcurrency int `yaml:"user_concurrency"` UserBalance float64 `yaml:"user_balance"` - ApiKeyPrefix string `yaml:"api_key_prefix"` + APIKeyPrefix string `yaml:"api_key_prefix"` RateMultiplier float64 `yaml:"rate_multiplier"` }{ UserConcurrency: 5, UserBalance: 0, - ApiKeyPrefix: "sk-", + APIKeyPrefix: "sk-", RateMultiplier: 1.0, }, RateLimit: struct { diff --git a/backend/internal/web/embed_off.go b/backend/internal/web/embed_off.go index ac57fb5c..0e59a4d2 100644 --- a/backend/internal/web/embed_off.go +++ b/backend/internal/web/embed_off.go @@ -1,5 +1,6 @@ //go:build !embed +// Package web provides web server functionality including embedded frontend support. package web import ( diff --git a/backend/migrations/017_ops_metrics_and_error_logs.sql b/backend/migrations/017_ops_metrics_and_error_logs.sql new file mode 100644 index 00000000..fd6a0215 --- /dev/null +++ b/backend/migrations/017_ops_metrics_and_error_logs.sql @@ -0,0 +1,48 @@ +-- Ops error logs and system metrics + +CREATE TABLE IF NOT EXISTS ops_error_logs ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64), + user_id BIGINT, + api_key_id BIGINT, + account_id BIGINT, + group_id BIGINT, + client_ip INET, + error_phase VARCHAR(32) NOT NULL, + error_type VARCHAR(64) NOT NULL, + severity VARCHAR(4) NOT NULL, + status_code INT, + platform VARCHAR(32), + model VARCHAR(100), + request_path VARCHAR(256), + stream BOOLEAN NOT NULL DEFAULT FALSE, + error_message TEXT, + error_body TEXT, + provider_error_code VARCHAR(64), + provider_error_type VARCHAR(64), + is_retryable BOOLEAN NOT NULL DEFAULT FALSE, + is_user_actionable BOOLEAN NOT NULL DEFAULT FALSE, + retry_count INT NOT NULL DEFAULT 0, + completion_status VARCHAR(16), + duration_ms INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_created_at ON ops_error_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_phase ON ops_error_logs (error_phase); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_platform ON ops_error_logs (platform); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_severity ON ops_error_logs (severity); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_phase_platform_time ON ops_error_logs (error_phase, platform, created_at DESC); + +CREATE TABLE IF NOT EXISTS ops_system_metrics ( + id BIGSERIAL PRIMARY KEY, + success_rate DOUBLE PRECISION, + error_rate DOUBLE PRECISION, + p95_latency_ms INT, + p99_latency_ms INT, + http2_errors INT, + active_alerts INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ops_system_metrics_created_at ON ops_system_metrics (created_at DESC); diff --git a/backend/migrations/018_ops_metrics_system_stats.sql b/backend/migrations/018_ops_metrics_system_stats.sql new file mode 100644 index 00000000..e92d2137 --- /dev/null +++ b/backend/migrations/018_ops_metrics_system_stats.sql @@ -0,0 +1,14 @@ +-- Extend ops_system_metrics with windowed/system stats + +ALTER TABLE ops_system_metrics + ADD COLUMN IF NOT EXISTS window_minutes INT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS cpu_usage_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS memory_used_mb BIGINT, + ADD COLUMN IF NOT EXISTS memory_total_mb BIGINT, + ADD COLUMN IF NOT EXISTS memory_usage_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS heap_alloc_mb BIGINT, + ADD COLUMN IF NOT EXISTS gc_pause_ms DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS concurrency_queue_depth INT; + +CREATE INDEX IF NOT EXISTS idx_ops_system_metrics_window_time + ON ops_system_metrics (window_minutes, created_at DESC); diff --git a/backend/migrations/019_ops_alerts.sql b/backend/migrations/019_ops_alerts.sql new file mode 100644 index 00000000..91dfd848 --- /dev/null +++ b/backend/migrations/019_ops_alerts.sql @@ -0,0 +1,42 @@ +-- Ops alert rules and events + +CREATE TABLE IF NOT EXISTS ops_alert_rules ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + metric_type VARCHAR(64) NOT NULL, + operator VARCHAR(8) NOT NULL, + threshold DOUBLE PRECISION NOT NULL, + window_minutes INT NOT NULL DEFAULT 1, + sustained_minutes INT NOT NULL DEFAULT 1, + severity VARCHAR(4) NOT NULL DEFAULT 'P1', + notify_email BOOLEAN NOT NULL DEFAULT FALSE, + notify_webhook BOOLEAN NOT NULL DEFAULT FALSE, + webhook_url TEXT, + cooldown_minutes INT NOT NULL DEFAULT 10, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ops_alert_rules_enabled ON ops_alert_rules (enabled); +CREATE INDEX IF NOT EXISTS idx_ops_alert_rules_metric ON ops_alert_rules (metric_type, window_minutes); + +CREATE TABLE IF NOT EXISTS ops_alert_events ( + id BIGSERIAL PRIMARY KEY, + rule_id BIGINT NOT NULL REFERENCES ops_alert_rules(id) ON DELETE CASCADE, + severity VARCHAR(4) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'firing', + title VARCHAR(200), + description TEXT, + metric_value DOUBLE PRECISION, + threshold_value DOUBLE PRECISION, + fired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + email_sent BOOLEAN NOT NULL DEFAULT FALSE, + webhook_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ops_alert_events_rule_status ON ops_alert_events (rule_id, status); +CREATE INDEX IF NOT EXISTS idx_ops_alert_events_fired_at ON ops_alert_events (fired_at DESC); diff --git a/backend/migrations/020_seed_ops_alert_rules.sql b/backend/migrations/020_seed_ops_alert_rules.sql new file mode 100644 index 00000000..eaf128a3 --- /dev/null +++ b/backend/migrations/020_seed_ops_alert_rules.sql @@ -0,0 +1,32 @@ +-- Seed default ops alert rules (idempotent) + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'Global success rate < 99%', + 'Trigger when the 1-minute success rate drops below 99% for 2 consecutive minutes.', + TRUE, + 'success_rate', + '<', + 99, + 1, + 2, + 'P1', + TRUE, + FALSE, + NULL, + 10 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules); diff --git a/backend/migrations/021_seed_ops_alert_rules_more.sql b/backend/migrations/021_seed_ops_alert_rules_more.sql new file mode 100644 index 00000000..1b0413fc --- /dev/null +++ b/backend/migrations/021_seed_ops_alert_rules_more.sql @@ -0,0 +1,205 @@ +-- Seed additional ops alert rules (idempotent) + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'Global error rate > 1%', + 'Trigger when the 1-minute error rate exceeds 1% for 2 consecutive minutes.', + TRUE, + 'error_rate', + '>', + 1, + 1, + 2, + 'P1', + TRUE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 10 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'Global error rate > 1%'); + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'P99 latency > 2000ms', + 'Trigger when the 5-minute P99 latency exceeds 2000ms for 2 consecutive samples.', + TRUE, + 'p99_latency_ms', + '>', + 2000, + 5, + 2, + 'P1', + TRUE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 15 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'P99 latency > 2000ms'); + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'HTTP/2 errors > 20', + 'Trigger when HTTP/2 errors exceed 20 in the last minute for 2 consecutive minutes.', + TRUE, + 'http2_errors', + '>', + 20, + 1, + 2, + 'P2', + FALSE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 10 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'HTTP/2 errors > 20'); + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'CPU usage > 85%', + 'Trigger when CPU usage exceeds 85% for 5 consecutive minutes.', + TRUE, + 'cpu_usage_percent', + '>', + 85, + 1, + 5, + 'P2', + FALSE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 15 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'CPU usage > 85%'); + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'Memory usage > 90%', + 'Trigger when memory usage exceeds 90% for 5 consecutive minutes.', + TRUE, + 'memory_usage_percent', + '>', + 90, + 1, + 5, + 'P2', + FALSE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 15 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'Memory usage > 90%'); + +INSERT INTO ops_alert_rules ( + name, + description, + enabled, + metric_type, + operator, + threshold, + window_minutes, + sustained_minutes, + severity, + notify_email, + notify_webhook, + webhook_url, + cooldown_minutes +) +SELECT + 'Queue depth > 50', + 'Trigger when concurrency queue depth exceeds 50 for 2 consecutive minutes.', + TRUE, + 'concurrency_queue_depth', + '>', + 50, + 1, + 2, + 'P2', + FALSE, + CASE + WHEN (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1) IS NULL THEN FALSE + ELSE TRUE + END, + (SELECT webhook_url FROM ops_alert_rules WHERE webhook_url IS NOT NULL AND webhook_url <> '' LIMIT 1), + 10 +WHERE NOT EXISTS (SELECT 1 FROM ops_alert_rules WHERE name = 'Queue depth > 50'); diff --git a/backend/migrations/022_enable_ops_alert_webhook.sql b/backend/migrations/022_enable_ops_alert_webhook.sql new file mode 100644 index 00000000..13d73c51 --- /dev/null +++ b/backend/migrations/022_enable_ops_alert_webhook.sql @@ -0,0 +1,7 @@ +-- Enable webhook notifications for rules with webhook_url configured + +UPDATE ops_alert_rules +SET notify_webhook = TRUE +WHERE webhook_url IS NOT NULL + AND webhook_url <> '' + AND notify_webhook IS DISTINCT FROM TRUE; diff --git a/backend/migrations/023_ops_metrics_request_counts.sql b/backend/migrations/023_ops_metrics_request_counts.sql new file mode 100644 index 00000000..ed515053 --- /dev/null +++ b/backend/migrations/023_ops_metrics_request_counts.sql @@ -0,0 +1,6 @@ +-- Add request counts to ops_system_metrics so the UI/alerts can distinguish "no traffic" from "healthy". + +ALTER TABLE ops_system_metrics + ADD COLUMN IF NOT EXISTS request_count BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS success_count BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS error_count BIGINT NOT NULL DEFAULT 0; diff --git a/backend/migrations/025_enhance_ops_monitoring.sql b/backend/migrations/025_enhance_ops_monitoring.sql new file mode 100644 index 00000000..69259f69 --- /dev/null +++ b/backend/migrations/025_enhance_ops_monitoring.sql @@ -0,0 +1,272 @@ +-- 运维监控中心 2.0 - 数据库 Schema 增强 +-- 创建时间: 2026-01-02 +-- 说明: 扩展监控指标,支持多维度分析和告警管理 + +-- ============================================ +-- 1. 扩展 ops_system_metrics 表 +-- ============================================ + +-- 添加 RED 指标列 +ALTER TABLE ops_system_metrics + ADD COLUMN IF NOT EXISTS qps DECIMAL(10,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS tps DECIMAL(10,2) DEFAULT 0, + + -- 错误分类 + ADD COLUMN IF NOT EXISTS error_4xx_count BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS error_5xx_count BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS error_timeout_count BIGINT DEFAULT 0, + + -- 延迟指标扩展 + ADD COLUMN IF NOT EXISTS latency_p50 DECIMAL(10,2), + ADD COLUMN IF NOT EXISTS latency_p999 DECIMAL(10,2), + ADD COLUMN IF NOT EXISTS latency_avg DECIMAL(10,2), + ADD COLUMN IF NOT EXISTS latency_max DECIMAL(10,2), + + -- 上游延迟 + ADD COLUMN IF NOT EXISTS upstream_latency_avg DECIMAL(10,2), + + -- 资源指标 + ADD COLUMN IF NOT EXISTS disk_used BIGINT, + ADD COLUMN IF NOT EXISTS disk_total BIGINT, + ADD COLUMN IF NOT EXISTS disk_iops BIGINT, + ADD COLUMN IF NOT EXISTS network_in_bytes BIGINT, + ADD COLUMN IF NOT EXISTS network_out_bytes BIGINT, + + -- 饱和度指标 + ADD COLUMN IF NOT EXISTS goroutine_count INT, + ADD COLUMN IF NOT EXISTS db_conn_active INT, + ADD COLUMN IF NOT EXISTS db_conn_idle INT, + ADD COLUMN IF NOT EXISTS db_conn_waiting INT, + + -- 业务指标 + ADD COLUMN IF NOT EXISTS token_consumed BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS token_rate DECIMAL(10,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS active_subscriptions INT DEFAULT 0, + + -- 维度标签 (支持多维度分析) + ADD COLUMN IF NOT EXISTS tags JSONB; + +-- 添加 JSONB 索引以加速标签查询 +CREATE INDEX IF NOT EXISTS idx_ops_metrics_tags ON ops_system_metrics USING GIN(tags); + +-- 添加注释 +COMMENT ON COLUMN ops_system_metrics.qps IS '每秒查询数 (Queries Per Second)'; +COMMENT ON COLUMN ops_system_metrics.tps IS '每秒事务数 (Transactions Per Second)'; +COMMENT ON COLUMN ops_system_metrics.error_4xx_count IS '客户端错误数量 (4xx)'; +COMMENT ON COLUMN ops_system_metrics.error_5xx_count IS '服务端错误数量 (5xx)'; +COMMENT ON COLUMN ops_system_metrics.error_timeout_count IS '超时错误数量'; +COMMENT ON COLUMN ops_system_metrics.upstream_latency_avg IS '上游 API 平均延迟 (ms)'; +COMMENT ON COLUMN ops_system_metrics.goroutine_count IS 'Goroutine 数量 (检测泄露)'; +COMMENT ON COLUMN ops_system_metrics.tags IS '维度标签 (JSON), 如: {"account_id": "123", "api_path": "/v1/chat"}'; + +-- ============================================ +-- 2. 创建维度统计表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS ops_dimension_stats ( + id BIGSERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL, + + -- 维度类型: account, api_path, provider, region + dimension_type VARCHAR(50) NOT NULL, + dimension_value VARCHAR(255) NOT NULL, + + -- 统计指标 + request_count BIGINT DEFAULT 0, + success_count BIGINT DEFAULT 0, + error_count BIGINT DEFAULT 0, + success_rate DECIMAL(5,2), + error_rate DECIMAL(5,2), + + -- 性能指标 + latency_p50 DECIMAL(10,2), + latency_p95 DECIMAL(10,2), + latency_p99 DECIMAL(10,2), + + -- 业务指标 + token_consumed BIGINT DEFAULT 0, + cost_usd DECIMAL(10,4) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 创建复合索引以加速维度查询 +CREATE INDEX IF NOT EXISTS idx_ops_dim_type_value_time + ON ops_dimension_stats(dimension_type, dimension_value, timestamp DESC); + +-- 创建单独的时间索引用于范围查询 +CREATE INDEX IF NOT EXISTS idx_ops_dim_timestamp + ON ops_dimension_stats(timestamp DESC); + +-- 添加注释 +COMMENT ON TABLE ops_dimension_stats IS '多维度统计表,支持按账户/API/Provider等维度下钻分析'; +COMMENT ON COLUMN ops_dimension_stats.dimension_type IS '维度类型: account(账户), api_path(接口), provider(上游), region(地域)'; +COMMENT ON COLUMN ops_dimension_stats.dimension_value IS '维度值,如: 账户ID, /v1/chat, openai, us-east-1'; + +-- ============================================ +-- 3. 创建告警规则表 +-- ============================================ + +ALTER TABLE ops_alert_rules + ADD COLUMN IF NOT EXISTS dimension_filters JSONB, + ADD COLUMN IF NOT EXISTS notify_channels JSONB, + ADD COLUMN IF NOT EXISTS notify_config JSONB, + ADD COLUMN IF NOT EXISTS created_by VARCHAR(100), + ADD COLUMN IF NOT EXISTS last_triggered_at TIMESTAMPTZ; + +-- ============================================ +-- 4. 告警历史表 (使用现有的 ops_alert_events) +-- ============================================ +-- 注意: 后端代码使用 ops_alert_events 表,不创建新表 + +-- ============================================ +-- 5. 创建数据清理配置表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS ops_data_retention_config ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(100) NOT NULL UNIQUE, + retention_days INT NOT NULL, -- 保留天数 + enabled BOOLEAN DEFAULT true, + last_cleanup_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 插入默认配置 +INSERT INTO ops_data_retention_config (table_name, retention_days) VALUES + ('ops_system_metrics', 30), -- 系统指标保留 30 天 + ('ops_dimension_stats', 30), -- 维度统计保留 30 天 + ('ops_error_logs', 30), -- 错误日志保留 30 天 + ('ops_alert_events', 90), -- 告警事件保留 90 天 + ('usage_logs', 90) -- 使用日志保留 90 天 +ON CONFLICT (table_name) DO NOTHING; + +COMMENT ON TABLE ops_data_retention_config IS '数据保留策略配置表'; +COMMENT ON COLUMN ops_data_retention_config.retention_days IS '数据保留天数,超过此天数的数据将被自动清理'; + +-- ============================================ +-- 6. 创建辅助函数 +-- ============================================ + +-- 函数: 计算健康度评分 +-- 权重: SLA(40%) + 错误率(30%) + 延迟(20%) + 资源(10%) +CREATE OR REPLACE FUNCTION calculate_health_score( + p_success_rate DECIMAL, + p_error_rate DECIMAL, + p_latency_p99 DECIMAL, + p_cpu_usage DECIMAL +) RETURNS INT AS $$ +DECLARE + sla_score INT; + error_score INT; + latency_score INT; + resource_score INT; +BEGIN + -- SLA 评分 (40分) + sla_score := CASE + WHEN p_success_rate >= 99.9 THEN 40 + WHEN p_success_rate >= 99.5 THEN 35 + WHEN p_success_rate >= 99.0 THEN 30 + WHEN p_success_rate >= 95.0 THEN 20 + ELSE 10 + END; + + -- 错误率评分 (30分) + error_score := CASE + WHEN p_error_rate <= 0.1 THEN 30 + WHEN p_error_rate <= 0.5 THEN 25 + WHEN p_error_rate <= 1.0 THEN 20 + WHEN p_error_rate <= 5.0 THEN 10 + ELSE 5 + END; + + -- 延迟评分 (20分) + latency_score := CASE + WHEN p_latency_p99 <= 500 THEN 20 + WHEN p_latency_p99 <= 1000 THEN 15 + WHEN p_latency_p99 <= 3000 THEN 10 + WHEN p_latency_p99 <= 5000 THEN 5 + ELSE 0 + END; + + -- 资源评分 (10分) + resource_score := CASE + WHEN p_cpu_usage <= 50 THEN 10 + WHEN p_cpu_usage <= 70 THEN 7 + WHEN p_cpu_usage <= 85 THEN 5 + ELSE 2 + END; + + RETURN sla_score + error_score + latency_score + resource_score; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +COMMENT ON FUNCTION calculate_health_score IS '计算系统健康度评分 (0-100),权重: SLA 40% + 错误率 30% + 延迟 20% + 资源 10%'; + +-- ============================================ +-- 7. 创建视图: 最新指标快照 +-- ============================================ + +CREATE OR REPLACE VIEW ops_latest_metrics AS +SELECT + m.*, + calculate_health_score( + m.success_rate::DECIMAL, + m.error_rate::DECIMAL, + m.p99_latency_ms::DECIMAL, + m.cpu_usage_percent::DECIMAL + ) AS health_score +FROM ops_system_metrics m +WHERE m.window_minutes = 1 + AND m.created_at = (SELECT MAX(created_at) FROM ops_system_metrics WHERE window_minutes = 1) +LIMIT 1; + +COMMENT ON VIEW ops_latest_metrics IS '最新的系统指标快照,包含健康度评分'; + +-- ============================================ +-- 8. 创建视图: 活跃告警列表 +-- ============================================ + +CREATE OR REPLACE VIEW ops_active_alerts AS +SELECT + e.id, + e.rule_id, + r.name AS rule_name, + r.metric_type, + e.fired_at, + e.metric_value, + e.threshold_value, + r.severity, + EXTRACT(EPOCH FROM (NOW() - e.fired_at))::INT AS duration_seconds +FROM ops_alert_events e +JOIN ops_alert_rules r ON e.rule_id = r.id +WHERE e.status = 'firing' +ORDER BY e.fired_at DESC; + +COMMENT ON VIEW ops_active_alerts IS '当前活跃的告警列表'; + +-- ============================================ +-- 9. 权限设置 (可选) +-- ============================================ + +-- 如果有专门的 ops 用户,可以授权 +-- GRANT SELECT, INSERT, UPDATE ON ops_system_metrics TO ops_user; +-- GRANT SELECT, INSERT ON ops_dimension_stats TO ops_user; +-- GRANT ALL ON ops_alert_rules TO ops_user; +-- GRANT ALL ON ops_alert_events TO ops_user; + +-- ============================================ +-- 10. 数据完整性检查 +-- ============================================ + +-- 确保现有数据的兼容性 +UPDATE ops_system_metrics +SET + qps = COALESCE(request_count / (window_minutes * 60.0), 0), + error_rate = COALESCE((error_count::DECIMAL / NULLIF(request_count, 0)) * 100, 0) +WHERE qps = 0 AND request_count > 0; + +-- ============================================ +-- 完成 +-- ============================================ diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 83e56c0e..3e50f1ca 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -8,7 +8,7 @@ import type { DashboardStats, TrendDataPoint, ModelStat, - ApiKeyUsageTrendPoint, + APIKeyUsageTrendPoint, UserUsageTrendPoint } from '@/types' @@ -93,7 +93,7 @@ export interface ApiKeyTrendParams extends TrendParams { } export interface ApiKeyTrendResponse { - trend: ApiKeyUsageTrendPoint[] + trend: APIKeyUsageTrendPoint[] start_date: string end_date: string granularity: string diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index ea12f6d2..7ebbaa50 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -15,6 +15,7 @@ import subscriptionsAPI from './subscriptions' import usageAPI from './usage' import geminiAPI from './gemini' import antigravityAPI from './antigravity' +import opsAPI from './ops' import userAttributesAPI from './userAttributes' /** @@ -33,6 +34,7 @@ export const adminAPI = { usage: usageAPI, gemini: geminiAPI, antigravity: antigravityAPI, + ops: opsAPI, userAttributes: userAttributesAPI } @@ -49,6 +51,7 @@ export { usageAPI, geminiAPI, antigravityAPI, + opsAPI, userAttributesAPI } diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts new file mode 100644 index 00000000..5b06532f --- /dev/null +++ b/frontend/src/api/admin/ops.ts @@ -0,0 +1,324 @@ +/** + * Admin Ops API endpoints + * Provides stability metrics and error logs for ops dashboard + */ + +import { apiClient } from '../client' + +export type OpsSeverity = 'P0' | 'P1' | 'P2' | 'P3' +export type OpsPhase = + | 'auth' + | 'concurrency' + | 'billing' + | 'scheduling' + | 'network' + | 'upstream' + | 'response' + | 'internal' +export type OpsPlatform = 'gemini' | 'openai' | 'anthropic' | 'antigravity' + +export interface OpsMetrics { + window_minutes: number + request_count: number + success_count: number + error_count: number + success_rate: number + error_rate: number + p95_latency_ms: number + p99_latency_ms: number + http2_errors: number + active_alerts: number + cpu_usage_percent?: number + memory_used_mb?: number + memory_total_mb?: number + memory_usage_percent?: number + heap_alloc_mb?: number + gc_pause_ms?: number + concurrency_queue_depth?: number + updated_at?: string +} + +export interface OpsErrorLog { + id: number + created_at: string + phase: OpsPhase + type: string + severity: OpsSeverity + status_code: number + platform: OpsPlatform + model: string + latency_ms: number | null + request_id: string + message: string + user_id?: number | null + api_key_id?: number | null + account_id?: number | null + group_id?: number | null + client_ip?: string + request_path?: string + stream?: boolean +} + +export interface OpsErrorListParams { + start_time?: string + end_time?: string + platform?: OpsPlatform + phase?: OpsPhase + severity?: OpsSeverity + q?: string + /** + * Max 500 (legacy endpoint uses a hard cap); use paginated /admin/ops/errors for larger result sets. + */ + limit?: number +} + +export interface OpsErrorListResponse { + items: OpsErrorLog[] + total?: number +} + +export interface OpsMetricsHistoryParams { + window_minutes?: number + minutes?: number + start_time?: string + end_time?: string + limit?: number +} + +export interface OpsMetricsHistoryResponse { + items: OpsMetrics[] +} + +/** + * Get latest ops metrics snapshot + */ +export async function getMetrics(): Promise { + const { data } = await apiClient.get('/admin/ops/metrics') + return data +} + +/** + * List metrics history for charts + */ +export async function listMetricsHistory(params?: OpsMetricsHistoryParams): Promise { + const { data } = await apiClient.get('/admin/ops/metrics/history', { params }) + return data +} + +/** + * List recent error logs with optional filters + */ +export async function listErrors(params?: OpsErrorListParams): Promise { + const { data } = await apiClient.get('/admin/ops/error-logs', { params }) + return data +} + +export interface OpsDashboardOverview { + timestamp: string + health_score: number + sla: { + current: number + threshold: number + status: string + trend: string + change_24h: number + } + qps: { + current: number + peak_1h: number + avg_1h: number + change_vs_yesterday: number + } + tps: { + current: number + peak_1h: number + avg_1h: number + } + latency: { + p50: number + p95: number + p99: number + p999: number + avg: number + max: number + threshold_p99: number + status: string + } + errors: { + total_count: number + error_rate: number + '4xx_count': number + '5xx_count': number + timeout_count: number + top_error?: { + code: string + message: string + count: number + } + } + resources: { + cpu_usage: number + memory_usage: number + disk_usage: number + goroutines: number + db_connections: { + active: number + idle: number + waiting: number + max: number + } + } + system_status: { + redis: string + database: string + background_jobs: string + } +} + +export interface ProviderHealthData { + name: string + request_count: number + success_rate: number + error_rate: number + latency_avg: number + latency_p99: number + status: string + errors_by_type: { + '4xx': number + '5xx': number + timeout: number + } +} + +export interface ProviderHealthResponse { + providers: ProviderHealthData[] + summary: { + total_requests: number + avg_success_rate: number + best_provider: string + worst_provider: string + } +} + +export interface LatencyHistogramResponse { + buckets: { + range: string + count: number + percentage: number + }[] + total_requests: number + slow_request_threshold: number +} + +export interface ErrorDistributionResponse { + items: { + code: string + message: string + count: number + percentage: number + }[] +} + +/** + * Get realtime ops dashboard overview + */ +export async function getDashboardOverview(timeRange = '1h'): Promise { + const { data } = await apiClient.get('/admin/ops/dashboard/overview', { + params: { time_range: timeRange } + }) + return data +} + +/** + * Get provider health comparison + */ +export async function getProviderHealth(timeRange = '1h'): Promise { + const { data } = await apiClient.get('/admin/ops/dashboard/providers', { + params: { time_range: timeRange } + }) + return data +} + +/** + * Get latency histogram + */ +export async function getLatencyHistogram(timeRange = '1h'): Promise { + const { data } = await apiClient.get('/admin/ops/dashboard/latency-histogram', { + params: { time_range: timeRange } + }) + return data +} + +/** + * Get error distribution + */ +export async function getErrorDistribution(timeRange = '1h'): Promise { + const { data } = await apiClient.get('/admin/ops/dashboard/errors/distribution', { + params: { time_range: timeRange } + }) + return data +} + +/** + * Subscribe to realtime QPS updates via WebSocket + */ +export function subscribeQPS(onMessage: (data: any) => void): () => void { + let ws: WebSocket | null = null + let reconnectAttempts = 0 + const maxReconnectAttempts = 5 + let reconnectTimer: ReturnType | null = null + let shouldReconnect = true + + const connect = () => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + ws = new WebSocket(`${protocol}//${host}/api/v1/admin/ops/ws/qps`) + + ws.onopen = () => { + console.log('[OpsWS] Connected') + reconnectAttempts = 0 + } + + ws.onmessage = (e) => { + const data = JSON.parse(e.data) + onMessage(data) + } + + ws.onerror = (error) => { + console.error('[OpsWS] Connection error:', error) + } + + ws.onclose = () => { + console.log('[OpsWS] Connection closed') + if (shouldReconnect && reconnectAttempts < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000) + console.log(`[OpsWS] Reconnecting in ${delay}ms...`) + reconnectTimer = setTimeout(() => { + reconnectAttempts++ + connect() + }, delay) + } + } + } + + connect() + + return () => { + shouldReconnect = false + if (reconnectTimer) clearTimeout(reconnectTimer) + if (ws) ws.close() + } +} + +export const opsAPI = { + getMetrics, + listMetricsHistory, + listErrors, + getDashboardOverview, + getProviderHealth, + getLatencyHistogram, + getErrorDistribution, + subscribeQPS +} + +export default opsAPI diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 791327a1..87e65a4e 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -183,6 +183,21 @@ const DashboardIcon = { ) } +const ActivityIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M3 12h4l3 6 4-12 3 6h4' + }) + ] + ) +} + const KeyIcon = { render: () => h( @@ -442,6 +457,7 @@ const personalNavItems = computed(() => { const adminNavItems = computed(() => { const baseItems = [ { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, + { path: '/admin/ops', label: t('nav.ops'), icon: ActivityIcon }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f171565a..0fd8fa09 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -127,6 +127,8 @@ export default { total: 'Total', balance: 'Balance', available: 'Available', + copy: 'Copy', + details: 'Details', copiedToClipboard: 'Copied to clipboard', copyFailed: 'Failed to copy', contactSupport: 'Contact Support', @@ -147,6 +149,7 @@ export default { // Navigation nav: { dashboard: 'Dashboard', + ops: 'Ops Center', apiKeys: 'API Keys', usage: 'Usage', redeem: 'Redeem', @@ -546,6 +549,123 @@ export default { recentUsage: 'Recent Usage', failedToLoad: 'Failed to load dashboard statistics' }, + ops: { + title: 'Ops Monitoring Center 2.0', + description: 'Stability metrics, error distribution, and system health', + status: { + title: 'System Health Snapshot', + subtitle: 'Real-time metrics and error visibility', + systemNormal: 'System Normal', + systemDegraded: 'System Degraded', + systemDown: 'System Down', + noData: 'No Data', + monitoring: 'Monitoring', + lastUpdated: 'Last Updated', + live: 'Live', + waiting: 'Waiting for data', + realtime: 'Connected', + disconnected: 'Disconnected' + }, + charts: { + errorTrend: 'Error Trend', + errorDistribution: 'Error Distribution', + errorRate: 'Error Rate', + requestCount: 'Request Count', + rateLimits: 'Rate Limits (429)', + serverErrors: 'Server Errors (5xx)', + clientErrors: 'Client Errors (4xx)', + otherErrors: 'Other', + latencyDist: 'Latency Distribution', + providerSla: 'Upstream SLA Comparison', + errorDist: 'Error Type Distribution', + systemStatus: 'System Resources' + }, + metrics: { + successRate: 'Success Rate', + errorRate: 'Error Rate', + p95: 'P95 Latency', + p99: 'P99 Latency', + http2Errors: 'HTTP/2 Errors', + activeAlerts: 'Active Alerts', + cpuUsage: 'CPU Usage', + queueDepth: 'Queue Depth', + healthScore: 'Health Score', + sla: 'Availability (SLA)', + qps: 'Real-time QPS', + tps: 'Real-time TPS', + errorCount: 'Error Count' + }, + errors: { + title: 'Recent Errors', + subtitle: 'Inspect failures across platforms and phases', + count: '{n} errors' + }, + filters: { + allSeverities: 'All severities', + allPlatforms: 'All platforms', + allPhases: 'All phases', + p0: 'P0 (Critical)', + p1: 'P1 (High)', + p2: 'P2 (Medium)', + p3: 'P3 (Low)' + }, + searchPlaceholder: 'Search by request ID, model, or message', + range: { + '15m': 'Last 15 minutes', + '1h': 'Last 1 hour', + '24h': 'Last 24 hours', + '7d': 'Last 7 days' + }, + platform: { + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity' + }, + phase: { + auth: 'Auth', + concurrency: 'Concurrency', + billing: 'Billing', + scheduling: 'Scheduling', + network: 'Network', + upstream: 'Upstream', + response: 'Response', + internal: 'Internal' + }, + severity: { + p0: 'P0', + p1: 'P1', + p2: 'P2', + p3: 'P3' + }, + table: { + time: 'Time', + severity: 'Severity', + phase: 'Phase', + statusCode: 'Status', + platform: 'Platform', + model: 'Model', + latency: 'Latency', + requestId: 'Request ID', + message: 'Message' + }, + details: { + title: 'Error Details', + requestId: 'Request ID', + errorMessage: 'Error Message', + requestPath: 'Request path', + clientIp: 'Client IP', + userId: 'User ID', + apiKeyId: 'API Key ID', + groupId: 'Group ID', + stream: 'Stream' + }, + empty: { + title: 'No ops data yet', + subtitle: 'Enable error logging and metrics to populate this view' + }, + failedToLoad: 'Failed to load ops data' + }, // Users users: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 45fb5a72..14c00dec 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -124,6 +124,8 @@ export default { total: '总计', balance: '余额', available: '可用', + copy: '复制', + details: '详情', copiedToClipboard: '已复制到剪贴板', copyFailed: '复制失败', contactSupport: '联系客服', @@ -144,6 +146,7 @@ export default { // Navigation nav: { dashboard: '仪表盘', + ops: '运维监控', apiKeys: 'API 密钥', usage: '使用记录', redeem: '兑换', @@ -559,6 +562,123 @@ export default { configureSystem: '配置系统设置', failedToLoad: '加载仪表盘数据失败' }, + ops: { + title: '运维监控中心 2.0', + description: '稳定性指标、错误分布与系统健康', + status: { + title: '系统健康快照', + subtitle: '实时指标与错误可见性', + systemNormal: '系统正常', + systemDegraded: '系统降级', + systemDown: '系统异常', + noData: '无数据', + monitoring: '监控中', + lastUpdated: '最后更新', + live: '实时', + waiting: '等待数据', + realtime: '实时连接中', + disconnected: '连接已断开' + }, + charts: { + errorTrend: '错误趋势', + errorDistribution: '错误分布', + errorRate: '错误率', + requestCount: '请求数', + rateLimits: '限流 (429)', + serverErrors: '服务端错误 (5xx)', + clientErrors: '客户端错误 (4xx)', + otherErrors: '其他', + latencyDist: '请求延迟分布', + providerSla: '上游供应商健康度 (SLA)', + errorDist: '错误类型分布', + systemStatus: '系统运行状态' + }, + metrics: { + successRate: '成功率', + errorRate: '错误率', + p95: 'P95 延迟', + p99: 'P99 延迟', + http2Errors: 'HTTP/2 错误', + activeAlerts: '活跃告警', + cpuUsage: 'CPU 使用率', + queueDepth: '排队深度', + healthScore: '健康评分', + sla: '服务可用率 (SLA)', + qps: '实时 QPS', + tps: '实时 TPS', + errorCount: '周期错误数' + }, + errors: { + title: '最近错误', + subtitle: '按平台与阶段定位失败原因', + count: '{n} 条错误' + }, + filters: { + allSeverities: '全部级别', + allPlatforms: '全部平台', + allPhases: '全部阶段', + p0: 'P0(致命)', + p1: 'P1(高)', + p2: 'P2(中)', + p3: 'P3(低)' + }, + searchPlaceholder: '按请求ID、模型或错误信息搜索', + range: { + '15m': '近 15 分钟', + '1h': '近 1 小时', + '24h': '近 24 小时', + '7d': '近 7 天' + }, + platform: { + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity' + }, + phase: { + auth: '认证', + concurrency: '并发', + billing: '计费', + scheduling: '调度', + network: '网络', + upstream: '上游', + response: '响应', + internal: '内部' + }, + severity: { + p0: 'P0', + p1: 'P1', + p2: 'P2', + p3: 'P3' + }, + table: { + time: '时间', + severity: '级别', + phase: '阶段', + statusCode: '状态码', + platform: '平台', + model: '模型', + latency: '延迟', + requestId: '请求ID', + message: '错误信息' + }, + details: { + title: '错误详情', + requestId: '请求ID', + errorMessage: '错误信息', + requestPath: '请求路径', + clientIp: '客户端IP', + userId: '用户ID', + apiKeyId: 'API Key ID', + groupId: '分组ID', + stream: '流式' + }, + empty: { + title: '暂无运维数据', + subtitle: '启用错误日志与指标采集后将展示在此处' + }, + failedToLoad: '加载运维数据失败' + }, // Users Management users: { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 48a6f0fd..be5d8ece 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -163,6 +163,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.dashboard.description' } }, + { + path: '/admin/ops', + name: 'AdminOps', + component: () => import('@/views/admin/ops/OpsDashboard.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Ops Dashboard', + titleKey: 'admin.ops.title', + descriptionKey: 'admin.ops.description' + } + }, { path: '/admin/users', name: 'AdminUsers', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 47155a5d..4f6864ab 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -619,7 +619,7 @@ export interface UserUsageTrendPoint { actual_cost: number // 实际扣除 } -export interface ApiKeyUsageTrendPoint { +export interface APIKeyUsageTrendPoint { date: string api_key_id: number key_name: string diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue new file mode 100644 index 00000000..2762400e --- /dev/null +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -0,0 +1,417 @@ + + + + +