diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 2b0caf982c..e68a6bd7d5 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -337,7 +337,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) - channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) + channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository) + channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) diff --git a/backend/ent/channelmonitor.go b/backend/ent/channelmonitor.go index e5697863f0..e74b6528cc 100644 --- a/backend/ent/channelmonitor.go +++ b/backend/ent/channelmonitor.go @@ -27,9 +27,13 @@ type ChannelMonitor struct { Name string `json:"name,omitempty"` // Provider holds the value of the "provider" field. Provider channelmonitor.Provider `json:"provider,omitempty"` + // probe = LLM probe (default); quota = account usage only; quota_probe = both + CheckMode string `json:"check_mode,omitempty"` + // AccountID holds the value of the "account_id" field. + AccountID *int64 `json:"account_id,omitempty"` // OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions APIMode string `json:"api_mode,omitempty"` - // Provider base origin, e.g. https://api.openai.com + // Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors Endpoint string `json:"endpoint,omitempty"` // AES-256-GCM encrypted API key APIKeyEncrypted string `json:"-"` @@ -114,9 +118,9 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case channelmonitor.FieldEnabled: values[i] = new(sql.NullBool) - case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: + case channelmonitor.FieldID, channelmonitor.FieldAccountID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: values[i] = new(sql.NullInt64) - case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode: + case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldCheckMode, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode: values[i] = new(sql.NullString) case channelmonitor.FieldCreatedAt, channelmonitor.FieldUpdatedAt, channelmonitor.FieldLastCheckedAt: values[i] = new(sql.NullTime) @@ -165,6 +169,19 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Provider = channelmonitor.Provider(value.String) } + case channelmonitor.FieldCheckMode: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field check_mode", values[i]) + } else if value.Valid { + _m.CheckMode = value.String + } + case channelmonitor.FieldAccountID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field account_id", values[i]) + } else if value.Valid { + _m.AccountID = new(int64) + *_m.AccountID = value.Int64 + } case channelmonitor.FieldAPIMode: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field api_mode", values[i]) @@ -326,6 +343,14 @@ func (_m *ChannelMonitor) String() string { builder.WriteString("provider=") builder.WriteString(fmt.Sprintf("%v", _m.Provider)) builder.WriteString(", ") + builder.WriteString("check_mode=") + builder.WriteString(_m.CheckMode) + builder.WriteString(", ") + if v := _m.AccountID; v != nil { + builder.WriteString("account_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("api_mode=") builder.WriteString(_m.APIMode) builder.WriteString(", ") diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 711e6217e2..3224c6eb04 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -23,6 +23,10 @@ const ( FieldName = "name" // FieldProvider holds the string denoting the provider field in the database. FieldProvider = "provider" + // FieldCheckMode holds the string denoting the check_mode field in the database. + FieldCheckMode = "check_mode" + // FieldAccountID holds the string denoting the account_id field in the database. + FieldAccountID = "account_id" // FieldAPIMode holds the string denoting the api_mode field in the database. FieldAPIMode = "api_mode" // FieldEndpoint holds the string denoting the endpoint field in the database. @@ -91,6 +95,8 @@ var Columns = []string{ FieldUpdatedAt, FieldName, FieldProvider, + FieldCheckMode, + FieldAccountID, FieldAPIMode, FieldEndpoint, FieldAPIKeyEncrypted, @@ -127,6 +133,10 @@ var ( UpdateDefaultUpdatedAt func() time.Time // NameValidator is a validator for the "name" field. It is called by the builders before save. NameValidator func(string) error + // DefaultCheckMode holds the default value on creation for the "check_mode" field. + DefaultCheckMode string + // CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save. + CheckModeValidator func(string) error // DefaultAPIMode holds the default value on creation for the "api_mode" field. DefaultAPIMode string // APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save. @@ -164,10 +174,14 @@ type Provider string // Provider values. const ( - ProviderOpenai Provider = "openai" - ProviderAnthropic Provider = "anthropic" - ProviderGemini Provider = "gemini" - ProviderGrok Provider = "grok" + ProviderOpenai Provider = "openai" + ProviderAnthropic Provider = "anthropic" + ProviderGemini Provider = "gemini" + ProviderGrok Provider = "grok" + ProviderAntigravity Provider = "antigravity" + ProviderKimi Provider = "kimi" + ProviderZhipu Provider = "zhipu" + ProviderDeepseek Provider = "deepseek" ) func (pr Provider) String() string { @@ -177,7 +191,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: return nil default: return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr) @@ -212,6 +226,16 @@ func ByProvider(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProvider, opts...).ToFunc() } +// ByCheckMode orders the results by the check_mode field. +func ByCheckMode(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCheckMode, opts...).ToFunc() +} + +// ByAccountID orders the results by the account_id field. +func ByAccountID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAccountID, opts...).ToFunc() +} + // ByAPIMode orders the results by the api_mode field. func ByAPIMode(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAPIMode, opts...).ToFunc() diff --git a/backend/ent/channelmonitor/where.go b/backend/ent/channelmonitor/where.go index 6575aa5df6..9370a0b5ea 100644 --- a/backend/ent/channelmonitor/where.go +++ b/backend/ent/channelmonitor/where.go @@ -70,6 +70,16 @@ func Name(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldName, v)) } +// CheckMode applies equality check predicate on the "check_mode" field. It's identical to CheckModeEQ. +func CheckMode(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v)) +} + +// AccountID applies equality check predicate on the "account_id" field. It's identical to AccountIDEQ. +func AccountID(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v)) +} + // APIMode applies equality check predicate on the "api_mode" field. It's identical to APIModeEQ. func APIMode(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v)) @@ -295,6 +305,121 @@ func ProviderNotIn(vs ...Provider) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldNotIn(FieldProvider, vs...)) } +// CheckModeEQ applies the EQ predicate on the "check_mode" field. +func CheckModeEQ(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v)) +} + +// CheckModeNEQ applies the NEQ predicate on the "check_mode" field. +func CheckModeNEQ(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNEQ(FieldCheckMode, v)) +} + +// CheckModeIn applies the In predicate on the "check_mode" field. +func CheckModeIn(vs ...string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIn(FieldCheckMode, vs...)) +} + +// CheckModeNotIn applies the NotIn predicate on the "check_mode" field. +func CheckModeNotIn(vs ...string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotIn(FieldCheckMode, vs...)) +} + +// CheckModeGT applies the GT predicate on the "check_mode" field. +func CheckModeGT(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGT(FieldCheckMode, v)) +} + +// CheckModeGTE applies the GTE predicate on the "check_mode" field. +func CheckModeGTE(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGTE(FieldCheckMode, v)) +} + +// CheckModeLT applies the LT predicate on the "check_mode" field. +func CheckModeLT(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLT(FieldCheckMode, v)) +} + +// CheckModeLTE applies the LTE predicate on the "check_mode" field. +func CheckModeLTE(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLTE(FieldCheckMode, v)) +} + +// CheckModeContains applies the Contains predicate on the "check_mode" field. +func CheckModeContains(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldContains(FieldCheckMode, v)) +} + +// CheckModeHasPrefix applies the HasPrefix predicate on the "check_mode" field. +func CheckModeHasPrefix(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldHasPrefix(FieldCheckMode, v)) +} + +// CheckModeHasSuffix applies the HasSuffix predicate on the "check_mode" field. +func CheckModeHasSuffix(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldHasSuffix(FieldCheckMode, v)) +} + +// CheckModeEqualFold applies the EqualFold predicate on the "check_mode" field. +func CheckModeEqualFold(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEqualFold(FieldCheckMode, v)) +} + +// CheckModeContainsFold applies the ContainsFold predicate on the "check_mode" field. +func CheckModeContainsFold(v string) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldContainsFold(FieldCheckMode, v)) +} + +// AccountIDEQ applies the EQ predicate on the "account_id" field. +func AccountIDEQ(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v)) +} + +// AccountIDNEQ applies the NEQ predicate on the "account_id" field. +func AccountIDNEQ(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNEQ(FieldAccountID, v)) +} + +// AccountIDIn applies the In predicate on the "account_id" field. +func AccountIDIn(vs ...int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIn(FieldAccountID, vs...)) +} + +// AccountIDNotIn applies the NotIn predicate on the "account_id" field. +func AccountIDNotIn(vs ...int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotIn(FieldAccountID, vs...)) +} + +// AccountIDGT applies the GT predicate on the "account_id" field. +func AccountIDGT(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGT(FieldAccountID, v)) +} + +// AccountIDGTE applies the GTE predicate on the "account_id" field. +func AccountIDGTE(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGTE(FieldAccountID, v)) +} + +// AccountIDLT applies the LT predicate on the "account_id" field. +func AccountIDLT(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLT(FieldAccountID, v)) +} + +// AccountIDLTE applies the LTE predicate on the "account_id" field. +func AccountIDLTE(v int64) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLTE(FieldAccountID, v)) +} + +// AccountIDIsNil applies the IsNil predicate on the "account_id" field. +func AccountIDIsNil() predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIsNull(FieldAccountID)) +} + +// AccountIDNotNil applies the NotNil predicate on the "account_id" field. +func AccountIDNotNil() predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotNull(FieldAccountID)) +} + // APIModeEQ applies the EQ predicate on the "api_mode" field. func APIModeEQ(v string) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v)) diff --git a/backend/ent/channelmonitor_create.go b/backend/ent/channelmonitor_create.go index 6c7e654917..cfd63437ff 100644 --- a/backend/ent/channelmonitor_create.go +++ b/backend/ent/channelmonitor_create.go @@ -65,6 +65,34 @@ func (_c *ChannelMonitorCreate) SetProvider(v channelmonitor.Provider) *ChannelM return _c } +// SetCheckMode sets the "check_mode" field. +func (_c *ChannelMonitorCreate) SetCheckMode(v string) *ChannelMonitorCreate { + _c.mutation.SetCheckMode(v) + return _c +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_c *ChannelMonitorCreate) SetNillableCheckMode(v *string) *ChannelMonitorCreate { + if v != nil { + _c.SetCheckMode(*v) + } + return _c +} + +// SetAccountID sets the "account_id" field. +func (_c *ChannelMonitorCreate) SetAccountID(v int64) *ChannelMonitorCreate { + _c.mutation.SetAccountID(v) + return _c +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_c *ChannelMonitorCreate) SetNillableAccountID(v *int64) *ChannelMonitorCreate { + if v != nil { + _c.SetAccountID(*v) + } + return _c +} + // SetAPIMode sets the "api_mode" field. func (_c *ChannelMonitorCreate) SetAPIMode(v string) *ChannelMonitorCreate { _c.mutation.SetAPIMode(v) @@ -303,6 +331,10 @@ func (_c *ChannelMonitorCreate) defaults() { v := channelmonitor.DefaultUpdatedAt() _c.mutation.SetUpdatedAt(v) } + if _, ok := _c.mutation.CheckMode(); !ok { + v := channelmonitor.DefaultCheckMode + _c.mutation.SetCheckMode(v) + } if _, ok := _c.mutation.APIMode(); !ok { v := channelmonitor.DefaultAPIMode _c.mutation.SetAPIMode(v) @@ -357,6 +389,14 @@ func (_c *ChannelMonitorCreate) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if _, ok := _c.mutation.CheckMode(); !ok { + return &ValidationError{Name: "check_mode", err: errors.New(`ent: missing required field "ChannelMonitor.check_mode"`)} + } + if v, ok := _c.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if _, ok := _c.mutation.APIMode(); !ok { return &ValidationError{Name: "api_mode", err: errors.New(`ent: missing required field "ChannelMonitor.api_mode"`)} } @@ -473,6 +513,14 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) _node.Provider = value } + if value, ok := _c.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + _node.CheckMode = value + } + if value, ok := _c.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + _node.AccountID = &value + } if value, ok := _c.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) _node.APIMode = value @@ -666,6 +714,42 @@ func (u *ChannelMonitorUpsert) UpdateProvider() *ChannelMonitorUpsert { return u } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsert) SetCheckMode(v string) *ChannelMonitorUpsert { + u.Set(channelmonitor.FieldCheckMode, v) + return u +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsert) UpdateCheckMode() *ChannelMonitorUpsert { + u.SetExcluded(channelmonitor.FieldCheckMode) + return u +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsert) SetAccountID(v int64) *ChannelMonitorUpsert { + u.Set(channelmonitor.FieldAccountID, v) + return u +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsert) UpdateAccountID() *ChannelMonitorUpsert { + u.SetExcluded(channelmonitor.FieldAccountID) + return u +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsert) AddAccountID(v int64) *ChannelMonitorUpsert { + u.Add(channelmonitor.FieldAccountID, v) + return u +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsert) ClearAccountID() *ChannelMonitorUpsert { + u.SetNull(channelmonitor.FieldAccountID) + return u +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsert) SetAPIMode(v string) *ChannelMonitorUpsert { u.Set(channelmonitor.FieldAPIMode, v) @@ -975,6 +1059,48 @@ func (u *ChannelMonitorUpsertOne) UpdateProvider() *ChannelMonitorUpsertOne { }) } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsertOne) SetCheckMode(v string) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetCheckMode(v) + }) +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsertOne) UpdateCheckMode() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateCheckMode() + }) +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsertOne) SetAccountID(v int64) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetAccountID(v) + }) +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsertOne) AddAccountID(v int64) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddAccountID(v) + }) +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsertOne) UpdateAccountID() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateAccountID() + }) +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsertOne) ClearAccountID() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.ClearAccountID() + }) +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsertOne) SetAPIMode(v string) *ChannelMonitorUpsertOne { return u.Update(func(s *ChannelMonitorUpsert) { @@ -1487,6 +1613,48 @@ func (u *ChannelMonitorUpsertBulk) UpdateProvider() *ChannelMonitorUpsertBulk { }) } +// SetCheckMode sets the "check_mode" field. +func (u *ChannelMonitorUpsertBulk) SetCheckMode(v string) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetCheckMode(v) + }) +} + +// UpdateCheckMode sets the "check_mode" field to the value that was provided on create. +func (u *ChannelMonitorUpsertBulk) UpdateCheckMode() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateCheckMode() + }) +} + +// SetAccountID sets the "account_id" field. +func (u *ChannelMonitorUpsertBulk) SetAccountID(v int64) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetAccountID(v) + }) +} + +// AddAccountID adds v to the "account_id" field. +func (u *ChannelMonitorUpsertBulk) AddAccountID(v int64) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddAccountID(v) + }) +} + +// UpdateAccountID sets the "account_id" field to the value that was provided on create. +func (u *ChannelMonitorUpsertBulk) UpdateAccountID() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateAccountID() + }) +} + +// ClearAccountID clears the value of the "account_id" field. +func (u *ChannelMonitorUpsertBulk) ClearAccountID() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.ClearAccountID() + }) +} + // SetAPIMode sets the "api_mode" field. func (u *ChannelMonitorUpsertBulk) SetAPIMode(v string) *ChannelMonitorUpsertBulk { return u.Update(func(s *ChannelMonitorUpsert) { diff --git a/backend/ent/channelmonitor_update.go b/backend/ent/channelmonitor_update.go index ccc6fa8cc2..f5b52e4e1d 100644 --- a/backend/ent/channelmonitor_update.go +++ b/backend/ent/channelmonitor_update.go @@ -66,6 +66,47 @@ func (_u *ChannelMonitorUpdate) SetNillableProvider(v *channelmonitor.Provider) return _u } +// SetCheckMode sets the "check_mode" field. +func (_u *ChannelMonitorUpdate) SetCheckMode(v string) *ChannelMonitorUpdate { + _u.mutation.SetCheckMode(v) + return _u +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_u *ChannelMonitorUpdate) SetNillableCheckMode(v *string) *ChannelMonitorUpdate { + if v != nil { + _u.SetCheckMode(*v) + } + return _u +} + +// SetAccountID sets the "account_id" field. +func (_u *ChannelMonitorUpdate) SetAccountID(v int64) *ChannelMonitorUpdate { + _u.mutation.ResetAccountID() + _u.mutation.SetAccountID(v) + return _u +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_u *ChannelMonitorUpdate) SetNillableAccountID(v *int64) *ChannelMonitorUpdate { + if v != nil { + _u.SetAccountID(*v) + } + return _u +} + +// AddAccountID adds value to the "account_id" field. +func (_u *ChannelMonitorUpdate) AddAccountID(v int64) *ChannelMonitorUpdate { + _u.mutation.AddAccountID(v) + return _u +} + +// ClearAccountID clears the value of the "account_id" field. +func (_u *ChannelMonitorUpdate) ClearAccountID() *ChannelMonitorUpdate { + _u.mutation.ClearAccountID() + return _u +} + // SetAPIMode sets the "api_mode" field. func (_u *ChannelMonitorUpdate) SetAPIMode(v string) *ChannelMonitorUpdate { _u.mutation.SetAPIMode(v) @@ -453,6 +494,11 @@ func (_u *ChannelMonitorUpdate) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if v, ok := _u.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if v, ok := _u.mutation.APIMode(); ok { if err := channelmonitor.APIModeValidator(v); err != nil { return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)} @@ -517,6 +563,18 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err if value, ok := _u.mutation.Provider(); ok { _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) } + if value, ok := _u.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + } + if value, ok := _u.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedAccountID(); ok { + _spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if _u.mutation.AccountIDCleared() { + _spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64) + } if value, ok := _u.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) } @@ -755,6 +813,47 @@ func (_u *ChannelMonitorUpdateOne) SetNillableProvider(v *channelmonitor.Provide return _u } +// SetCheckMode sets the "check_mode" field. +func (_u *ChannelMonitorUpdateOne) SetCheckMode(v string) *ChannelMonitorUpdateOne { + _u.mutation.SetCheckMode(v) + return _u +} + +// SetNillableCheckMode sets the "check_mode" field if the given value is not nil. +func (_u *ChannelMonitorUpdateOne) SetNillableCheckMode(v *string) *ChannelMonitorUpdateOne { + if v != nil { + _u.SetCheckMode(*v) + } + return _u +} + +// SetAccountID sets the "account_id" field. +func (_u *ChannelMonitorUpdateOne) SetAccountID(v int64) *ChannelMonitorUpdateOne { + _u.mutation.ResetAccountID() + _u.mutation.SetAccountID(v) + return _u +} + +// SetNillableAccountID sets the "account_id" field if the given value is not nil. +func (_u *ChannelMonitorUpdateOne) SetNillableAccountID(v *int64) *ChannelMonitorUpdateOne { + if v != nil { + _u.SetAccountID(*v) + } + return _u +} + +// AddAccountID adds value to the "account_id" field. +func (_u *ChannelMonitorUpdateOne) AddAccountID(v int64) *ChannelMonitorUpdateOne { + _u.mutation.AddAccountID(v) + return _u +} + +// ClearAccountID clears the value of the "account_id" field. +func (_u *ChannelMonitorUpdateOne) ClearAccountID() *ChannelMonitorUpdateOne { + _u.mutation.ClearAccountID() + return _u +} + // SetAPIMode sets the "api_mode" field. func (_u *ChannelMonitorUpdateOne) SetAPIMode(v string) *ChannelMonitorUpdateOne { _u.mutation.SetAPIMode(v) @@ -1155,6 +1254,11 @@ func (_u *ChannelMonitorUpdateOne) check() error { return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)} } } + if v, ok := _u.mutation.CheckMode(); ok { + if err := channelmonitor.CheckModeValidator(v); err != nil { + return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)} + } + } if v, ok := _u.mutation.APIMode(); ok { if err := channelmonitor.APIModeValidator(v); err != nil { return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)} @@ -1236,6 +1340,18 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM if value, ok := _u.mutation.Provider(); ok { _spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value) } + if value, ok := _u.mutation.CheckMode(); ok { + _spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value) + } + if value, ok := _u.mutation.AccountID(); ok { + _spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedAccountID(); ok { + _spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value) + } + if _u.mutation.AccountIDCleared() { + _spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64) + } if value, ok := _u.mutation.APIMode(); ok { _spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value) } diff --git a/backend/ent/channelmonitorhistory.go b/backend/ent/channelmonitorhistory.go index 70dde5422a..fef5109528 100644 --- a/backend/ent/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory.go @@ -3,6 +3,7 @@ package ent import ( + "encoding/json" "fmt" "strings" "time" @@ -11,6 +12,7 @@ import ( "entgo.io/ent/dialect/sql" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistory is the model entity for the ChannelMonitorHistory schema. @@ -30,6 +32,8 @@ type ChannelMonitorHistory struct { PingLatencyMs *int `json:"ping_latency_ms,omitempty"` // Message holds the value of the "message" field. Message string `json:"message,omitempty"` + // Quota holds the value of the "quota" field. + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` // CheckedAt holds the value of the "checked_at" field. CheckedAt time.Time `json:"checked_at,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -63,6 +67,8 @@ func (*ChannelMonitorHistory) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case channelmonitorhistory.FieldQuota: + values[i] = new([]byte) case channelmonitorhistory.FieldID, channelmonitorhistory.FieldMonitorID, channelmonitorhistory.FieldLatencyMs, channelmonitorhistory.FieldPingLatencyMs: values[i] = new(sql.NullInt64) case channelmonitorhistory.FieldModel, channelmonitorhistory.FieldStatus, channelmonitorhistory.FieldMessage: @@ -128,6 +134,14 @@ func (_m *ChannelMonitorHistory) assignValues(columns []string, values []any) er } else if value.Valid { _m.Message = value.String } + case channelmonitorhistory.FieldQuota: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field quota", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.Quota); err != nil { + return fmt.Errorf("unmarshal field quota: %w", err) + } + } case channelmonitorhistory.FieldCheckedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field checked_at", values[i]) @@ -197,6 +211,9 @@ func (_m *ChannelMonitorHistory) String() string { builder.WriteString("message=") builder.WriteString(_m.Message) builder.WriteString(", ") + builder.WriteString("quota=") + builder.WriteString(fmt.Sprintf("%v", _m.Quota)) + builder.WriteString(", ") builder.WriteString("checked_at=") builder.WriteString(_m.CheckedAt.Format(time.ANSIC)) builder.WriteByte(')') diff --git a/backend/ent/channelmonitorhistory/channelmonitorhistory.go b/backend/ent/channelmonitorhistory/channelmonitorhistory.go index 6a9dc00670..ceb11cee67 100644 --- a/backend/ent/channelmonitorhistory/channelmonitorhistory.go +++ b/backend/ent/channelmonitorhistory/channelmonitorhistory.go @@ -27,6 +27,8 @@ const ( FieldPingLatencyMs = "ping_latency_ms" // FieldMessage holds the string denoting the message field in the database. FieldMessage = "message" + // FieldQuota holds the string denoting the quota field in the database. + FieldQuota = "quota" // FieldCheckedAt holds the string denoting the checked_at field in the database. FieldCheckedAt = "checked_at" // EdgeMonitor holds the string denoting the monitor edge name in mutations. @@ -51,6 +53,7 @@ var Columns = []string{ FieldLatencyMs, FieldPingLatencyMs, FieldMessage, + FieldQuota, FieldCheckedAt, } diff --git a/backend/ent/channelmonitorhistory/where.go b/backend/ent/channelmonitorhistory/where.go index afa73f35c8..f7afd7c319 100644 --- a/backend/ent/channelmonitorhistory/where.go +++ b/backend/ent/channelmonitorhistory/where.go @@ -365,6 +365,16 @@ func MessageContainsFold(v string) predicate.ChannelMonitorHistory { return predicate.ChannelMonitorHistory(sql.FieldContainsFold(FieldMessage, v)) } +// QuotaIsNil applies the IsNil predicate on the "quota" field. +func QuotaIsNil() predicate.ChannelMonitorHistory { + return predicate.ChannelMonitorHistory(sql.FieldIsNull(FieldQuota)) +} + +// QuotaNotNil applies the NotNil predicate on the "quota" field. +func QuotaNotNil() predicate.ChannelMonitorHistory { + return predicate.ChannelMonitorHistory(sql.FieldNotNull(FieldQuota)) +} + // CheckedAtEQ applies the EQ predicate on the "checked_at" field. func CheckedAtEQ(v time.Time) predicate.ChannelMonitorHistory { return predicate.ChannelMonitorHistory(sql.FieldEQ(FieldCheckedAt, v)) diff --git a/backend/ent/channelmonitorhistory_create.go b/backend/ent/channelmonitorhistory_create.go index 71034865c9..4d4222d6d3 100644 --- a/backend/ent/channelmonitorhistory_create.go +++ b/backend/ent/channelmonitorhistory_create.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistoryCreate is the builder for creating a ChannelMonitorHistory entity. @@ -83,6 +84,12 @@ func (_c *ChannelMonitorHistoryCreate) SetNillableMessage(v *string) *ChannelMon return _c } +// SetQuota sets the "quota" field. +func (_c *ChannelMonitorHistoryCreate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryCreate { + _c.mutation.SetQuota(v) + return _c +} + // SetCheckedAt sets the "checked_at" field. func (_c *ChannelMonitorHistoryCreate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryCreate { _c.mutation.SetCheckedAt(v) @@ -226,6 +233,10 @@ func (_c *ChannelMonitorHistoryCreate) createSpec() (*ChannelMonitorHistory, *sq _spec.SetField(channelmonitorhistory.FieldMessage, field.TypeString, value) _node.Message = value } + if value, ok := _c.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + _node.Quota = value + } if value, ok := _c.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) _node.CheckedAt = value @@ -401,6 +412,24 @@ func (u *ChannelMonitorHistoryUpsert) ClearMessage() *ChannelMonitorHistoryUpser return u } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsert) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsert { + u.Set(channelmonitorhistory.FieldQuota, v) + return u +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsert) UpdateQuota() *ChannelMonitorHistoryUpsert { + u.SetExcluded(channelmonitorhistory.FieldQuota) + return u +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsert) ClearQuota() *ChannelMonitorHistoryUpsert { + u.SetNull(channelmonitorhistory.FieldQuota) + return u +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsert) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsert { u.Set(channelmonitorhistory.FieldCheckedAt, v) @@ -572,6 +601,27 @@ func (u *ChannelMonitorHistoryUpsertOne) ClearMessage() *ChannelMonitorHistoryUp }) } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsertOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.SetQuota(v) + }) +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsertOne) UpdateQuota() *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.UpdateQuota() + }) +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsertOne) ClearQuota() *ChannelMonitorHistoryUpsertOne { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.ClearQuota() + }) +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsertOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertOne { return u.Update(func(s *ChannelMonitorHistoryUpsert) { @@ -909,6 +959,27 @@ func (u *ChannelMonitorHistoryUpsertBulk) ClearMessage() *ChannelMonitorHistoryU }) } +// SetQuota sets the "quota" field. +func (u *ChannelMonitorHistoryUpsertBulk) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.SetQuota(v) + }) +} + +// UpdateQuota sets the "quota" field to the value that was provided on create. +func (u *ChannelMonitorHistoryUpsertBulk) UpdateQuota() *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.UpdateQuota() + }) +} + +// ClearQuota clears the value of the "quota" field. +func (u *ChannelMonitorHistoryUpsertBulk) ClearQuota() *ChannelMonitorHistoryUpsertBulk { + return u.Update(func(s *ChannelMonitorHistoryUpsert) { + s.ClearQuota() + }) +} + // SetCheckedAt sets the "checked_at" field. func (u *ChannelMonitorHistoryUpsertBulk) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertBulk { return u.Update(func(s *ChannelMonitorHistoryUpsert) { diff --git a/backend/ent/channelmonitorhistory_update.go b/backend/ent/channelmonitorhistory_update.go index a85a8072a9..58d7214068 100644 --- a/backend/ent/channelmonitorhistory_update.go +++ b/backend/ent/channelmonitorhistory_update.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // ChannelMonitorHistoryUpdate is the builder for updating ChannelMonitorHistory entities. @@ -145,6 +146,18 @@ func (_u *ChannelMonitorHistoryUpdate) ClearMessage() *ChannelMonitorHistoryUpda return _u } +// SetQuota sets the "quota" field. +func (_u *ChannelMonitorHistoryUpdate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdate { + _u.mutation.SetQuota(v) + return _u +} + +// ClearQuota clears the value of the "quota" field. +func (_u *ChannelMonitorHistoryUpdate) ClearQuota() *ChannelMonitorHistoryUpdate { + _u.mutation.ClearQuota() + return _u +} + // SetCheckedAt sets the "checked_at" field. func (_u *ChannelMonitorHistoryUpdate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdate { _u.mutation.SetCheckedAt(v) @@ -267,6 +280,12 @@ func (_u *ChannelMonitorHistoryUpdate) sqlSave(ctx context.Context) (_node int, if _u.mutation.MessageCleared() { _spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString) } + if value, ok := _u.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + } + if _u.mutation.QuotaCleared() { + _spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON) + } if value, ok := _u.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) } @@ -435,6 +454,18 @@ func (_u *ChannelMonitorHistoryUpdateOne) ClearMessage() *ChannelMonitorHistoryU return _u } +// SetQuota sets the "quota" field. +func (_u *ChannelMonitorHistoryUpdateOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdateOne { + _u.mutation.SetQuota(v) + return _u +} + +// ClearQuota clears the value of the "quota" field. +func (_u *ChannelMonitorHistoryUpdateOne) ClearQuota() *ChannelMonitorHistoryUpdateOne { + _u.mutation.ClearQuota() + return _u +} + // SetCheckedAt sets the "checked_at" field. func (_u *ChannelMonitorHistoryUpdateOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdateOne { _u.mutation.SetCheckedAt(v) @@ -587,6 +618,12 @@ func (_u *ChannelMonitorHistoryUpdateOne) sqlSave(ctx context.Context) (_node *C if _u.mutation.MessageCleared() { _spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString) } + if value, ok := _u.mutation.Quota(); ok { + _spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value) + } + if _u.mutation.QuotaCleared() { + _spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON) + } if value, ok := _u.mutation.CheckedAt(); ok { _spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value) } diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index 5989d0e743..f0e9dbf16e 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -100,10 +100,14 @@ type Provider string // Provider values. const ( - ProviderOpenai Provider = "openai" - ProviderAnthropic Provider = "anthropic" - ProviderGemini Provider = "gemini" - ProviderGrok Provider = "grok" + ProviderOpenai Provider = "openai" + ProviderAnthropic Provider = "anthropic" + ProviderGemini Provider = "gemini" + ProviderGrok Provider = "grok" + ProviderAntigravity Provider = "antigravity" + ProviderKimi Provider = "kimi" + ProviderZhipu Provider = "zhipu" + ProviderDeepseek Provider = "deepseek" ) func (pr Provider) String() string { @@ -113,7 +117,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: return nil default: return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/group.go b/backend/ent/group.go index 110f06742a..c9795fb58f 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -97,7 +97,7 @@ type Group struct { AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` // STT 每小时价格(USD) AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` - // 是否按上下文长度应用模型阶梯价格 + // 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价 LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"` // 分组逐模型定价;优先级高于渠道和内置定价 ModelPricing json.RawMessage `json:"model_pricing,omitempty"` diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 316870e5a5..f667c3ffbf 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -623,7 +623,9 @@ var ( {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: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, + {Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"}, + {Name: "account_id", Type: field.TypeInt64, Nullable: true}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "endpoint", Type: field.TypeString, Size: 500}, {Name: "api_key_encrypted", Type: field.TypeString}, @@ -648,7 +650,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "channel_monitors_channel_monitor_request_templates_request_template", - Columns: []*schema.Column{ChannelMonitorsColumns[19]}, + Columns: []*schema.Column{ChannelMonitorsColumns[21]}, RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]}, OnDelete: schema.SetNull, }, @@ -657,7 +659,7 @@ var ( { Name: "channelmonitor_enabled_last_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]}, + Columns: []*schema.Column{ChannelMonitorsColumns[13], ChannelMonitorsColumns[16]}, }, { Name: "channelmonitor_provider", @@ -667,17 +669,22 @@ var ( { Name: "channelmonitor_provider_api_mode", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[5]}, + Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[7]}, }, { Name: "channelmonitor_group_name", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[10]}, + Columns: []*schema.Column{ChannelMonitorsColumns[12]}, }, { Name: "channelmonitor_template_id", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[19]}, + Columns: []*schema.Column{ChannelMonitorsColumns[21]}, + }, + { + Name: "channelmonitor_account_id", + Unique: false, + Columns: []*schema.Column{ChannelMonitorsColumns[6]}, }, }, } @@ -733,6 +740,7 @@ var ( {Name: "latency_ms", Type: field.TypeInt, Nullable: true}, {Name: "ping_latency_ms", Type: field.TypeInt, Nullable: true}, {Name: "message", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, + {Name: "quota", Type: field.TypeJSON, Nullable: true}, {Name: "checked_at", Type: field.TypeTime}, {Name: "monitor_id", Type: field.TypeInt64}, } @@ -744,7 +752,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "channel_monitor_histories_channel_monitors_history", - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8]}, RefColumns: []*schema.Column{ChannelMonitorsColumns[0]}, OnDelete: schema.Cascade, }, @@ -753,12 +761,12 @@ var ( { Name: "channelmonitorhistory_monitor_id_model_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[6]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[7]}, }, { Name: "channelmonitorhistory_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorHistoriesColumns[6]}, + Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]}, }, }, } @@ -768,7 +776,7 @@ var ( {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: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, {Name: "extra_headers", Type: field.TypeJSON}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index da0500d9be..ddf50a1c42 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -14608,6 +14608,9 @@ type ChannelMonitorMutation struct { updated_at *time.Time name *string provider *channelmonitor.Provider + check_mode *string + account_id *int64 + addaccount_id *int64 api_mode *string endpoint *string api_key_encrypted *string @@ -14882,6 +14885,112 @@ func (m *ChannelMonitorMutation) ResetProvider() { m.provider = nil } +// SetCheckMode sets the "check_mode" field. +func (m *ChannelMonitorMutation) SetCheckMode(s string) { + m.check_mode = &s +} + +// CheckMode returns the value of the "check_mode" field in the mutation. +func (m *ChannelMonitorMutation) CheckMode() (r string, exists bool) { + v := m.check_mode + if v == nil { + return + } + return *v, true +} + +// OldCheckMode returns the old "check_mode" field's value of the ChannelMonitor entity. +// If the ChannelMonitor 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 *ChannelMonitorMutation) OldCheckMode(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCheckMode is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCheckMode requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCheckMode: %w", err) + } + return oldValue.CheckMode, nil +} + +// ResetCheckMode resets all changes to the "check_mode" field. +func (m *ChannelMonitorMutation) ResetCheckMode() { + m.check_mode = nil +} + +// SetAccountID sets the "account_id" field. +func (m *ChannelMonitorMutation) SetAccountID(i int64) { + m.account_id = &i + m.addaccount_id = nil +} + +// AccountID returns the value of the "account_id" field in the mutation. +func (m *ChannelMonitorMutation) AccountID() (r int64, exists bool) { + v := m.account_id + if v == nil { + return + } + return *v, true +} + +// OldAccountID returns the old "account_id" field's value of the ChannelMonitor entity. +// If the ChannelMonitor 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 *ChannelMonitorMutation) OldAccountID(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAccountID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAccountID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAccountID: %w", err) + } + return oldValue.AccountID, nil +} + +// AddAccountID adds i to the "account_id" field. +func (m *ChannelMonitorMutation) AddAccountID(i int64) { + if m.addaccount_id != nil { + *m.addaccount_id += i + } else { + m.addaccount_id = &i + } +} + +// AddedAccountID returns the value that was added to the "account_id" field in this mutation. +func (m *ChannelMonitorMutation) AddedAccountID() (r int64, exists bool) { + v := m.addaccount_id + if v == nil { + return + } + return *v, true +} + +// ClearAccountID clears the value of the "account_id" field. +func (m *ChannelMonitorMutation) ClearAccountID() { + m.account_id = nil + m.addaccount_id = nil + m.clearedFields[channelmonitor.FieldAccountID] = struct{}{} +} + +// AccountIDCleared returns if the "account_id" field was cleared in this mutation. +func (m *ChannelMonitorMutation) AccountIDCleared() bool { + _, ok := m.clearedFields[channelmonitor.FieldAccountID] + return ok +} + +// ResetAccountID resets all changes to the "account_id" field. +func (m *ChannelMonitorMutation) ResetAccountID() { + m.account_id = nil + m.addaccount_id = nil + delete(m.clearedFields, channelmonitor.FieldAccountID) +} + // SetAPIMode sets the "api_mode" field. func (m *ChannelMonitorMutation) SetAPIMode(s string) { m.api_mode = &s @@ -15731,7 +15840,7 @@ func (m *ChannelMonitorMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ChannelMonitorMutation) Fields() []string { - fields := make([]string, 0, 19) + fields := make([]string, 0, 21) if m.created_at != nil { fields = append(fields, channelmonitor.FieldCreatedAt) } @@ -15744,6 +15853,12 @@ func (m *ChannelMonitorMutation) Fields() []string { if m.provider != nil { fields = append(fields, channelmonitor.FieldProvider) } + if m.check_mode != nil { + fields = append(fields, channelmonitor.FieldCheckMode) + } + if m.account_id != nil { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.api_mode != nil { fields = append(fields, channelmonitor.FieldAPIMode) } @@ -15805,6 +15920,10 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) { return m.Name() case channelmonitor.FieldProvider: return m.Provider() + case channelmonitor.FieldCheckMode: + return m.CheckMode() + case channelmonitor.FieldAccountID: + return m.AccountID() case channelmonitor.FieldAPIMode: return m.APIMode() case channelmonitor.FieldEndpoint: @@ -15852,6 +15971,10 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent return m.OldName(ctx) case channelmonitor.FieldProvider: return m.OldProvider(ctx) + case channelmonitor.FieldCheckMode: + return m.OldCheckMode(ctx) + case channelmonitor.FieldAccountID: + return m.OldAccountID(ctx) case channelmonitor.FieldAPIMode: return m.OldAPIMode(ctx) case channelmonitor.FieldEndpoint: @@ -15919,6 +16042,20 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error { } m.SetProvider(v) return nil + case channelmonitor.FieldCheckMode: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCheckMode(v) + return nil + case channelmonitor.FieldAccountID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAccountID(v) + return nil case channelmonitor.FieldAPIMode: v, ok := value.(string) if !ok { @@ -16032,6 +16169,9 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error { // this mutation. func (m *ChannelMonitorMutation) AddedFields() []string { var fields []string + if m.addaccount_id != nil { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.addinterval_seconds != nil { fields = append(fields, channelmonitor.FieldIntervalSeconds) } @@ -16049,6 +16189,8 @@ func (m *ChannelMonitorMutation) AddedFields() []string { // was not set, or was not defined in the schema. func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) { switch name { + case channelmonitor.FieldAccountID: + return m.AddedAccountID() case channelmonitor.FieldIntervalSeconds: return m.AddedIntervalSeconds() case channelmonitor.FieldJitterSeconds: @@ -16064,6 +16206,13 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) { // type. func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error { switch name { + case channelmonitor.FieldAccountID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAccountID(v) + return nil case channelmonitor.FieldIntervalSeconds: v, ok := value.(int) if !ok { @@ -16093,6 +16242,9 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error { // mutation. func (m *ChannelMonitorMutation) ClearedFields() []string { var fields []string + if m.FieldCleared(channelmonitor.FieldAccountID) { + fields = append(fields, channelmonitor.FieldAccountID) + } if m.FieldCleared(channelmonitor.FieldGroupName) { fields = append(fields, channelmonitor.FieldGroupName) } @@ -16119,6 +16271,9 @@ func (m *ChannelMonitorMutation) FieldCleared(name string) bool { // error if the field is not defined in the schema. func (m *ChannelMonitorMutation) ClearField(name string) error { switch name { + case channelmonitor.FieldAccountID: + m.ClearAccountID() + return nil case channelmonitor.FieldGroupName: m.ClearGroupName() return nil @@ -16151,6 +16306,12 @@ func (m *ChannelMonitorMutation) ResetField(name string) error { case channelmonitor.FieldProvider: m.ResetProvider() return nil + case channelmonitor.FieldCheckMode: + m.ResetCheckMode() + return nil + case channelmonitor.FieldAccountID: + m.ResetAccountID() + return nil case channelmonitor.FieldAPIMode: m.ResetAPIMode() return nil @@ -17756,6 +17917,7 @@ type ChannelMonitorHistoryMutation struct { ping_latency_ms *int addping_latency_ms *int message *string + quota **domain.MonitorQuotaSnapshot checked_at *time.Time clearedFields map[string]struct{} monitor *int64 @@ -18160,6 +18322,55 @@ func (m *ChannelMonitorHistoryMutation) ResetMessage() { delete(m.clearedFields, channelmonitorhistory.FieldMessage) } +// SetQuota sets the "quota" field. +func (m *ChannelMonitorHistoryMutation) SetQuota(dqs *domain.MonitorQuotaSnapshot) { + m.quota = &dqs +} + +// Quota returns the value of the "quota" field in the mutation. +func (m *ChannelMonitorHistoryMutation) Quota() (r *domain.MonitorQuotaSnapshot, exists bool) { + v := m.quota + if v == nil { + return + } + return *v, true +} + +// OldQuota returns the old "quota" field's value of the ChannelMonitorHistory entity. +// If the ChannelMonitorHistory 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 *ChannelMonitorHistoryMutation) OldQuota(ctx context.Context) (v *domain.MonitorQuotaSnapshot, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldQuota is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldQuota requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldQuota: %w", err) + } + return oldValue.Quota, nil +} + +// ClearQuota clears the value of the "quota" field. +func (m *ChannelMonitorHistoryMutation) ClearQuota() { + m.quota = nil + m.clearedFields[channelmonitorhistory.FieldQuota] = struct{}{} +} + +// QuotaCleared returns if the "quota" field was cleared in this mutation. +func (m *ChannelMonitorHistoryMutation) QuotaCleared() bool { + _, ok := m.clearedFields[channelmonitorhistory.FieldQuota] + return ok +} + +// ResetQuota resets all changes to the "quota" field. +func (m *ChannelMonitorHistoryMutation) ResetQuota() { + m.quota = nil + delete(m.clearedFields, channelmonitorhistory.FieldQuota) +} + // SetCheckedAt sets the "checked_at" field. func (m *ChannelMonitorHistoryMutation) SetCheckedAt(t time.Time) { m.checked_at = &t @@ -18257,7 +18468,7 @@ func (m *ChannelMonitorHistoryMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ChannelMonitorHistoryMutation) Fields() []string { - fields := make([]string, 0, 7) + fields := make([]string, 0, 8) if m.monitor != nil { fields = append(fields, channelmonitorhistory.FieldMonitorID) } @@ -18276,6 +18487,9 @@ func (m *ChannelMonitorHistoryMutation) Fields() []string { if m.message != nil { fields = append(fields, channelmonitorhistory.FieldMessage) } + if m.quota != nil { + fields = append(fields, channelmonitorhistory.FieldQuota) + } if m.checked_at != nil { fields = append(fields, channelmonitorhistory.FieldCheckedAt) } @@ -18299,6 +18513,8 @@ func (m *ChannelMonitorHistoryMutation) Field(name string) (ent.Value, bool) { return m.PingLatencyMs() case channelmonitorhistory.FieldMessage: return m.Message() + case channelmonitorhistory.FieldQuota: + return m.Quota() case channelmonitorhistory.FieldCheckedAt: return m.CheckedAt() } @@ -18322,6 +18538,8 @@ func (m *ChannelMonitorHistoryMutation) OldField(ctx context.Context, name strin return m.OldPingLatencyMs(ctx) case channelmonitorhistory.FieldMessage: return m.OldMessage(ctx) + case channelmonitorhistory.FieldQuota: + return m.OldQuota(ctx) case channelmonitorhistory.FieldCheckedAt: return m.OldCheckedAt(ctx) } @@ -18375,6 +18593,13 @@ func (m *ChannelMonitorHistoryMutation) SetField(name string, value ent.Value) e } m.SetMessage(v) return nil + case channelmonitorhistory.FieldQuota: + v, ok := value.(*domain.MonitorQuotaSnapshot) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetQuota(v) + return nil case channelmonitorhistory.FieldCheckedAt: v, ok := value.(time.Time) if !ok { @@ -18448,6 +18673,9 @@ func (m *ChannelMonitorHistoryMutation) ClearedFields() []string { if m.FieldCleared(channelmonitorhistory.FieldMessage) { fields = append(fields, channelmonitorhistory.FieldMessage) } + if m.FieldCleared(channelmonitorhistory.FieldQuota) { + fields = append(fields, channelmonitorhistory.FieldQuota) + } return fields } @@ -18471,6 +18699,9 @@ func (m *ChannelMonitorHistoryMutation) ClearField(name string) error { case channelmonitorhistory.FieldMessage: m.ClearMessage() return nil + case channelmonitorhistory.FieldQuota: + m.ClearQuota() + return nil } return fmt.Errorf("unknown ChannelMonitorHistory nullable field %s", name) } @@ -18497,6 +18728,9 @@ func (m *ChannelMonitorHistoryMutation) ResetField(name string) error { case channelmonitorhistory.FieldMessage: m.ResetMessage() return nil + case channelmonitorhistory.FieldQuota: + m.ResetQuota() + return nil case channelmonitorhistory.FieldCheckedAt: m.ResetCheckedAt() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 4be8d374d6..03a6894e0d 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -635,36 +635,28 @@ func init() { return nil } }() + // channelmonitorDescCheckMode is the schema descriptor for check_mode field. + channelmonitorDescCheckMode := channelmonitorFields[2].Descriptor() + // channelmonitor.DefaultCheckMode holds the default value on creation for the check_mode field. + channelmonitor.DefaultCheckMode = channelmonitorDescCheckMode.Default.(string) + // channelmonitor.CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save. + channelmonitor.CheckModeValidator = channelmonitorDescCheckMode.Validators[0].(func(string) error) // channelmonitorDescAPIMode is the schema descriptor for api_mode field. - channelmonitorDescAPIMode := channelmonitorFields[2].Descriptor() + channelmonitorDescAPIMode := channelmonitorFields[4].Descriptor() // channelmonitor.DefaultAPIMode holds the default value on creation for the api_mode field. channelmonitor.DefaultAPIMode = channelmonitorDescAPIMode.Default.(string) // channelmonitor.APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save. channelmonitor.APIModeValidator = channelmonitorDescAPIMode.Validators[0].(func(string) error) // channelmonitorDescEndpoint is the schema descriptor for endpoint field. - channelmonitorDescEndpoint := channelmonitorFields[3].Descriptor() + channelmonitorDescEndpoint := channelmonitorFields[5].Descriptor() // channelmonitor.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save. - channelmonitor.EndpointValidator = func() func(string) error { - validators := channelmonitorDescEndpoint.Validators - fns := [...]func(string) error{ - validators[0].(func(string) error), - validators[1].(func(string) error), - } - return func(endpoint string) error { - for _, fn := range fns { - if err := fn(endpoint); err != nil { - return err - } - } - return nil - } - }() + channelmonitor.EndpointValidator = channelmonitorDescEndpoint.Validators[0].(func(string) error) // channelmonitorDescAPIKeyEncrypted is the schema descriptor for api_key_encrypted field. - channelmonitorDescAPIKeyEncrypted := channelmonitorFields[4].Descriptor() + channelmonitorDescAPIKeyEncrypted := channelmonitorFields[6].Descriptor() // channelmonitor.APIKeyEncryptedValidator is a validator for the "api_key_encrypted" field. It is called by the builders before save. channelmonitor.APIKeyEncryptedValidator = channelmonitorDescAPIKeyEncrypted.Validators[0].(func(string) error) // channelmonitorDescPrimaryModel is the schema descriptor for primary_model field. - channelmonitorDescPrimaryModel := channelmonitorFields[5].Descriptor() + channelmonitorDescPrimaryModel := channelmonitorFields[7].Descriptor() // channelmonitor.PrimaryModelValidator is a validator for the "primary_model" field. It is called by the builders before save. channelmonitor.PrimaryModelValidator = func() func(string) error { validators := channelmonitorDescPrimaryModel.Validators @@ -682,35 +674,35 @@ func init() { } }() // channelmonitorDescExtraModels is the schema descriptor for extra_models field. - channelmonitorDescExtraModels := channelmonitorFields[6].Descriptor() + channelmonitorDescExtraModels := channelmonitorFields[8].Descriptor() // channelmonitor.DefaultExtraModels holds the default value on creation for the extra_models field. channelmonitor.DefaultExtraModels = channelmonitorDescExtraModels.Default.([]string) // channelmonitorDescGroupName is the schema descriptor for group_name field. - channelmonitorDescGroupName := channelmonitorFields[7].Descriptor() + channelmonitorDescGroupName := channelmonitorFields[9].Descriptor() // channelmonitor.DefaultGroupName holds the default value on creation for the group_name field. channelmonitor.DefaultGroupName = channelmonitorDescGroupName.Default.(string) // channelmonitor.GroupNameValidator is a validator for the "group_name" field. It is called by the builders before save. channelmonitor.GroupNameValidator = channelmonitorDescGroupName.Validators[0].(func(string) error) // channelmonitorDescEnabled is the schema descriptor for enabled field. - channelmonitorDescEnabled := channelmonitorFields[8].Descriptor() + channelmonitorDescEnabled := channelmonitorFields[10].Descriptor() // channelmonitor.DefaultEnabled holds the default value on creation for the enabled field. channelmonitor.DefaultEnabled = channelmonitorDescEnabled.Default.(bool) // channelmonitorDescIntervalSeconds is the schema descriptor for interval_seconds field. - channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor() + channelmonitorDescIntervalSeconds := channelmonitorFields[11].Descriptor() // channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save. channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error) // channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field. - channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor() + channelmonitorDescJitterSeconds := channelmonitorFields[12].Descriptor() // channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field. channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int) // channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save. channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error) // channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field. - channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor() + channelmonitorDescExtraHeaders := channelmonitorFields[16].Descriptor() // channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field. channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string) // channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field. - channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor() + channelmonitorDescBodyOverrideMode := channelmonitorFields[17].Descriptor() // channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field. channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string) // channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save. @@ -808,7 +800,7 @@ func init() { // channelmonitorhistory.MessageValidator is a validator for the "message" field. It is called by the builders before save. channelmonitorhistory.MessageValidator = channelmonitorhistoryDescMessage.Validators[0].(func(string) error) // channelmonitorhistoryDescCheckedAt is the schema descriptor for checked_at field. - channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor() + channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[7].Descriptor() // channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field. channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time) channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin() diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index cb62079316..e14416b765 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -35,15 +35,32 @@ func (ChannelMonitor) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini", "grok"), + Values("openai", "anthropic", "gemini", "grok", + "antigravity", "kimi", "zhipu", "deepseek"), + // check_mode: 'probe' | 'quota' | 'quota_probe' + // probe - LLM 探活(默认,原有行为) + // quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空) + // quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行) + // antigravity 无探活 adapter,仅允许 quota。 + field.String("check_mode"). + Default("probe"). + MaxLen(32). + Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"), + // account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。 + // 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空, + // 监控保留并报「账号未关联」。 + field.Int64("account_id"). + Optional(). + Nillable(), field.String("api_mode"). Default("chat_completions"). MaxLen(32). Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"), + // endpoint: 探活模式必填(service 层校验);quota 模式存空串 + // (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。 field.String("endpoint"). - NotEmpty(). MaxLen(500). - Comment("Provider base origin, e.g. https://api.openai.com"), + Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"), field.String("api_key_encrypted"). NotEmpty(). Sensitive(). @@ -115,5 +132,6 @@ func (ChannelMonitor) Indexes() []ent.Index { index.Fields("provider", "api_mode"), index.Fields("group_name"), index.Fields("template_id"), + index.Fields("account_id"), } } diff --git a/backend/ent/schema/channel_monitor_history.go b/backend/ent/schema/channel_monitor_history.go index 4366e79a67..c2ec4dc25c 100644 --- a/backend/ent/schema/channel_monitor_history.go +++ b/backend/ent/schema/channel_monitor_history.go @@ -3,6 +3,8 @@ package schema import ( "time" + "github.com/Wei-Shaw/sub2api/internal/domain" + "entgo.io/ent" "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema" @@ -43,6 +45,10 @@ func (ChannelMonitorHistory) Fields() []ent.Field { Optional(). Default(""). MaxLen(500), + // quota: 配额模式(check_mode = quota / quota_probe)检测时附带的 + // 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。 + field.JSON("quota", &domain.MonitorQuotaSnapshot{}). + Optional(), field.Time("checked_at"). Default(time.Now), } diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index cf7fe05158..332a01007c 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -39,7 +39,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field { NotEmpty(). MaxLen(100), field.Enum("provider"). - Values("openai", "anthropic", "gemini", "grok"), + Values("openai", "anthropic", "gemini", "grok", + "antigravity", "kimi", "zhipu", "deepseek"), field.String("api_mode"). Default("chat_completions"). MaxLen(32). diff --git a/backend/internal/domain/channel_monitor_quota.go b/backend/internal/domain/channel_monitor_quota.go new file mode 100644 index 0000000000..72c989c38f --- /dev/null +++ b/backend/internal/domain/channel_monitor_quota.go @@ -0,0 +1,64 @@ +package domain + +import "time" + +// 渠道监控「配额模式」的归一化配额快照类型。 +// +// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务 +// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService), +// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化 +// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。 +// +// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做 +// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。 + +// MonitorQuotaTier 单个用量窗口的快照。 +// +// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应): +// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan) +// - "7d" 7 天窗口(Claude/Codex) +// - "7d-sonnet" Claude 7 天 Sonnet 独立额度 +// - "7d-fable" Claude 7 天 Fable 独立额度 +// - "weekly" 周窗口(Kimi/Zhipu coding plan) +// - "daily" 日窗口(Gemini 日配额 / Grok 日请求) +// - "30d" 30 天窗口(Grok 月度) +// - "total" 无窗口语义的总量额度(Antigravity per-model 等) +// +// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、 +// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/ +// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。 +type MonitorQuotaTier struct { + Window string `json:"window"` + Label string `json:"label,omitempty"` + UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算 + Used float64 `json:"used,omitempty"` + Limit float64 `json:"limit,omitempty"` + ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空 +} + +// MonitorQuotaSnapshot 一次配额查询的完整快照。 +// +// Source 取值: +// - "usage" 海外平台(AccountUsageService.GetUsage) +// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage) +// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance) +type MonitorQuotaSnapshot struct { + Source string `json:"source"` + Success bool `json:"success"` + Tiers []MonitorQuotaTier `json:"tiers,omitempty"` + Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额 + Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD) + Currency string `json:"currency,omitempty"` // 主余额币种 + PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level) + // CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误), + // 检测状态据此推导 failed 而非 error。 + CredentialInvalid bool `json:"credential_invalid,omitempty"` + Error string `json:"error,omitempty"` // Success=false 时的错误摘要 + FetchedAt time.Time `json:"fetched_at"` +} + +// MonitorBalance 单币种余额条目。 +type MonitorBalance struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` +} diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index ec5735f0ca..4832be2dec 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch type channelMonitorCreateRequest struct { Name string `json:"name" binding:"required,max=100"` - Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"` + Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` - Endpoint string `json:"endpoint" binding:"required,max=500"` - APIKey string `json:"api_key" binding:"required,max=2000"` + Endpoint string `json:"endpoint" binding:"omitempty,max=500"` + APIKey string `json:"api_key" binding:"omitempty,max=2000"` PrimaryModel string `json:"primary_model" binding:"max=200"` ExtraModels []string `json:"extra_models"` GroupName string `json:"group_name" binding:"max=100"` @@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct { ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` BodyOverride map[string]any `json:"body_override"` + + // CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key + // 可空(条件必填校验在 service 层按模式分支)。 + CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"` + // AccountID: 配额模式关联的账号 ID。 + AccountID *int64 `json:"account_id"` } type channelMonitorUpdateRequest struct { Name *string `json:"name" binding:"omitempty,max=100"` - Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"` + Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint *string `json:"endpoint" binding:"omitempty,max=500"` APIKey *string `json:"api_key" binding:"omitempty,max=2000"` @@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct { ExtraHeaders *map[string]string `json:"extra_headers"` BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` BodyOverride *map[string]any `json:"body_override"` + + // CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。 + CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"` + AccountID *int64 `json:"account_id"` } type channelMonitorResponse struct { @@ -101,25 +112,33 @@ type channelMonitorResponse struct { ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode"` BodyOverride map[string]any `json:"body_override"` + + // 配额模式:check_mode + 关联账号 + 主模型最近配额快照 + // (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。 + CheckMode string `json:"check_mode"` + AccountID *int64 `json:"account_id"` + LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"` } type channelMonitorCheckResultResponse struct { - Model string `json:"model"` - Status string `json:"status"` - LatencyMs *int `json:"latency_ms"` - PingLatencyMs *int `json:"ping_latency_ms"` - Message string `json:"message"` - CheckedAt string `json:"checked_at"` + Model string `json:"model"` + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + Message string `json:"message"` + CheckedAt string `json:"checked_at"` + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` } type channelMonitorHistoryItemResponse struct { - ID int64 `json:"id"` - Model string `json:"model"` - Status string `json:"status"` - LatencyMs *int `json:"latency_ms"` - PingLatencyMs *int `json:"ping_latency_ms"` - Message string `json:"message"` - CheckedAt string `json:"checked_at"` + ID int64 `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + LatencyMs *int `json:"latency_ms"` + PingLatencyMs *int `json:"ping_latency_ms"` + Message string `json:"message"` + CheckedAt string `json:"checked_at"` + Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"` } // maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。 @@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse ExtraHeaders: headers, BodyOverrideMode: m.BodyOverrideMode, BodyOverride: m.BodyOverride, - // PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。 + CheckMode: m.CheckMode, + AccountID: m.AccountID, + // PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota + // 由 List handler 在批量聚合后填充。 } if m.LastCheckedAt != nil { s := m.LastCheckedAt.UTC().Format(time.RFC3339) @@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp PingLatencyMs: r.PingLatencyMs, Message: r.Message, CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339), + Quota: r.Quota, } } @@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito PingLatencyMs: e.PingLatencyMs, Message: e.Message, CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339), + Quota: e.Quota, } } @@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta resp.PrimaryStatus = summary.PrimaryStatus resp.PrimaryLatencyMs = summary.PrimaryLatencyMs resp.Availability7d = summary.Availability7d + resp.LatestQuota = summary.LatestQuota resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels)) for _, e := range summary.ExtraModels { resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{ @@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) { ExtraHeaders: req.ExtraHeaders, BodyOverrideMode: req.BodyOverrideMode, BodyOverride: req.BodyOverride, + CheckMode: req.CheckMode, + AccountID: req.AccountID, }) if err != nil { response.ErrorFrom(c, err) @@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) { ExtraHeaders: req.ExtraHeaders, BodyOverrideMode: req.BodyOverrideMode, BodyOverride: req.BodyOverride, + CheckMode: req.CheckMode, + AccountID: req.AccountID, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a5f3d41d90..01a066165b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, GrokDefaultTextModel: settings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 5884dc392e..e2399d0891 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -332,6 +332,7 @@ type UpdateSettingsRequest struct { ChannelMonitorMode *string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"` // Grok model mapping policy GrokDefaultTextModel *string `json:"grok_default_text_model"` @@ -1889,6 +1890,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ChannelMonitorHideThroughput }(), + ChannelMonitorShowQuota: func() bool { + if req.ChannelMonitorShowQuota != nil { + return *req.ChannelMonitorShowQuota + } + return previousSettings.ChannelMonitorShowQuota + }(), GrokDefaultTextModel: func() string { if req.GrokDefaultTextModel != nil { return *req.GrokDefaultTextModel @@ -2342,6 +2349,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ChannelMonitorMode: updatedSettings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota, GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index 7e42c5a74d..173ca56dfe 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -3,6 +3,7 @@ package handler import ( "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/admin" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -39,6 +40,15 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool { return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1 } +// quotaVisible 返回用户端是否展示配额/余额快照(channel_monitor_show_quota, +// fail-closed:未配置/非 "true" 一律视为关闭)。settingService 为 nil 时 fail-closed。 +func (h *ChannelMonitorUserHandler) quotaVisible(c *gin.Context) bool { + if h.settingService == nil { + return false + } + return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).ShowQuota +} + // --- Response --- type channelMonitorUserListItem struct { @@ -53,6 +63,9 @@ type channelMonitorUserListItem struct { Availability7d float64 `json:"availability_7d"` ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"` Timeline []channelMonitorUserTimelinePoint `json:"timeline"` + // LatestQuota 主模型最近配额快照;channel_monitor_show_quota=false 时 + // 由 userMonitorViewToItem 的调用方传入 false 剥离(服务端脱敏,非仅前端隐藏)。 + LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"` } // channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。 @@ -82,7 +95,7 @@ type channelMonitorUserModelStat struct { AvgLatency7dMs *int `json:"avg_latency_7d_ms"` } -func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem { +func userMonitorViewToItem(v *service.UserMonitorView, includeQuota bool) channelMonitorUserListItem { extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels)) for _, e := range v.ExtraModels { extras = append(extras, dto.ChannelMonitorExtraModelStatus{ @@ -100,7 +113,7 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339), }) } - return channelMonitorUserListItem{ + item := channelMonitorUserListItem{ ID: v.ID, Name: v.Name, Provider: v.Provider, @@ -113,6 +126,10 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte ExtraModels: extras, Timeline: timeline, } + if includeQuota { + item.LatestQuota = v.LatestQuota + } + return item } func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse { @@ -150,9 +167,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) { response.ErrorFrom(c, err) return } + includeQuota := h.quotaVisible(c) items := make([]channelMonitorUserListItem, 0, len(views)) for _, v := range views { - items = append(items, userMonitorViewToItem(v)) + items = append(items, userMonitorViewToItem(v, includeQuota)) } response.Success(c, gin.H{"items": items}) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 5ab5eaa68b..f0284eb55e 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -305,6 +305,7 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -414,6 +415,7 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index a415d67e34..f0785de996 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -107,6 +107,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, diff --git a/backend/internal/repository/channel_monitor_quota_integration_test.go b/backend/internal/repository/channel_monitor_quota_integration_test.go new file mode 100644 index 0000000000..2ffc2ae692 --- /dev/null +++ b/backend/internal/repository/channel_monitor_quota_integration_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 配额模式 repo 层集成测试: +// - Create/GetByID/Update 的 check_mode/account_id 往返 +// - InsertHistoryBatch → ListHistory / ListLatestForMonitorIDs 的 quota JSONB 回读 +// (裸 SQL 列 + scanMonitorQuota),以及探活模式旧行 quota=NULL 的兼容 +// +// 注意 channelMonitorRepository 的 GetByID/裸 SQL 走全局 client(不识别 tx ctx), +// 因此本文件用 integrationEntClient 直连 + t.Cleanup 显式清理,不走 testEntTx 回滚。 + +func TestChannelMonitorQuotaModeRoundTrip(t *testing.T) { + ctx := context.Background() + repo := NewChannelMonitorRepository(integrationEntClient, integrationDB) + + account := mustCreateAccount(t, integrationEntClient, &service.Account{ + Name: "quota-linked-kimi", Platform: domain.PlatformKimi, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi", "account_mode": service.AccountModeCoding}, + }) + t.Cleanup(func() { + _ = integrationEntClient.Account.DeleteOneID(account.ID).Exec(ctx) + }) + + created := &service.ChannelMonitor{ + Name: "kimi-quota-roundtrip", + Provider: service.MonitorProviderKimi, + APIMode: service.MonitorAPIModeChatCompletions, + Endpoint: "", + APIKey: "encrypted-empty", + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: service.MonitorCheckModeQuota, + AccountID: &account.ID, + BodyOverrideMode: service.MonitorBodyOverrideModeOff, + } + require.NoError(t, repo.Create(ctx, created)) + t.Cleanup(func() { + _ = repo.Delete(ctx, created.ID) + }) + + loaded, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeQuota, loaded.CheckMode) + require.NotNil(t, loaded.AccountID) + require.Equal(t, account.ID, *loaded.AccountID) + + // Update:切换模式并清空关联账号(probe 化)。 + loaded.CheckMode = service.MonitorCheckModeProbe + loaded.AccountID = nil + loaded.Endpoint = "https://api.moonshot.cn" + require.NoError(t, repo.Update(ctx, loaded)) + + reloaded, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeProbe, reloaded.CheckMode) + require.Nil(t, reloaded.AccountID) + + // 重新绑定账号。 + reloaded.CheckMode = service.MonitorCheckModeQuotaProbe + reloaded.AccountID = &account.ID + require.NoError(t, repo.Update(ctx, reloaded)) + final, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, service.MonitorCheckModeQuotaProbe, final.CheckMode) + require.NotNil(t, final.AccountID) +} + +func TestChannelMonitorHistoryQuotaRoundTrip(t *testing.T) { + ctx := context.Background() + repo := NewChannelMonitorRepository(integrationEntClient, integrationDB) + + monitor := &service.ChannelMonitor{ + Name: "quota-history-roundtrip", + Provider: service.MonitorProviderOpenAI, + APIMode: service.MonitorAPIModeChatCompletions, + Endpoint: "https://api.openai.com", + APIKey: "encrypted", + PrimaryModel: "gpt-test", + ExtraModels: []string{"gpt-extra"}, + Enabled: true, + IntervalSeconds: 60, + BodyOverrideMode: service.MonitorBodyOverrideModeOff, + } + require.NoError(t, repo.Create(ctx, monitor)) + t.Cleanup(func() { + _ = repo.Delete(ctx, monitor.ID) // histories 级联删除 + }) + + now := time.Now().UTC() + rows := []*service.ChannelMonitorHistoryRow{ + { + MonitorID: monitor.ID, Model: "gpt-test", Status: service.MonitorStatusOperational, + Message: "ok", CheckedAt: now, + Quota: &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: true, + PlanLevel: "PRO", + Tiers: []domain.MonitorQuotaTier{ + {Window: "5h", UsedPercent: 42.5, Used: 17, Limit: 40, ResetAt: now.Add(time.Hour).Format(time.RFC3339)}, + }, + FetchedAt: now, + }, + }, + { + // 探活模式旧行:无 quota(NULL 兼容)。 + MonitorID: monitor.ID, Model: "gpt-extra", Status: service.MonitorStatusOperational, + Message: "ok", CheckedAt: now, + }, + } + require.NoError(t, repo.InsertHistoryBatch(ctx, rows)) + + history, err := repo.ListHistory(ctx, monitor.ID, "", 10) + require.NoError(t, err) + require.Len(t, history, 2) + + byModel := map[string]*service.ChannelMonitorHistoryEntry{} + for _, entry := range history { + byModel[entry.Model] = entry + } + withQuota := byModel["gpt-test"] + require.NotNil(t, withQuota.Quota) + require.True(t, withQuota.Quota.Success) + require.Equal(t, "usage", withQuota.Quota.Source) + require.Equal(t, "PRO", withQuota.Quota.PlanLevel) + require.Len(t, withQuota.Quota.Tiers, 1) + require.Equal(t, "5h", withQuota.Quota.Tiers[0].Window) + require.InDelta(t, 42.5, withQuota.Quota.Tiers[0].UsedPercent, 0.001) + require.Nil(t, byModel["gpt-extra"].Quota, "probe rows must read back as NULL quota") + + // 用户视图聚合:主模型最近一行带快照。 + latest, err := repo.ListLatestForMonitorIDs(ctx, []int64{monitor.ID}) + require.NoError(t, err) + primaryRows := latest[monitor.ID] + require.NotEmpty(t, primaryRows) + var primaryQuota *domain.MonitorQuotaSnapshot + for _, row := range primaryRows { + if row.Model == "gpt-test" { + primaryQuota = row.Quota + } + } + require.NotNil(t, primaryQuota, "ListLatestForMonitorIDs must surface quota for the primary model") + require.True(t, primaryQuota.Success) +} diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index aa8b12ff8b..c4586bc38c 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -10,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" + "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" @@ -51,10 +53,14 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe SetJitterSeconds(m.JitterSeconds). SetCreatedBy(m.CreatedBy). SetExtraHeaders(channelMonitorHeadersForPersistence(m)). - SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) + SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)). + SetCheckMode(defaultCheckModeRepo(m.CheckMode)) if m.TemplateID != nil { builder = builder.SetTemplateID(*m.TemplateID) } + if m.AccountID != nil { + builder = builder.SetAccountID(*m.AccountID) + } if m.BodyOverride != nil { builder = builder.SetBodyOverride(m.BodyOverride) } @@ -118,12 +124,18 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe SetIntervalSeconds(m.IntervalSeconds). SetJitterSeconds(m.JitterSeconds). SetExtraHeaders(channelMonitorHeadersForPersistence(m)). - SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) + SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)). + SetCheckMode(defaultCheckModeRepo(m.CheckMode)) if m.TemplateID != nil { updater = updater.SetTemplateID(*m.TemplateID) } else { updater = updater.ClearTemplateID() } + if m.AccountID != nil { + updater = updater.SetAccountID(*m.AccountID) + } else { + updater = updater.ClearAccountID() + } if m.BodyOverride != nil { updater = updater.SetBodyOverride(m.BodyOverride) } else { @@ -237,6 +249,9 @@ func (r *channelMonitorRepository) InsertHistoryBatch(ctx context.Context, rows if row.PingLatencyMs != nil { c = c.SetPingLatencyMs(*row.PingLatencyMs) } + if row.Quota != nil { + c = c.SetQuota(row.Quota) + } bulk = append(bulk, c) } if _, err := client.ChannelMonitorHistory.CreateBulk(bulk...).Save(ctx); err != nil { @@ -276,6 +291,7 @@ func (r *channelMonitorRepository) ListHistory(ctx context.Context, monitorID in PingLatencyMs: row.PingLatencyMs, Message: row.Message, CheckedAt: row.CheckedAt, + Quota: row.Quota, } out = append(out, entry) } @@ -324,6 +340,20 @@ func assignNullInt(dst **int, n sql.NullInt64) { *dst = &v } +// scanMonitorQuota 把裸 SQL 读出的 JSONB quota 列解包为配额快照。 +// NULL(探活模式旧行)返回 nil;解析失败也返回 nil 并由调用方日志感知, +// 不阻断列表渲染(与聚合层"失败仅日志"的原则一致)。 +func scanMonitorQuota(data []byte) *domain.MonitorQuotaSnapshot { + if len(data) == 0 { + return nil + } + snapshot := &domain.MonitorQuotaSnapshot{} + if err := json.Unmarshal(data, snapshot); err != nil { + return nil + } + return snapshot +} + // ComputeAvailability 计算指定窗口内每个模型的可用率与平均延迟。 // "可用" = status IN (operational, degraded)。 // @@ -396,7 +426,7 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, } const q = ` SELECT DISTINCT ON (monitor_id, model) - monitor_id, model, status, latency_ms, ping_latency_ms, checked_at + monitor_id, model, status, latency_ms, ping_latency_ms, checked_at, quota FROM channel_monitor_histories WHERE monitor_id = ANY($1) ORDER BY monitor_id, model, checked_at DESC @@ -411,11 +441,13 @@ func (r *channelMonitorRepository) ListLatestForMonitorIDs(ctx context.Context, var monitorID int64 l := &service.ChannelMonitorLatest{} var latency, ping sql.NullInt64 - if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt); err != nil { + var quota []byte + if err := rows.Scan(&monitorID, &l.Model, &l.Status, &latency, &ping, &l.CheckedAt, "a); err != nil { return nil, fmt.Errorf("scan latest batch row: %w", err) } assignNullInt(&l.LatencyMs, latency) assignNullInt(&l.PingLatencyMs, ping) + l.Quota = scanMonitorQuota(quota) out[monitorID] = append(out[monitorID], l) } if err := rows.Err(); err != nil { @@ -757,12 +789,17 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor { ExtraHeaders: headers, BodyOverrideMode: row.BodyOverrideMode, BodyOverride: row.BodyOverride, + CheckMode: defaultCheckModeRepo(row.CheckMode), DuplicateOperationID: duplicateOperationID, } if row.TemplateID != nil { id := *row.TemplateID out.TemplateID = &id } + if row.AccountID != nil { + id := *row.AccountID + out.AccountID = &id + } return out } @@ -807,6 +844,14 @@ func defaultAPIModeRepo(apiMode string) string { return apiMode } +// defaultCheckModeRepo 空串归一为 probe(存量行有列默认值,这里兜底防御)。 +func defaultCheckModeRepo(checkMode string) string { + if checkMode == "" { + return "probe" + } + return checkMode +} + func emptySliceIfNil(in []string) []string { if in == nil { return []string{} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index b0b1df7bc0..7f3b25f763 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -988,6 +988,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, + "channel_monitor_show_quota": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1298,6 +1299,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, + "channel_monitor_show_quota": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, diff --git a/backend/internal/service/channel_monitor_aggregator.go b/backend/internal/service/channel_monitor_aggregator.go index 09020f5fa4..b6f632bf4b 100644 --- a/backend/internal/service/channel_monitor_aggregator.go +++ b/backend/internal/service/channel_monitor_aggregator.go @@ -204,6 +204,8 @@ func buildStatusSummary( if l, ok := latestByModel[primary]; ok { summary.PrimaryStatus = l.Status summary.PrimaryLatencyMs = l.LatencyMs + // 配额快照只挂主模型行(quota 模式唯一行 / quota_probe 的主行)。 + summary.LatestQuota = l.Quota } if a, ok := availByModel[primary]; ok { summary.Availability7d = a.AvailabilityPct @@ -242,6 +244,7 @@ func buildUserViewFromSummary( } if primaryLatest != nil { view.PrimaryPingLatencyMs = primaryLatest.PingLatencyMs + view.LatestQuota = primaryLatest.Quota } return view } diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 910a6e80d9..71177db0bd 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -170,6 +170,11 @@ type providerAdapter struct { var providerAdapters = map[string]providerAdapter{ MonitorProviderOpenAI: providerOpenAIChatAdapter, MonitorProviderGrok: providerGrokChatAdapter, + // 国产 3 家(配额模式引入):均为 OpenAI 兼容 Chat Completions, + // 仅智谱路径前缀不同(/api/paas/v4/chat/completions)。 + MonitorProviderKimi: providerKimiChatAdapter, + MonitorProviderZhipu: providerZhipuChatAdapter, + MonitorProviderDeepseek: providerDeepseekChatAdapter, MonitorProviderAnthropic: { buildPath: func(string) string { return providerAnthropicPath }, buildBody: func(model, prompt string) ([]byte, error) { @@ -212,6 +217,15 @@ var providerOpenAIChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPat //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 var providerGrokChatAdapter = newOpenAICompatibleChatAdapter(providerGrokPath) +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerKimiChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) + +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath) + +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) + func newOpenAICompatibleChatAdapter(path string) providerAdapter { return providerAdapter{ buildPath: func(string) string { return path }, @@ -257,13 +271,6 @@ func providerAdapterFor(provider, apiMode string) (providerAdapter, string, bool return adapter, MonitorAPIModeChatCompletions, ok } -// isSupportedProvider 校验 provider 字符串是否在 adapter 表中。 -// 供 validate.go 的 validateProvider 复用,避免两份 switch 漂移。 -func isSupportedProvider(p string) bool { - _, ok := providerAdapters[p] - return ok -} - // callProvider 通过 providerAdapters 分发到具体实现。 // opts 承载用户的自定义 headers / body 覆盖(可为 nil)。 // @@ -447,6 +454,10 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{ MonitorProviderGrok: {"model": true, "messages": true, "stream": true}, MonitorProviderAnthropic: {"model": true, "messages": true}, MonitorProviderGemini: {"contents": true}, + // 国产 3 家与 OpenAI Chat Completions 同构。 + MonitorProviderKimi: {"model": true, "messages": true, "stream": true}, + MonitorProviderZhipu: {"model": true, "messages": true, "stream": true}, + MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true}, } func checkAPIMode(opts *CheckOptions) string { @@ -463,8 +474,20 @@ func bodyMergeDenyKey(provider, apiMode string) string { return provider } +// isOpenAICompatibleChatProvider 该 provider 的探活请求是否为 OpenAI Chat +// Completions 同构(replace 模式的 body 校验按 messages 必填处理)。 +func isOpenAICompatibleChatProvider(provider string) bool { + switch provider { + case MonitorProviderOpenAI, MonitorProviderGrok, + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek: + return true + default: + return false + } +} + func validateReplaceRequestBody(provider, apiMode string, body map[string]any) error { - if provider != MonitorProviderOpenAI && provider != MonitorProviderGrok { + if !isOpenAICompatibleChatProvider(provider) { return nil } switch defaultAPIMode(apiMode) { diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 3375ee6ac9..2357646337 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -199,7 +199,7 @@ func TestGrokMonitorConfiguration(t *testing.T) { if err := validateProvider(MonitorProviderGrok); err != nil { t.Fatalf("grok provider should be supported: %v", err) } - if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, ""); got != MonitorDefaultGrokModel { + if got := normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""); got != MonitorDefaultGrokModel { t.Fatalf("expected default Grok model %q, got %q", MonitorDefaultGrokModel, got) } if err := validateAPIMode(MonitorProviderGrok, MonitorAPIModeChatCompletions); err != nil { diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 6a41add298..bf7676ed50 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -45,10 +45,12 @@ const ( monitorChallengeMin = 1 monitorChallengeMax = 50 - // providerOpenAIPath OpenAI Chat Completions 路径。 + // providerOpenAIPath OpenAI Chat Completions 路径(Kimi / DeepSeek 同为 OpenAI 兼容)。 providerOpenAIPath = "/v1/chat/completions" // providerGrokPath Grok OpenAI-compatible Chat Completions 路径。 providerGrokPath = "/v1/chat/completions" + // providerZhipuPath 智谱 OpenAI 兼容 Chat Completions 路径(前缀与官方不同)。 + providerZhipuPath = "/api/paas/v4/chat/completions" // providerOpenAIResponsesPath OpenAI Responses API 路径。 providerOpenAIResponsesPath = "/v1/responses" // providerAnthropicPath Anthropic Messages 路径。 @@ -56,11 +58,42 @@ const ( // providerGeminiPathTemplate Gemini generateContent 路径模板(含 model 占位)。 providerGeminiPathTemplate = "/v1beta/models/%s:generateContent" - // MonitorProviderOpenAI / Anthropic / Gemini / Grok provider 字符串常量(也是 ent enum 的实际值)。 - MonitorProviderOpenAI = "openai" - MonitorProviderAnthropic = "anthropic" - MonitorProviderGemini = "gemini" - MonitorProviderGrok = "grok" + // MonitorProviderOpenAI 等 provider 字符串常量(也是 ent enum 的实际值)。 + // 后 4 个 provider(antigravity/kimi/zhipu/deepseek)为配额模式引入: + // antigravity 无探活 adapter(仅配额),其余 3 个复用 OpenAI 兼容探活。 + MonitorProviderOpenAI = "openai" + MonitorProviderAnthropic = "anthropic" + MonitorProviderGemini = "gemini" + MonitorProviderGrok = "grok" + MonitorProviderAntigravity = "antigravity" + MonitorProviderKimi = "kimi" + MonitorProviderZhipu = "zhipu" + MonitorProviderDeepseek = "deepseek" + + // MonitorCheckMode 检测模式(channel_monitors.check_mode)。 + // probe - LLM 探活(默认,原有行为) + // quota - 仅查关联账号用量/余额,零 LLM 成本 + // quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行) + MonitorCheckModeProbe = "probe" + MonitorCheckModeQuota = "quota" + MonitorCheckModeQuotaProbe = "quota_probe" + + // MonitorDefaultQuotaModel 是 quota 模式监控未显式指定模型时占位的虚拟模型名 + // (primary_model 列 NotEmpty,用 "quota" 让历史行/时间线机制无需特判)。 + MonitorDefaultQuotaModel = "quota" + + // monitorQuotaFetchCacheTTL 配额快照缓存时长。多个监控可能关联同一账号, + // 而 interval 最小 15s 且国产配额服务无缓存,TTL 防止打爆上游配额端点。 + monitorQuotaFetchCacheTTL = 5 * time.Minute + // monitorQuotaErrorCacheTTL 失败快照的负缓存时长:失败也短缓存,避免 + // 故障/凭据失效期间每次调度(最小 15s)都带真实凭据打上游;到期自动重试。 + monitorQuotaErrorCacheTTL = 60 * time.Second + // monitorQuotaFetchTimeout singleflight 内单次配额抓取的总超时 + // (脱离调用方 ctx,防止某个监控的取消波及共享同一账号的其他监控)。 + monitorQuotaFetchTimeout = 45 * time.Second + // monitorQuotaDegradedUsedPercent 任一用量窗口使用率超过该阈值时, + // 配额检查状态记为 degraded(对齐账号页展示阈值)。 + monitorQuotaDegradedUsedPercent = 90.0 // MonitorDefaultGrokModel 是新增 Grok 监控未显式指定模型时使用的轻量测活模型。 MonitorDefaultGrokModel = "grok-4.5" @@ -118,7 +151,16 @@ var ( "CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found", ) ErrChannelMonitorInvalidProvider = infraerrors.BadRequest( - "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok", + "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek", + ) + ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest( + "CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota", + ) + ErrChannelMonitorAccountRequired = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_REQUIRED", "account_id is required for quota-based check_mode", + ) + ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest( + "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go new file mode 100644 index 0000000000..a4fa992c30 --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -0,0 +1,506 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "golang.org/x/sync/singleflight" +) + +// 渠道监控「配额模式」的配额抓取器。 +// +// 不直接对接上游,而是把账号侧现成的用量服务归一成 domain.MonitorQuotaSnapshot: +// - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsage +// - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsage +// - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalance +// (zhipu payg 无公开余额端点,QueryBalance 会返回该错误,原样透出) +// +// Fetch 永不返回 error:所有失败都降级为 Success=false 的快照照常入库, +// 由 deriveQuotaCheckResult 推导为 failed/error 状态。 +// +// 多个监控可能关联同一账号,而 interval 最小 15s 且国产配额服务自身无缓存, +// 所以快照统一带 TTL 缓存(成功 monitorQuotaFetchCacheTTL、失败 +// monitorQuotaErrorCacheTTL 负缓存),防止打爆上游配额端点;同账号的并发 +// 抓取由 singleflight 合并为一次上游查询。 + +// monitorUsageSource 海外平台账号用量查询(AccountUsageService 天然满足)。 +type monitorUsageSource interface { + GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) +} + +// monitorCNQuotaSource 国产 coding plan 滚动窗口额度探测(CNProviderQuotaService 天然满足)。 +type monitorCNQuotaSource interface { + QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) +} + +// monitorCNBalanceSource 国产 payg 余额探测(CNProviderBalanceService 天然满足)。 +type monitorCNBalanceSource interface { + QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) +} + +// monitorAccountSource 账号加载(AccountRepository 天然满足)。 +type monitorAccountSource interface { + GetByID(ctx context.Context, id int64) (*Account, error) +} + +// ChannelMonitorQuotaFetcher 配额抓取器(成功/失败快照均带 TTL 缓存, +// 同账号并发抓取由 singleflight 合并)。 +type ChannelMonitorQuotaFetcher struct { + usage monitorUsageSource + cnQuota monitorCNQuotaSource + cnBalance monitorCNBalanceSource + accounts monitorAccountSource + + mu sync.Mutex + cache map[int64]monitorQuotaCacheEntry + flight singleflight.Group +} + +type monitorQuotaCacheEntry struct { + snapshot *domain.MonitorQuotaSnapshot + expiry time.Time +} + +// NewChannelMonitorQuotaFetcher 构造配额抓取器。 +// 参数取具体服务类型以便 wire 直连;单元测试在同包内用 struct 字面量注入 stub。 +func NewChannelMonitorQuotaFetcher( + usage *AccountUsageService, + cnQuota *CNProviderQuotaService, + cnBalance *CNProviderBalanceService, + accounts AccountRepository, +) *ChannelMonitorQuotaFetcher { + f := &ChannelMonitorQuotaFetcher{cache: make(map[int64]monitorQuotaCacheEntry)} + if usage != nil { + f.usage = usage + } + if cnQuota != nil { + f.cnQuota = cnQuota + } + if cnBalance != nil { + f.cnBalance = cnBalance + } + if accounts != nil { + f.accounts = accounts + } + return f +} + +// LoadAccount 加载账号(不走缓存)。供 Create/Update 时校验 +// provider 与 account.platform 一致;账号不存在时返回错误。 +func (f *ChannelMonitorQuotaFetcher) LoadAccount(ctx context.Context, id int64) (*Account, error) { + if f == nil || f.accounts == nil { + return nil, fmt.Errorf("quota fetcher is not configured") + } + return f.accounts.GetByID(ctx, id) +} + +// Fetch 抓取账号的最新配额快照。永不返回 error:失败降级为 +// Success=false 快照(Error 带摘要),保证检测历史的时间线连续。 +func (f *ChannelMonitorQuotaFetcher) Fetch(ctx context.Context, accountID int64) *domain.MonitorQuotaSnapshot { + if f == nil { + // fail-closed:fetcher 未注入(存量测试构造)时不 panic,降级为错误快照。 + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + + now := time.Now() + + if cached, ok := f.cachedSnapshot(accountID, now); ok { + return cached + } + + // singleflight 合并同账号并发抓取;脱离调用方 ctx(仿 CN 配额服务), + // 避免某个监控的取消波及共享同一账号的其他监控。 + key := "monitor-quota:" + strconv.FormatInt(accountID, 10) + ch := f.flight.DoChan(key, func() (any, error) { + fetchCtx, cancel := context.WithTimeout(context.Background(), monitorQuotaFetchTimeout) + defer cancel() + snapshot := f.fetchUncached(fetchCtx, accountID, time.Now()) + // 失败也进短 TTL 负缓存:凭据失效/故障期间不必每次调度都打上游。 + ttl := monitorQuotaFetchCacheTTL + if !snapshot.Success { + ttl = monitorQuotaErrorCacheTTL + } + f.storeSnapshot(accountID, snapshot, time.Now().Add(ttl)) + return snapshot, nil + }) + select { + case <-ctx.Done(): + return quotaErrorSnapshot("usage", "context canceled", now) + case res := <-ch: + snapshot, ok := res.Val.(*domain.MonitorQuotaSnapshot) + if res.Err != nil || !ok || snapshot == nil { + return quotaErrorSnapshot("usage", "quota fetch failed", now) + } + return snapshot + } +} + +func (f *ChannelMonitorQuotaFetcher) cachedSnapshot(accountID int64, now time.Time) (*domain.MonitorQuotaSnapshot, bool) { + f.mu.Lock() + defer f.mu.Unlock() + entry, ok := f.cache[accountID] + if !ok || now.After(entry.expiry) { + return nil, false + } + return entry.snapshot, true +} + +func (f *ChannelMonitorQuotaFetcher) storeSnapshot(accountID int64, snapshot *domain.MonitorQuotaSnapshot, expiry time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + f.cache[accountID] = monitorQuotaCacheEntry{snapshot: snapshot, expiry: expiry} +} + +func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", now) + } + + account, err := f.LoadAccount(ctx, accountID) + if err != nil || account == nil { + // FK ON DELETE SET NULL 后 account_id 可能为空/失效;显式报「账号未关联」, + // 推导为 degraded(配置问题,不是渠道故障)。 + slog.Warn("channel_monitor: load linked account failed", + "account_id", accountID, "error", err) + return quotaErrorSnapshot("usage", "linked account not found", now) + } + + switch account.Platform { + case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: + if account.IsCodingPlan() { + return f.fetchCNQuota(ctx, accountID, now) + } + return f.fetchCNBalance(ctx, accountID, now) + default: + return f.fetchUsage(ctx, accountID, now) + } +} + +// fetchUsage 海外平台:AccountUsageService.GetUsage → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.usage == nil { + return quotaErrorSnapshot("usage", "usage service is not configured", now) + } + usage, err := f.usage.GetUsage(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + if usage == nil { + return quotaErrorSnapshot("usage", "usage service returned no data", now) + } + // openai/gemini/antigravity/grok 的失败多走「值通道」(err==nil 但错误 + // 降级在 UsageInfo 字段里),必须显式识别,否则会被误判为 operational。 + if failed, credInvalid, msg := usageFailureInfo(usage); failed { + return &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: false, + CredentialInvalid: credInvalid, + Error: truncateMessage(sanitizeErrorMessage(msg)), + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "usage", + Success: true, + PlanLevel: usage.SubscriptionTier, + Tiers: usageQuotaTiers(usage), + FetchedAt: now, + } + if snapshot.PlanLevel == "" { + snapshot.PlanLevel = usage.SubscriptionTierRaw + } + return snapshot +} + +// usageQuotaTiers 把 UsageInfo 的各平台窗口归一为 tier 列表(无数据的窗口跳过)。 +func usageQuotaTiers(usage *UsageInfo) []domain.MonitorQuotaTier { + if usage == nil { + return nil + } + tiers := make([]domain.MonitorQuotaTier, 0, 8) + appendProgressTier(&tiers, "5h", "", usage.FiveHour) + appendProgressTier(&tiers, "7d", "", usage.SevenDay) + appendProgressTier(&tiers, "7d-sonnet", "", usage.SevenDaySonnet) + appendProgressTier(&tiers, "7d-fable", "", usage.SevenDayFable) + appendProgressTier(&tiers, "30d", "", usage.ThirtyDay) + // Gemini 多档日配额:同 Window 不同 Label。 + appendProgressTier(&tiers, "daily", "shared", usage.GeminiSharedDaily) + appendProgressTier(&tiers, "daily", "pro", usage.GeminiProDaily) + appendProgressTier(&tiers, "daily", "flash", usage.GeminiFlashDaily) + // Grok requests/tokens 两个日窗口 + 月度计费窗口。 + appendQuotaWindowTier(&tiers, "daily", "requests", usage.GrokRequestQuota) + appendQuotaWindowTier(&tiers, "daily", "tokens", usage.GrokTokenQuota) + // Antigravity per-model 总量额度,Label = 模型名(按名排序保证输出稳定)。 + for _, model := range sortedQuotaModelNames(usage.AntigravityQuota) { + q := usage.AntigravityQuota[model] + if q == nil { + continue + } + tiers = append(tiers, domain.MonitorQuotaTier{ + Window: "total", + Label: model, + UsedPercent: float64(q.Utilization), + ResetAt: q.ResetTime, + }) + } + if len(tiers) == 0 { + return nil + } + return tiers +} + +func appendProgressTier(tiers *[]domain.MonitorQuotaTier, window, label string, p *UsageProgress) { + if p == nil { + return + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + UsedPercent: p.Utilization, + } + if p.ResetsAt != nil { + tier.ResetAt = p.ResetsAt.UTC().Format(time.RFC3339) + } + if p.LimitRequests > 0 { + tier.Used = float64(p.UsedRequests) + tier.Limit = float64(p.LimitRequests) + } + *tiers = append(*tiers, tier) +} + +func appendQuotaWindowTier(tiers *[]domain.MonitorQuotaTier, window, label string, q *xai.QuotaWindow) { + if q == nil || q.Limit == nil || *q.Limit <= 0 { + return + } + used := *q.Limit + if q.Remaining != nil { + used = *q.Limit - *q.Remaining + if used < 0 { + used = 0 + } + } + tier := domain.MonitorQuotaTier{ + Window: window, + Label: label, + Used: float64(used), + Limit: float64(*q.Limit), + UsedPercent: float64(used) / float64(*q.Limit) * 100, + } + if q.ResetAt != "" { + tier.ResetAt = q.ResetAt + } else if q.ResetUnix != nil && *q.ResetUnix > 0 { + tier.ResetAt = time.Unix(*q.ResetUnix, 0).UTC().Format(time.RFC3339) + } + *tiers = append(*tiers, tier) +} + +func sortedQuotaModelNames(quotas map[string]*AntigravityModelQuota) []string { + names := make([]string, 0, len(quotas)) + for name := range quotas { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsage → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnQuota == nil { + return quotaErrorSnapshot("cn_quota", "cn quota service is not configured", now) + } + result, err := f.cnQuota.QueryUsage(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_quota", + Success: result.Success, + PlanLevel: result.PlanLevel, + Error: result.Error, + FetchedAt: now, + } + if !result.Success && !result.CredentialValid { + snapshot.CredentialInvalid = true + } + if len(result.Tiers) > 0 { + snapshot.Tiers = make([]domain.MonitorQuotaTier, 0, len(result.Tiers)) + for _, t := range result.Tiers { + snapshot.Tiers = append(snapshot.Tiers, domain.MonitorQuotaTier{ + Window: t.Window, + UsedPercent: t.UsedPercent, + ResetAt: t.ResetAt, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn quota probe failed") + } + return snapshot +} + +// fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalance → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { + if f.cnBalance == nil { + return quotaErrorSnapshot("cn_balance", "cn balance service is not configured", now) + } + result, err := f.cnBalance.QueryBalance(ctx, accountID) + if err != nil { + msg := truncateMessage(sanitizeErrorMessage(err.Error())) + return &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: false, + CredentialInvalid: isCredentialErrorMessage(msg), + Error: msg, + FetchedAt: now, + } + } + snapshot := &domain.MonitorQuotaSnapshot{ + Source: "cn_balance", + Success: result.Success, + Currency: result.Currency, + Error: result.Error, + FetchedAt: now, + } + if result.Success { + balance := result.Balance + snapshot.Balance = &balance + } else if result.StatusCode == 401 || result.StatusCode == 403 { + snapshot.CredentialInvalid = true + } + if len(result.Balances) > 0 { + snapshot.Balances = make([]domain.MonitorBalance, 0, len(result.Balances)) + for _, b := range result.Balances { + snapshot.Balances = append(snapshot.Balances, domain.MonitorBalance{ + Currency: b.Currency, + Balance: b.Balance, + }) + } + } + if !snapshot.Success { + snapshot.Error = firstNonEmpty(snapshot.Error, "cn balance probe failed") + } + return snapshot +} + +// quotaErrorSnapshot 构造统一错误快照。 +func quotaErrorSnapshot(source, message string, now time.Time) *domain.MonitorQuotaSnapshot { + return &domain.MonitorQuotaSnapshot{ + Source: source, + Success: false, + Error: truncateMessage(sanitizeErrorMessage(message)), + FetchedAt: now, + } +} + +// isCredentialErrorMessage 上游 401/403 鉴权失败的启发式识别 +// (海外 GetUsage 的错误没有结构化状态码,只能看文本)。 +func isCredentialErrorMessage(msg string) bool { + msg = strings.ToLower(msg) + return strings.Contains(msg, "401") || + strings.Contains(msg, "403") || + strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "forbidden") || + strings.Contains(msg, "invalid_api_key") || + strings.Contains(msg, "authentication") +} + +// usageFailureInfo 识别 GetUsage 经「值通道」返回的失败:antigravity/grok +// 等平台 err==nil 但把错误降级在 UsageInfo 字段里(Error/ErrorCode/状态标记)。 +// 返回 failed=false 表示可用;credentialInvalid 表示凭据失效(401/403 语义, +// 推导为 failed 状态);msg 为失败摘要。 +// +// grok 的 ErrorCode=quota_unknown 是「尚未观测到计费快照/限流头」的已知未知态, +// 不是失败(严格按 ErrorCode 判会把健康 grok 账号永久判 error),显式豁免。 +func usageFailureInfo(usage *UsageInfo) (failed, credentialInvalid bool, msg string) { + if usage == nil { + return false, false, "" + } + if usage.ErrorCode == "quota_unknown" { + return false, false, "" + } + failed = usage.Error != "" || usage.NeedsReauth || usage.IsBanned || + usage.IsForbidden || usage.ErrorCode != "" + if !failed { + return false, false, "" + } + credentialInvalid = usage.NeedsReauth || usage.IsBanned || usage.IsForbidden || + usage.ErrorCode == errorCodeUnauthenticated || usage.ErrorCode == errorCodeForbidden + msg = firstNonEmpty(usage.Error, usage.ForbiddenReason, usage.ErrorCode, "usage fetch failed") + return failed, credentialInvalid, msg +} + +// deriveQuotaCheckResult 把配额快照推导为检测状态(复用既有 status 枚举, +// 时间线/可用率机制自动生效): +// - 查询成功且无告警 → operational +// - 任一窗口使用率 >= 阈值或余额耗尽 → degraded +// - 账号未关联(配置问题) → degraded +// - 凭据失效(401/403) → failed +// - 网络/解析等其他错误 → error +func deriveQuotaCheckResult(snapshot *domain.MonitorQuotaSnapshot, model string, checkedAt time.Time) *CheckResult { + res := &CheckResult{Model: model, CheckedAt: checkedAt} + if snapshot == nil { + res.Status = MonitorStatusError + res.Message = "quota snapshot missing" + return res + } + + switch { + case !snapshot.Success && snapshot.CredentialInvalid: + res.Status = MonitorStatusFailed + res.Message = snapshot.Error + case !snapshot.Success && strings.Contains(snapshot.Error, "linked account not found"): + res.Status = MonitorStatusDegraded + res.Message = snapshot.Error + case !snapshot.Success: + res.Status = MonitorStatusError + res.Message = snapshot.Error + default: + if hint := quotaDegradedHint(snapshot); hint != "" { + res.Status = MonitorStatusDegraded + res.Message = hint + } else { + res.Status = MonitorStatusOperational + } + } + return res +} + +// quotaDegradedHint 生成 degraded 的 message(指出触发告警的窗口/余额); +// 空串表示无告警。 +func quotaDegradedHint(snapshot *domain.MonitorQuotaSnapshot) string { + for _, tier := range snapshot.Tiers { + if tier.UsedPercent >= monitorQuotaDegradedUsedPercent { + name := tier.Window + if tier.Label != "" { + name = tier.Label + "/" + tier.Window + } + return fmt.Sprintf("quota high: %s at %s%%", name, strconv.FormatFloat(tier.UsedPercent, 'f', 1, 64)) + } + } + if snapshot.Balance != nil && *snapshot.Balance <= 0 { + return fmt.Sprintf("balance depleted (%s)", firstNonEmpty(snapshot.Currency, "?")) + } + return "" +} diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go new file mode 100644 index 0000000000..f2e6ff998c --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -0,0 +1,518 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +// --- fetcher 依赖 stub --- + +type stubMonitorUsageSource struct { + usage *UsageInfo + err error + // block 非 nil 时 GetUsage 阻塞在该 channel 上,用于并发/singleflight 测试。 + block chan struct{} + + mu sync.Mutex + calls int + lastCtx context.Context +} + +func (s *stubMonitorUsageSource) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { + s.mu.Lock() + s.calls++ + s.lastCtx = ctx + s.mu.Unlock() + if s.block != nil { + <-s.block + } + return s.usage, s.err +} + +func (s *stubMonitorUsageSource) getCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +type stubMonitorCNQuotaSource struct { + result *CNProviderQuotaProbeResult + err error + calls int +} + +func (s *stubMonitorCNQuotaSource) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + s.calls++ + return s.result, s.err +} + +type stubMonitorCNBalanceSource struct { + result *CNProviderBalanceResult + err error + calls int +} + +func (s *stubMonitorCNBalanceSource) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + s.calls++ + return s.result, s.err +} + +type stubMonitorAccountSource struct { + accounts map[int64]*Account + err error + calls int +} + +func (s *stubMonitorAccountSource) GetByID(ctx context.Context, id int64) (*Account, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + return s.accounts[id], nil +} + +func newQuotaFetcherTestSetup(t *testing.T) (*ChannelMonitorQuotaFetcher, *stubMonitorUsageSource, *stubMonitorCNQuotaSource, *stubMonitorCNBalanceSource, *stubMonitorAccountSource) { + t.Helper() + usage := &stubMonitorUsageSource{} + cnQuota := &stubMonitorCNQuotaSource{} + cnBalance := &stubMonitorCNBalanceSource{} + accounts := &stubMonitorAccountSource{accounts: make(map[int64]*Account)} + fetcher := &ChannelMonitorQuotaFetcher{ + usage: usage, + cnQuota: cnQuota, + cnBalance: cnBalance, + accounts: accounts, + cache: make(map[int64]monitorQuotaCacheEntry), + } + return fetcher, usage, cnQuota, cnBalance, accounts +} + +// --- 分派 --- + +func TestQuotaFetcher_OverseasAccountUsesUsageService(t *testing.T) { + fetcher, usage, _, cnQuota, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[7] = &Account{ID: 7, Platform: domain.PlatformAnthropic} + resets := time.Now().Add(2 * time.Hour).UTC() + usage.usage = &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 42.5, UsedRequests: 17, LimitRequests: 40, ResetsAt: &resets}, + SevenDay: &UsageProgress{Utilization: 10}, + SubscriptionTier: "PRO", + } + + snapshot := fetcher.Fetch(context.Background(), 7) + + require.True(t, snapshot.Success) + require.Equal(t, "usage", snapshot.Source) + require.Equal(t, "PRO", snapshot.PlanLevel) + require.False(t, snapshot.CredentialInvalid) + require.Empty(t, snapshot.Error) + require.Len(t, snapshot.Tiers, 2) + + fiveHour := snapshot.Tiers[0] + require.Equal(t, "5h", fiveHour.Window) + require.Empty(t, fiveHour.Label) + require.InDelta(t, 42.5, fiveHour.UsedPercent, 0.001) + require.Equal(t, float64(17), fiveHour.Used) + require.Equal(t, float64(40), fiveHour.Limit) + require.NotEmpty(t, fiveHour.ResetAt) + + require.Equal(t, "7d", snapshot.Tiers[1].Window) + require.Equal(t, 1, usage.getCalls()) + require.Equal(t, 0, cnQuota.calls) +} + +func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) { + fetcher, _, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[9] = &Account{ + ID: 9, + Platform: domain.PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + PlanLevel: "", + Tiers: []CNQuotaTier{ + {Window: "5h", UsedPercent: 33.3, ResetAt: "2026-08-18T06:00:00Z"}, + {Window: "weekly", UsedPercent: 12}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 9) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_quota", snapshot.Source) + require.Len(t, snapshot.Tiers, 2) + require.Equal(t, "5h", snapshot.Tiers[0].Window) + require.InDelta(t, 33.3, snapshot.Tiers[0].UsedPercent, 0.001) + require.Equal(t, "weekly", snapshot.Tiers[1].Window) + require.Equal(t, 1, cnQuota.calls) + require.Equal(t, 0, cnBalance.calls) +} + +func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[11] = &Account{ + ID: 11, + Platform: domain.PlatformDeepseek, + Credentials: map[string]any{"account_mode": AccountModePayG}, + } + cnBalance.result = &CNProviderBalanceResult{ + Success: true, + Balance: 12.34, + Currency: "CNY", + Balances: []CNProviderBalanceEntry{ + {Currency: "CNY", Balance: 12.34}, + {Currency: "USD", Balance: 1.5}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 11) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_balance", snapshot.Source) + require.NotNil(t, snapshot.Balance) + require.InDelta(t, 12.34, *snapshot.Balance, 0.001) + require.Equal(t, "CNY", snapshot.Currency) + require.Len(t, snapshot.Balances, 2) + require.Equal(t, "USD", snapshot.Balances[1].Currency) + require.Empty(t, snapshot.Error) +} + +// --- 失败路径(Fetch 永不返回 error) --- + +func TestQuotaFetcher_AccountMissingYieldsLinkedAccountSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.err = errors.New("not found") + + snapshot := fetcher.Fetch(context.Background(), 404) + + require.False(t, snapshot.Success) + require.Equal(t, "linked account not found", snapshot.Error) + require.Equal(t, 0, usage.getCalls()) // 未走到数据源 +} + +func TestQuotaFetcher_UsageAuthErrorMarksCredentialInvalid(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformOpenAI} + usage.err = errors.New("API returned 401: unauthorized") + + snapshot := fetcher.Fetch(context.Background(), 3) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") +} + +// 值通道失败:antigravity/grok 等平台 err==nil 但错误降级在 UsageInfo 字段里, +// 必须识别为失败快照,否则会被误判为 operational。 +func TestQuotaFetcher_UsageValueChannelFailureYieldsFailureSnapshot(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + + // 凭据失效(401 语义)→ failed。 + accounts.accounts[3] = &Account{ID: 3, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 401", ErrorCode: errorCodeUnauthenticated, NeedsReauth: true} + snapshot := fetcher.Fetch(context.Background(), 3) + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "401") + require.Equal(t, MonitorStatusFailed, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // 限流等非凭据失败 → error(而非 operational)。 + accounts.accounts[13] = &Account{ID: 13, Platform: domain.PlatformAnthropic} + usage.usage = &UsageInfo{Error: "usage API error: HTTP 429", ErrorCode: errorCodeRateLimited} + snapshot = fetcher.Fetch(context.Background(), 13) + require.False(t, snapshot.Success) + require.False(t, snapshot.CredentialInvalid) + require.Contains(t, snapshot.Error, "429") + require.Equal(t, MonitorStatusError, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + + // grok 已知未知态(尚未观测到计费/限流头)不算失败。 + accounts.accounts[14] = &Account{ID: 14, Platform: domain.PlatformGrok} + usage.usage = &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"} + snapshot = fetcher.Fetch(context.Background(), 14) + require.True(t, snapshot.Success) + require.Empty(t, snapshot.Error) + require.Empty(t, snapshot.Tiers) + require.Equal(t, MonitorStatusOperational, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) +} + +func TestUsageFailureInfo_ClassificationMatrix(t *testing.T) { + cases := []struct { + name string + usage *UsageInfo + failed bool + credentialInvalid bool + msg string + }{ + {name: "nil usage", usage: nil}, + {name: "healthy empty", usage: &UsageInfo{}}, + {name: "error text only", usage: &UsageInfo{Error: "boom"}, failed: true, msg: "boom"}, + {name: "needs reauth", usage: &UsageInfo{NeedsReauth: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "banned", usage: &UsageInfo{IsBanned: true}, failed: true, credentialInvalid: true, msg: "usage fetch failed"}, + {name: "forbidden with reason", usage: &UsageInfo{IsForbidden: true, ForbiddenReason: "usage limited"}, failed: true, credentialInvalid: true, msg: "usage limited"}, + {name: "error code unauthenticated", usage: &UsageInfo{ErrorCode: errorCodeUnauthenticated}, failed: true, credentialInvalid: true, msg: errorCodeUnauthenticated}, + {name: "error code forbidden", usage: &UsageInfo{ErrorCode: errorCodeForbidden}, failed: true, credentialInvalid: true, msg: errorCodeForbidden}, + {name: "error code rate limited", usage: &UsageInfo{ErrorCode: errorCodeRateLimited}, failed: true, msg: errorCodeRateLimited}, + {name: "error code network error", usage: &UsageInfo{ErrorCode: errorCodeNetworkError}, failed: true, msg: errorCodeNetworkError}, + {name: "grok quota unknown exempted", usage: &UsageInfo{ErrorCode: "quota_unknown", Error: "Grok quota is unknown until billing is probed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + failed, credentialInvalid, msg := usageFailureInfo(tc.usage) + require.Equal(t, tc.failed, failed) + require.Equal(t, tc.credentialInvalid, credentialInvalid) + if tc.msg != "" { + require.Equal(t, tc.msg, msg) + } + }) + } +} + +func TestQuotaFetcher_CNQuotaCredentialInvalidFlagPropagates(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[5] = &Account{ + ID: 5, + Platform: domain.PlatformZhipu, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{Success: false, CredentialValid: false, Error: "api key expired"} + + snapshot := fetcher.Fetch(context.Background(), 5) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) + require.Equal(t, "api key expired", snapshot.Error) +} + +func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[6] = &Account{ID: 6, Platform: domain.PlatformKimi} + cnBalance.result = &CNProviderBalanceResult{Success: false, StatusCode: 403, Error: "forbidden"} + + snapshot := fetcher.Fetch(context.Background(), 6) + + require.False(t, snapshot.Success) + require.True(t, snapshot.CredentialInvalid) +} + +func TestQuotaFetcher_NilDependenciesProduceErrorSnapshots(t *testing.T) { + // fetcher 本体为 nil:直接降级为错误快照,不 panic。 + var nilFetcher *ChannelMonitorQuotaFetcher + snapshot := nilFetcher.Fetch(context.Background(), 1) + require.False(t, snapshot.Success) + require.Equal(t, "quota fetcher is not configured", snapshot.Error) + + // 数据源缺失:账号能加载,但对应服务未注入。 + fetcher, _, _, _, accounts := newQuotaFetcherTestSetup(t) + fetcher.usage = nil + accounts.accounts[2] = &Account{ID: 2, Platform: domain.PlatformOpenAI} + snapshot = fetcher.Fetch(context.Background(), 2) + require.False(t, snapshot.Success) + require.Contains(t, snapshot.Error, "not configured") +} + +// --- TTL 缓存 --- + +func TestQuotaFetcher_CachesSuccessSnapshotPerAccount(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[8] = &Account{ID: 8, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + + for i := 0; i < 3; i++ { + snapshot := fetcher.Fetch(context.Background(), 8) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "success snapshots should be served from cache") + + // 缓存过期后重新拉取。 + fetcher.mu.Lock() + entry := fetcher.cache[8] + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[8] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 8) + require.Equal(t, 2, usage.getCalls()) +} + +func TestQuotaFetcher_CachesFailureSnapshotWithShortTTL(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[4] = &Account{ID: 4, Platform: domain.PlatformOpenAI} + usage.err = errors.New("boom") + + for i := 0; i < 2; i++ { + snapshot := fetcher.Fetch(context.Background(), 4) + require.False(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls(), "failure snapshots should be served from the short negative cache") + + // 失败快照的 TTL 是负缓存时长(而非成功 TTL)。 + fetcher.mu.Lock() + entry := fetcher.cache[4] + require.WithinDuration(t, entry.snapshot.FetchedAt.Add(monitorQuotaErrorCacheTTL), entry.expiry, time.Second) + entry.expiry = time.Now().Add(-time.Second) + fetcher.cache[4] = entry + fetcher.mu.Unlock() + + _ = fetcher.Fetch(context.Background(), 4) + require.Equal(t, 2, usage.getCalls(), "expired negative cache should refetch") +} + +func TestQuotaFetcher_ConcurrentFetchesShareSingleFlight(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[12] = &Account{ID: 12, Platform: domain.PlatformOpenAI} + usage.usage = &UsageInfo{FiveHour: &UsageProgress{Utilization: 10}} + usage.block = make(chan struct{}) + + var wg sync.WaitGroup + snapshots := make([]*domain.MonitorQuotaSnapshot, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + snapshots[idx] = fetcher.Fetch(context.Background(), 12) + }(i) + } + + // 上游被 block 卡住时,5 个并发 Fetch 应只产生 1 次真实查询。 + require.Eventually(t, func() bool { return usage.getCalls() == 1 }, + 5*time.Second, 10*time.Millisecond) + close(usage.block) + wg.Wait() + + for _, snapshot := range snapshots { + require.NotNil(t, snapshot) + require.True(t, snapshot.Success) + } + require.Equal(t, 1, usage.getCalls()) + + // 成功快照已缓存:再取一次仍不打上游。 + _ = fetcher.Fetch(context.Background(), 12) + require.Equal(t, 1, usage.getCalls()) +} + +// --- UsageInfo → tiers 归一 --- + +func TestUsageQuotaTiers_MapsAllWindowKinds(t *testing.T) { + limit := int64(1000) + remaining := int64(400) + resetUnix := int64(1777283883) + usage := &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 50}, + SevenDay: &UsageProgress{Utilization: 60}, + SevenDaySonnet: &UsageProgress{Utilization: 70}, + SevenDayFable: &UsageProgress{Utilization: 80}, + ThirtyDay: &UsageProgress{Utilization: 20}, + GeminiSharedDaily: &UsageProgress{Utilization: 11}, + GeminiProDaily: &UsageProgress{Utilization: 22}, + GeminiFlashDaily: &UsageProgress{Utilization: 33}, + GrokRequestQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetUnix: &resetUnix}, + GrokTokenQuota: &xai.QuotaWindow{Limit: &limit, Remaining: &remaining, ResetAt: "2026-08-19T00:00:00Z"}, + AntigravityQuota: map[string]*AntigravityModelQuota{ + "gemini-3-pro": {Utilization: 45}, + "gemini-3-flash": {Utilization: 55}, + }, + } + + tiers := usageQuotaTiers(usage) + + // 5h/7d/7d-sonnet/7d-fable/30d + gemini×3 + grok×2 + antigravity×2 + require.Len(t, tiers, 12) + + byKey := make(map[string]domain.MonitorQuotaTier, len(tiers)) + for _, tier := range tiers { + key := tier.Window + if tier.Label != "" { + key = tier.Window + "/" + tier.Label + } + byKey[key] = tier + } + + require.Contains(t, byKey, "5h") + require.Contains(t, byKey, "7d") + require.Contains(t, byKey, "7d-sonnet") + require.Contains(t, byKey, "7d-fable") + require.Contains(t, byKey, "30d") + require.Contains(t, byKey, "daily/shared") + require.Contains(t, byKey, "daily/pro") + require.Contains(t, byKey, "daily/flash") + require.Contains(t, byKey, "daily/requests") + require.Contains(t, byKey, "daily/tokens") + require.Contains(t, byKey, "total/gemini-3-pro") + require.Contains(t, byKey, "total/gemini-3-flash") + + // grok requests 窗口:used = limit - remaining,百分比 60%。 + requests := byKey["daily/requests"] + require.Equal(t, float64(600), requests.Used) + require.Equal(t, float64(1000), requests.Limit) + require.InDelta(t, 60.0, requests.UsedPercent, 0.001) + require.NotEmpty(t, requests.ResetAt, "ResetUnix should fall back to RFC3339") + + tokens := byKey["daily/tokens"] + require.Equal(t, "2026-08-19T00:00:00Z", tokens.ResetAt) +} + +func TestUsageQuotaTiers_NilAndEmptyInputs(t *testing.T) { + require.Nil(t, usageQuotaTiers(nil)) + require.Nil(t, usageQuotaTiers(&UsageInfo{})) + + // Grok 窗口 limit<=0 时跳过,避免除零。 + var zero int64 + tiers := usageQuotaTiers(&UsageInfo{ + GrokRequestQuota: &xai.QuotaWindow{Limit: &zero, Remaining: &zero}, + }) + require.Nil(t, tiers) +} + +// --- 状态推导 --- + +func TestDeriveQuotaCheckResult_StatusMatrix(t *testing.T) { + now := time.Now() + + healthy := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{{Window: "5h", UsedPercent: 40}}} + res := deriveQuotaCheckResult(healthy, "quota", now) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Equal(t, "quota", res.Model) + require.Empty(t, res.Message) + + highUsage := &domain.MonitorQuotaSnapshot{Success: true, Tiers: []domain.MonitorQuotaTier{ + {Window: "5h", UsedPercent: 30}, + {Window: "daily", Label: "pro", UsedPercent: 95}, + }} + res = deriveQuotaCheckResult(highUsage, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "pro/daily") + require.Contains(t, res.Message, "95.0%") + + balance := -0.5 + depleted := &domain.MonitorQuotaSnapshot{Success: true, Balance: &balance, Currency: "CNY"} + res = deriveQuotaCheckResult(depleted, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + require.Contains(t, res.Message, "balance depleted") + + invalid := &domain.MonitorQuotaSnapshot{Success: false, CredentialInvalid: true, Error: "401 unauthorized"} + res = deriveQuotaCheckResult(invalid, "quota", now) + require.Equal(t, MonitorStatusFailed, res.Status) + + unlinked := &domain.MonitorQuotaSnapshot{Success: false, Error: "linked account not found"} + res = deriveQuotaCheckResult(unlinked, "quota", now) + require.Equal(t, MonitorStatusDegraded, res.Status) + + other := &domain.MonitorQuotaSnapshot{Success: false, Error: "connection refused"} + res = deriveQuotaCheckResult(other, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) + require.Equal(t, "connection refused", res.Message) + + res = deriveQuotaCheckResult(nil, "quota", now) + require.Equal(t, MonitorStatusError, res.Status) +} diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go new file mode 100644 index 0000000000..0e4b3ea7da --- /dev/null +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -0,0 +1,463 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +// --- repo / fetcher 装配 --- + +// quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。 +type quotaModeRepoStub struct { + ChannelMonitorRepository + monitor *ChannelMonitor + history []*ChannelMonitorHistoryRow + markedIDs []int64 + updated []*ChannelMonitor +} + +func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) { + if r.monitor == nil || r.monitor.ID != id { + return nil, ErrChannelMonitorNotFound + } + clone := *r.monitor + return &clone, nil +} + +func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error { + r.history = append(r.history, rows...) + return nil +} + +func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error { + r.markedIDs = append(r.markedIDs, id) + return nil +} + +func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error { + clone := *m + r.updated = append(r.updated, &clone) + return nil +} + +// newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。 +func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService { + svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ + Enabled: true, + Mode: ChannelMonitorModeV1, + }}) + return svc +} + +func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher { + if accounts == nil { + accounts = make(map[int64]*Account) + } + if usage == nil { + usage = &stubMonitorUsageSource{} + } + return &ChannelMonitorQuotaFetcher{ + usage: usage, + accounts: &stubMonitorAccountSource{accounts: accounts}, + cache: make(map[int64]monitorQuotaCacheEntry), + } +} + +// --- RunCheck 分派 --- + +func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 1, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(9), + }} + svc := newQuotaModeService(repo) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil) + fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}}, + }} + svc.SetQuotaFetcher(fetcher) + + results, err := svc.RunCheck(context.Background(), 1) + require.NoError(t, err) + require.Len(t, results, 1) + + res := results[0] + require.Equal(t, "quota", res.Model) + require.Equal(t, MonitorStatusOperational, res.Status) + require.Nil(t, res.LatencyMs) + require.Nil(t, res.PingLatencyMs) + require.NotNil(t, res.Quota) + require.True(t, res.Quota.Success) + require.Equal(t, "cn_quota", res.Quota.Source) + + // 历史行携带配额快照,并推进 last_checked_at。 + require.Len(t, repo.history, 1) + require.Equal(t, "quota", repo.history[0].Model) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, []int64{1}, repo.markedIDs) +} + +func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 2, + Provider: MonitorProviderDeepseek, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: nil, // FK ON DELETE SET NULL 后的形态 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 2) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusDegraded, results[0].Status) + require.Contains(t, results[0].Message, "linked account not found") + require.False(t, results[0].Quota.Success) +} + +func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) { + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 3, + Provider: MonitorProviderZhipu, + APIMode: MonitorAPIModeChatCompletions, + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: int64Ptr(5), + }} + svc := newQuotaModeService(repo) // 不注入 fetcher + + results, err := svc.RunCheck(context.Background(), 3) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusError, results[0].Status) + require.Contains(t, results[0].Message, "not configured") +} + +func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 4, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + ExtraModels: []string{"gpt-extra"}, + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: int64Ptr(12), + }} + svc := newQuotaModeService(repo) + usage := &stubMonitorUsageSource{usage: &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 20}, + }} + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 12: {ID: 12, Platform: domain.PlatformOpenAI}, + }, usage)) + + results, err := svc.RunCheck(context.Background(), 4) + require.NoError(t, err) + require.Len(t, results, 2) + + // 探活状态为准,配额只挂主模型行。 + require.Equal(t, MonitorStatusOperational, results[0].Status) + require.NotNil(t, results[0].Quota) + require.True(t, results[0].Quota.Success) + require.Equal(t, "usage", results[0].Quota.Source) + require.Nil(t, results[1].Quota, "extra model rows must not carry quota") + + // 历史落库时同样只有主模型行带快照。 + require.Len(t, repo.history, 2) + require.NotNil(t, repo.history[0].Quota) + require.Equal(t, "gpt-test", repo.history[0].Model) + require.Nil(t, repo.history[1].Quota) +} + +func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) { + h := &openAICaptureHandler{} + endpoint := setupFakeOpenAI(t, h) + repo := "aModeRepoStub{monitor: &ChannelMonitor{ + ID: 5, + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: endpoint, + APIKey: "OLD:sk-openai", + PrimaryModel: "gpt-test", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuotaProbe, + AccountID: nil, // 配额侧失效 + }} + svc := newQuotaModeService(repo) + svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) + + results, err := svc.RunCheck(context.Background(), 5) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status") + require.False(t, results[0].Quota.Success) +} + +// --- attachQuotaSnapshot 细节 --- + +func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) { + results := []*CheckResult{ + {Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"}, + {Model: "extra"}, + } + failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"} + + attachQuotaSnapshot(results, failed) + + require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins") + require.Equal(t, failed, results[0].Quota) + require.Nil(t, results[1].Quota) + + quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}} + attachQuotaSnapshot(quiet, failed) + require.Contains(t, quiet[0].Message, "quota fetch failed: boom") + + attachQuotaSnapshot(nil, failed) // 空结果不 panic + attachQuotaSnapshot(results, nil) // 空快照不动结果 +} + +// --- 校验矩阵 --- + +func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { + accountID := int64(9) + + cases := []struct { + name string + params ChannelMonitorCreateParams + wantErr error + }{ + { + name: "probe requires endpoint", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "probe requires api key", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorMissingAPIKey, + }, + { + name: "quota drops endpoint and api key requirements", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: nil, // primary_model 默认 "quota" + }, + { + name: "quota requires account", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, + IntervalSeconds: 60, PrimaryModel: "quota", + }, + wantErr: ErrChannelMonitorAccountRequired, + }, + { + name: "quota_probe requires endpoint and api key too", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2", + }, + wantErr: ErrChannelMonitorInvalidEndpoint, + }, + { + name: "antigravity probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "antigravity quota_probe unsupported", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://example.com", APIKey: "k", + IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + { + name: "unknown mode rejected", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderOpenAI, CheckMode: "auto", + Endpoint: "https://api.openai.com", APIKey: "sk", + IntervalSeconds: 60, PrimaryModel: "gpt-5", + }, + wantErr: ErrChannelMonitorInvalidCheckMode, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCreateParams(tc.params) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) { + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, "")) + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " ")) + // 探活模式沿用原语义:grok 默认模型,其余必填(空串报错在 validateCreateParams)。 + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, "")) + require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2")) +} + +func TestProviderProbeCapabilityMatrix(t *testing.T) { + require.False(t, providerSupportsProbe(MonitorProviderAntigravity)) + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.True(t, providerSupportsProbe(p), p) + } + for _, p := range []string{ + MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini, + MonitorProviderGrok, MonitorProviderAntigravity, + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, + } { + require.NoError(t, validateProvider(p), p) + } +} + +// --- 关联账号校验 --- + +func TestValidateLinkedAccount_Matrix(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + fetcher := newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformKimi}, + }, nil) + svc.SetQuotaFetcher(fetcher) + + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, nil)) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(0))) + require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1))) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderZhipu, int64Ptr(1)), ErrChannelMonitorProviderIncompatible) + require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(404)), ErrChannelMonitorAccountRequired) + + noFetcher := NewChannelMonitorService(nil, nil) + require.ErrorIs(t, noFetcher.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_QuotaErrorsProbeUnbinds(t *testing.T) { + fetcher := newQuotaModeFetcher(nil, nil) // 账号一律加载失败 + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(fetcher) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountRequired) + require.NotNil(t, quota.AccountID) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(9)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind stale account") + + quotaNoAccount := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quotaNoAccount), ErrChannelMonitorAccountRequired) +} + +func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorProviderIncompatible) + + probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID) +} + +// --- quota → probe 切换的 key 管控(validateProbeAPIKey) --- + +func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) { + svc := NewChannelMonitorService(nil, &duplicateChannelMonitorEncryptor{}) + + quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, APIKey: "NEW:"} + require.NoError(t, svc.validateProbeAPIKey(quota, "")) // quota 模式不管 key + + quota.CheckMode = MonitorCheckModeProbe + // 存量密文解出空明文(quota 监控存的加密空串)→ 必须重填 key。 + require.ErrorIs(t, svc.validateProbeAPIKey(quota, ""), ErrChannelMonitorMissingAPIKey) + // 提供新明文 key → 放行。 + require.NoError(t, svc.validateProbeAPIKey(quota, "sk-fresh")) + // 密文解出非空明文 → 放行。 + require.NoError(t, svc.validateProbeAPIKey( + &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, APIKey: "OLD:sk-live"}, "")) +} + +// --- Duplicate:quota 模式空明文重加密 --- + +func TestDuplicateChannelMonitorQuotaModeReencryptsEmptyKey(t *testing.T) { + accountID := int64(9) + source := &ChannelMonitor{ + ID: 42, + Name: "kimi-quota", + Provider: MonitorProviderKimi, + APIMode: MonitorAPIModeChatCompletions, + Endpoint: "", + APIKey: "OLD:", // 解密为空串(quota 监控的加密空 key) + PrimaryModel: "quota", + Enabled: true, + IntervalSeconds: 60, + CheckMode: MonitorCheckModeQuota, + AccountID: &accountID, + } + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + + dup, err := service.Duplicate(context.Background(), 42, 7, "admin:7", "op-1") + require.NoError(t, err) + require.Equal(t, MonitorCheckModeQuota, dup.CheckMode) + require.NotNil(t, dup.AccountID) + require.Equal(t, accountID, *dup.AccountID) + require.Empty(t, dup.APIKey, "plaintext stays empty for quota monitors") + require.Len(t, repo.created, 1) + require.Equal(t, "NEW:", repo.created[0].APIKey, "empty key must be re-encrypted, not dropped") +} diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 248b72133d..9b0bb07639 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/domain" "golang.org/x/sync/errgroup" ) @@ -77,6 +78,10 @@ type ChannelMonitorService struct { // scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。 // 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。 scheduler MonitorScheduler + // quotaFetcher 由 wire 通过 SetQuotaFetcher 注入(accountUsage/CN 服务在本服务 + // 之后构造,构造参数注入会破坏既有依赖顺序)。nil 时 fail-closed: + // 配额模式的检测产出「未配置」错误快照,Create/Update 关联账号直接报错。 + quotaFetcher *ChannelMonitorQuotaFetcher } const maxChannelMonitorNameRunes = 100 @@ -150,6 +155,10 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea if err := validateExtraHeaders(p.ExtraHeaders); err != nil { return nil, err } + if err := s.validateLinkedAccount(ctx, p.Provider, p.AccountID); err != nil { + return nil, err + } + checkMode := defaultCheckMode(p.CheckMode) encrypted, err := s.encryptor.Encrypt(p.APIKey) if err != nil { return nil, fmt.Errorf("encrypt api key: %w", err) @@ -160,7 +169,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea APIMode: defaultAPIMode(p.APIMode), Endpoint: normalizeEndpoint(p.Endpoint), APIKey: encrypted, // 注意:传入 repository 时该字段为密文 - PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel), + PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel), ExtraModels: normalizeModels(p.ExtraModels), GroupName: strings.TrimSpace(p.GroupName), Enabled: p.Enabled, @@ -171,6 +180,8 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders), BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode), BodyOverride: p.BodyOverride, + CheckMode: checkMode, + AccountID: cloneInt64Pointer(p.AccountID), } if err := s.repo.Create(ctx, m); err != nil { return nil, fmt.Errorf("create channel monitor: %w", err) @@ -236,6 +247,8 @@ func (s *ChannelMonitorService) Duplicate( ExtraHeaders: cloneChannelMonitorHeaders(source.ExtraHeaders), BodyOverrideMode: source.BodyOverrideMode, BodyOverride: bodyOverride, + CheckMode: defaultCheckMode(source.CheckMode), + AccountID: cloneInt64Pointer(source.AccountID), DuplicateOperationID: operationID, } if err := s.repo.Create(ctx, duplicate); err != nil { @@ -290,11 +303,22 @@ func (s *ChannelMonitorService) decryptAPIKeyForDuplicate(source *ChannelMonitor return "", ErrChannelMonitorAPIKeyDecryptFailed } plain, err := s.encryptor.Decrypt(source.APIKey) - if err != nil || strings.TrimSpace(plain) == "" { + if err != nil { slog.Warn("channel_monitor: decrypt api key for duplicate failed", "monitor_id", source.ID, "error", err) return "", ErrChannelMonitorAPIKeyDecryptFailed } + // quota 模式明文为空串是合法状态(api_key_encrypted 存的是加密空串): + // 重加密空串即可。若在此报错,克隆出的配额监控会被 runner 当作 + // 解密失败而 Unschedule,静默停摆。 + if strings.TrimSpace(plain) == "" { + if monitorCheckModeUsesQuota(defaultCheckMode(source.CheckMode)) { + return "", nil + } + slog.Warn("channel_monitor: decrypted api key for duplicate is empty", + "monitor_id", source.ID) + return "", ErrChannelMonitorAPIKeyDecryptFailed + } return plain, nil } @@ -343,10 +367,16 @@ func cloneChannelMonitorJSONMap(source map[string]any) (map[string]any, error) { } // validateCreateParams 把 Create 入参的所有校验聚拢为一个函数,避免 Create 主体超过 30 行。 +// 按 check_mode 分支:probe 沿用 endpoint+api_key 必填;quota 只需关联账号; +// quota_probe 两者皆需。 func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateProvider(p.Provider); err != nil { return err } + checkMode := defaultCheckMode(p.CheckMode) + if err := validateCheckMode(p.Provider, checkMode); err != nil { + return err + } if err := validateAPIMode(p.Provider, p.APIMode); err != nil { return err } @@ -356,18 +386,45 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateJitter(p.JitterSeconds, p.IntervalSeconds); err != nil { return err } - if err := validateEndpoint(p.Endpoint); err != nil { - return err + usesQuota := monitorCheckModeUsesQuota(checkMode) + // probe 分支(含 quota_probe 的探活部分)仍需 endpoint + api_key; + // quota 模式 endpoint/api_key 留空,避免要求用户填无意义的占位值。 + if checkMode != MonitorCheckModeQuota { + if err := validateEndpoint(p.Endpoint); err != nil { + return err + } + if strings.TrimSpace(p.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } } - if strings.TrimSpace(p.APIKey) == "" { - return ErrChannelMonitorMissingAPIKey + if usesQuota && (p.AccountID == nil || *p.AccountID <= 0) { + return ErrChannelMonitorAccountRequired } - if normalizeMonitorPrimaryModel(p.Provider, p.PrimaryModel) == "" { + if normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel) == "" { return ErrChannelMonitorMissingPrimaryModel } return nil } +// validateLinkedAccount 校验关联账号存在且平台与监控 provider 一致。 +// fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。 +func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error { + if accountID == nil || *accountID <= 0 { + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *accountID) + if err != nil || account == nil { + return ErrChannelMonitorAccountRequired + } + if account.Platform != provider { + return ErrChannelMonitorProviderIncompatible + } + return nil +} + // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。 func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelMonitorUpdateParams) (*ChannelMonitor, error) { existing, err := s.repo.GetByID(ctx, id) @@ -382,6 +439,14 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM if err != nil { return nil, err } + if err := s.validateProbeAPIKey(existing, newPlainAPIKey); err != nil { + return nil, err + } + if p.Provider != nil || p.CheckMode != nil || p.AccountID != nil { + if err := s.revalidateLinkedAccount(ctx, existing); err != nil { + return nil, err + } + } if err := s.repo.Update(ctx, existing); err != nil { return nil, fmt.Errorf("update channel monitor: %w", err) @@ -401,6 +466,76 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM return existing, nil } +// validateMonitorModeFields 校验 check_mode 与其它字段的组合约束 +// (在 provider/check_mode/account_id/endpoint 全部应用后调用): +// - quota / quota_probe 必须关联账号 +// - probe / quota_probe 必须持有 endpoint(探活目标) +func validateMonitorModeFields(m *ChannelMonitor) error { + checkMode := defaultCheckMode(m.CheckMode) + if monitorCheckModeUsesQuota(checkMode) && m.AccountID == nil { + return ErrChannelMonitorAccountRequired + } + if checkMode != MonitorCheckModeQuota && strings.TrimSpace(m.Endpoint) == "" { + return ErrChannelMonitorInvalidEndpoint + } + return nil +} + +// validateProbeAPIKey 探活模式(probe / quota_probe)必须持有可用明文 key: +// 存量密文解密为空串(quota 监控切回探活但未重填 key)时拒绝。 +// 密文损坏的情况交给既有 APIKeyDecryptFailed 链路(Get/RunCheck 会显式报错)。 +func (s *ChannelMonitorService) validateProbeAPIKey(m *ChannelMonitor, newPlainKey string) error { + if defaultCheckMode(m.CheckMode) == MonitorCheckModeQuota { + return nil + } + if strings.TrimSpace(newPlainKey) != "" { + return nil + } + if strings.TrimSpace(m.APIKey) == "" { + return ErrChannelMonitorMissingAPIKey + } + plain, err := s.encryptor.Decrypt(m.APIKey) + if err != nil { + return nil + } + if strings.TrimSpace(plain) == "" { + return ErrChannelMonitorMissingAPIKey + } + return nil +} + +// revalidateLinkedAccount 在 provider/check_mode/account_id 任一变化后复核关联账号: +// - 账号已被删除或平台失配:probe 模式自动解绑(静默修复), +// quota 模式显式报错(配额监控必须有可用数据源) +func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m *ChannelMonitor) error { + usesQuota := monitorCheckModeUsesQuota(defaultCheckMode(m.CheckMode)) + if m.AccountID == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + return nil + } + if s.quotaFetcher == nil { + return ErrChannelMonitorAccountRequired + } + account, err := s.quotaFetcher.LoadAccount(ctx, *m.AccountID) + if err != nil || account == nil { + if usesQuota { + return ErrChannelMonitorAccountRequired + } + m.AccountID = nil + return nil + } + if account.Platform != m.Provider { + if usesQuota { + return ErrChannelMonitorProviderIncompatible + } + m.AccountID = nil + return nil + } + return nil +} + // applyAPIKeyUpdate 处理 Update 中的 APIKey 字段: // - 入参 raw 为 nil 或空白:不修改 existing.APIKey(仍为密文),返回 updated=false // - 非空:加密后写入 existing.APIKey;同时把明文返回给调用方, @@ -454,6 +589,9 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。 // 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测; // mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。 +// +// 按 check_mode 分派:probe(默认,现状探活)/ quota(仅查关联账号配额, +// 零 LLM 成本)/ quota_probe(探活 + 配额快照挂主模型行)。 func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { rt := s.probeRuntime(ctx) if !rt.Enabled { @@ -466,14 +604,59 @@ func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*Chec if err != nil { return nil, err } - if m.APIKeyDecryptFailed { + checkMode := defaultCheckMode(m.CheckMode) + if checkMode != MonitorCheckModeQuota && m.APIKeyDecryptFailed { return nil, ErrChannelMonitorAPIKeyDecryptFailed } - results := s.runChecksConcurrent(ctx, m) + + var results []*CheckResult + switch checkMode { + case MonitorCheckModeQuota: + results = s.runQuotaOnlyCheck(ctx, m) + case MonitorCheckModeQuotaProbe: + results = s.runChecksConcurrent(ctx, m) + attachQuotaSnapshot(results, s.fetchQuotaSnapshot(ctx, m)) + default: + results = s.runChecksConcurrent(ctx, m) + } s.persistCheckResults(ctx, m, results) return results, nil } +// runQuotaOnlyCheck quota 模式:一次配额抓取 → 单条 CheckResult +// (Model=PrimaryModel,默认 "quota";无 ping/latency,状态由快照推导)。 +func (s *ChannelMonitorService) runQuotaOnlyCheck(ctx context.Context, m *ChannelMonitor) []*CheckResult { + snapshot := s.fetchQuotaSnapshot(ctx, m) + res := deriveQuotaCheckResult(snapshot, m.PrimaryModel, time.Now()) + res.Quota = snapshot + return []*CheckResult{res} +} + +// fetchQuotaSnapshot 抓取关联账号配额。未关联账号 / fetcher 未注入时返回 +// 显式错误快照(不返回 error,保证检测周期与历史时间线连续)。 +func (s *ChannelMonitorService) fetchQuotaSnapshot(ctx context.Context, m *ChannelMonitor) *domain.MonitorQuotaSnapshot { + if m.AccountID == nil { + return quotaErrorSnapshot("usage", "linked account not found", time.Now()) + } + if s.quotaFetcher == nil { + return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now()) + } + return s.quotaFetcher.Fetch(ctx, *m.AccountID) +} + +// attachQuotaSnapshot quota_probe:把配额快照挂到主模型行(results[0])。 +// 配额失败不改变探活状态,仅在探活 message 为空时附注失败原因。 +func attachQuotaSnapshot(results []*CheckResult, snapshot *domain.MonitorQuotaSnapshot) { + if len(results) == 0 || snapshot == nil { + return + } + primary := results[0] + primary.Quota = snapshot + if !snapshot.Success && strings.TrimSpace(primary.Message) == "" { + primary.Message = truncateMessage("quota fetch failed: " + snapshot.Error) + } +} + // persistCheckResults 写入本次检测的历史记录并更新 last_checked_at。 // 任一写库失败都只记日志,不影响调用方拿到 results(与 MVP 期望一致:宁可漏记历史也要先返回结果)。 func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *ChannelMonitor, results []*CheckResult) { @@ -487,6 +670,7 @@ func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *Chan PingLatencyMs: r.PingLatencyMs, Message: r.Message, CheckedAt: r.CheckedAt, + Quota: r.Quota, }) } if err := s.repo.InsertHistoryBatch(ctx, rows); err != nil { @@ -541,6 +725,14 @@ func (s *ChannelMonitorService) SetScheduler(sched MonitorScheduler) { s.scheduler = sched } +// SetQuotaFetcher 由 wire 注入配额抓取器(账号侧用量服务聚合)。 +func (s *ChannelMonitorService) SetQuotaFetcher(fetcher *ChannelMonitorQuotaFetcher) { + if s == nil { + return + } + s.quotaFetcher = fetcher +} + // ListEnabledMonitors 返回所有 enabled=true 的监控(解密后),供 runner 启动时建立任务表。 func (s *ChannelMonitorService) ListEnabledMonitors(ctx context.Context) ([]*ChannelMonitor, error) { all, err := s.repo.ListEnabled(ctx) @@ -693,14 +885,36 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) providerChanged = existing.Provider != *p.Provider existing.Provider = *p.Provider } - if p.Endpoint != nil { - if err := validateEndpoint(*p.Endpoint); err != nil { + if p.CheckMode != nil { + mode := defaultCheckMode(*p.CheckMode) + if err := validateCheckMode(existing.Provider, mode); err != nil { return err } + existing.CheckMode = mode + } + if p.AccountID != nil { + if *p.AccountID > 0 { + id := *p.AccountID + existing.AccountID = &id + } else { + existing.AccountID = nil // 0 = 清空关联 + } + } + if p.Endpoint != nil { + // quota 模式允许清空 endpoint(校验由 validateMonitorModeFields 兜底)。 + if strings.TrimSpace(*p.Endpoint) != "" { + if err := validateEndpoint(*p.Endpoint); err != nil { + return err + } + } existing.Endpoint = normalizeEndpoint(*p.Endpoint) } + // 模式与字段的组合校验(provider/check_mode/account_id/endpoint 全部应用后)。 + if err := validateMonitorModeFields(existing); err != nil { + return err + } if p.PrimaryModel != nil { - primaryModel := normalizeMonitorPrimaryModel(existing.Provider, *p.PrimaryModel) + primaryModel := normalizeMonitorPrimaryModel(existing.Provider, defaultCheckMode(existing.CheckMode), *p.PrimaryModel) if primaryModel == "" { return ErrChannelMonitorMissingPrimaryModel } diff --git a/backend/internal/service/channel_monitor_service_grok_test.go b/backend/internal/service/channel_monitor_service_grok_test.go index 20c9db2666..e3a89075f4 100644 --- a/backend/internal/service/channel_monitor_service_grok_test.go +++ b/backend/internal/service/channel_monitor_service_grok_test.go @@ -9,6 +9,7 @@ func TestApplyMonitorUpdate_ProviderOnlySwitchToGrokUsesDefaultModel(t *testing. existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeResponses, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -31,6 +32,7 @@ func TestApplyMonitorUpdate_SwitchToGrokPreservesExplicitModel(t *testing.T) { existing := &ChannelMonitor{ Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.openai.com/v1", PrimaryModel: "gpt-5", IntervalSeconds: 60, } @@ -52,6 +54,7 @@ func TestApplyMonitorUpdate_SameGrokProviderDoesNotResetExistingModel(t *testing existing := &ChannelMonitor{ Provider: MonitorProviderGrok, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.x.ai", PrimaryModel: "grok-4.3", IntervalSeconds: 60, } diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index 20ab1da935..138593e0bd 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -1,6 +1,10 @@ package service -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // MonitorBodyOverrideMode 自定义请求体处理模式。 // @@ -45,6 +49,11 @@ type ChannelMonitor struct { CreatedAt time.Time UpdatedAt time.Time + // 配额模式(check_mode = quota / quota_probe): + // 关联已有账号复用账号侧用量服务,Endpoint/APIKey 可为空(quota 模式)。 + CheckMode string // probe(默认)/ quota / quota_probe;空串按 probe 处理 + AccountID *int64 // 关联账号 ID;账号删除后被 DB 置空(监控保留并报「账号未关联」) + // 请求自定义快照(来自模板拷贝 or 用户手填,运行时直接读取) TemplateID *int64 // 仅用于 UI 分组 + 一键应用,运行时不用 ExtraHeaders map[string]string // 与 adapter 默认 headers 合并,用户优先 @@ -89,6 +98,10 @@ type ChannelMonitorCreateParams struct { ExtraHeaders map[string]string BodyOverrideMode string BodyOverride map[string]any + + // 配额模式:CheckMode 空串默认 probe;quota/quota_probe 必须关联账号。 + CheckMode string + AccountID *int64 } // ChannelMonitorUpdateParams 更新参数(指针字段表示"未提供则不更新")。 @@ -112,6 +125,11 @@ type ChannelMonitorUpdateParams struct { ExtraHeaders *map[string]string BodyOverrideMode *string BodyOverride *map[string]any + + // 配额模式:CheckMode nil = 不更新;AccountID nil = 不更新, + // 指向 0 = 清空关联(退回 probe 模式时由 CheckMode 分支兜底)。 + CheckMode *string + AccountID *int64 } // CheckResult 单个模型一次检测的结果。 @@ -122,6 +140,8 @@ type CheckResult struct { PingLatencyMs *int Message string CheckedAt time.Time + // Quota 配额模式附带快照(quota 模式唯一数据;quota_probe 挂在主模型行)。 + Quota *domain.MonitorQuotaSnapshot } // UserMonitorView 用户只读视图:监控概览(含主模型最近状态 + 7d 可用率 + 附加模型最近状态)。 @@ -137,6 +157,9 @@ type UserMonitorView struct { Availability7d float64 // 0-100 ExtraModels []ExtraModelStatus Timeline []UserMonitorTimelinePoint // 主模型最近 N 个历史点(按 checked_at DESC,最新在前) + // LatestQuota 主模型最近一次配额快照;channel_monitor_show_quota=false + // 时由 handler 服务端剥离。 + LatestQuota *domain.MonitorQuotaSnapshot } // UserMonitorTimelinePoint 用户视图 timeline 单点数据(去除 message 以减小响应体)。 @@ -183,6 +206,7 @@ type ChannelMonitorHistoryRow struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorHistoryEntry 历史记录查询返回行(含 ent 主键 ID)。 @@ -194,6 +218,7 @@ type ChannelMonitorHistoryEntry struct { PingLatencyMs *int Message string CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorLatest 最近一次检测的简明信息(用于 UserMonitorView 聚合)。 @@ -203,6 +228,7 @@ type ChannelMonitorLatest struct { LatencyMs *int PingLatencyMs *int CheckedAt time.Time + Quota *domain.MonitorQuotaSnapshot } // ChannelMonitorAvailability 单个模型在某窗口内的可用率与平均延迟(用于 UserMonitorDetail 聚合)。 @@ -223,4 +249,5 @@ type MonitorStatusSummary struct { PrimaryLatencyMs *int Availability7d float64 // 0-100,无历史时为 0 ExtraModels []ExtraModelStatus + LatestQuota *domain.MonitorQuotaSnapshot // 主模型最近配额快照(配额模式) } diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 7740dc83b7..faf7247fbc 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -9,15 +9,81 @@ import ( // 渠道监控参数校验与归一化辅助函数。 // 校验失败一律返回 channel_monitor_const.go 中预定义的 Err* 错误,错误信息不含具体 IP/hostname,避免泄露内网拓扑。 +// monitorProviders 渠道监控支持的全部 provider(与迁移 226 的 CHECK 约束一致)。 +// 不再以 adapter 表为唯一来源:antigravity 没有探活 adapter,但支持配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var monitorProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderAntigravity: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + +// probeCapableProviders 支持探活(probe / quota_probe)的 provider。 +// antigravity 上游无 Chat/Responses 可打(仅 IDE 代理形态),只允许配额模式。 +// +//nolint:gochecknoglobals // 静态查表,初始化后不变。 +var probeCapableProviders = map[string]struct{}{ + MonitorProviderOpenAI: {}, + MonitorProviderAnthropic: {}, + MonitorProviderGemini: {}, + MonitorProviderGrok: {}, + MonitorProviderKimi: {}, + MonitorProviderZhipu: {}, + MonitorProviderDeepseek: {}, +} + // validateProvider 校验 provider 字符串。 -// 唯一来源于 providerAdapters:新增 provider 只需要在 channel_monitor_checker.go 注册 adapter。 func validateProvider(p string) error { - if !isSupportedProvider(p) { + if _, ok := monitorProviders[p]; !ok { return ErrChannelMonitorInvalidProvider } return nil } +// providerSupportsProbe 该 provider 是否注册了探活 adapter(antigravity 为 false)。 +func providerSupportsProbe(p string) bool { + _, ok := probeCapableProviders[p] + return ok +} + +// defaultCheckMode 空串归一为 probe,保证存量数据与旧客户端兼容。 +func defaultCheckMode(checkMode string) string { + if strings.TrimSpace(checkMode) == "" { + return MonitorCheckModeProbe + } + return strings.TrimSpace(checkMode) +} + +// monitorCheckModeUsesQuota 该模式是否需要关联账号查配额。 +func monitorCheckModeUsesQuota(checkMode string) bool { + return checkMode == MonitorCheckModeQuota || checkMode == MonitorCheckModeQuotaProbe +} + +// validateCheckMode 校验 check_mode 与 provider 的组合矩阵: +// +// provider | probe | quota | quota_probe +// ------------------------+-------+-------+------------ +// openai/anthropic/... | Y | Y | Y +// antigravity(无 adapter)| N | Y | N +func validateCheckMode(provider, checkMode string) error { + checkMode = defaultCheckMode(checkMode) + switch checkMode { + case MonitorCheckModeProbe, MonitorCheckModeQuota, MonitorCheckModeQuotaProbe: + default: + return ErrChannelMonitorInvalidCheckMode + } + if checkMode != MonitorCheckModeQuota && !providerSupportsProbe(provider) { + return ErrChannelMonitorInvalidCheckMode + } + return nil +} + // validateAPIMode 校验 provider 与 api_mode 的组合。 // responses 只对 OpenAI 有意义;其它 provider 使用 chat_completions 作为默认占位。 func validateAPIMode(provider, apiMode string) error { @@ -124,13 +190,18 @@ func normalizeModels(in []string) []string { return out } -// normalizeMonitorPrimaryModel applies the Grok health-check default while -// preserving the existing required-model behavior for every other provider. -func normalizeMonitorPrimaryModel(provider, model string) string { +// normalizeMonitorPrimaryModel applies provider/check_mode defaults while +// preserving the existing required-model behavior: +// - Grok 探活默认轻量测活模型 +// - quota 模式占位 "quota"(primary_model 列 NotEmpty;历史行/时间线机制无需特判) +func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } + if model == "" && monitorCheckModeUsesQuota(defaultCheckMode(checkMode)) { + return MonitorDefaultQuotaModel + } return model } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c0efa2b527..ba35301be0 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -477,6 +477,13 @@ const ( // Default false (show rates). Admin endpoints always keep full metrics. SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput" + // SettingKeyChannelMonitorShowQuota controls whether quota/balance snapshots + // attached to channel monitors (check_mode=quota/quota_probe) are exposed on + // the user-facing monitor APIs and UI. Default false (hidden); parsed + // fail-closed (only the literal "true" enables it). Admin endpoints always + // keep the full snapshots regardless of this flag. + SettingKeyChannelMonitorShowQuota = "channel_monitor_show_quota" + // SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty // request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5. SettingKeyGrokDefaultTextModel = "grok_default_text_model" diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 283c76f8f4..9f3dfbddf2 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -190,6 +190,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorHideThroughput: "true", + SettingKeyChannelMonitorShowQuota: "false", // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. SettingKeyGrokDefaultTextModel: "grok-4.5", @@ -798,6 +799,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) + // 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启 + // (与 setting_public.go 公开读取路径保持一致)。 + result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true" // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index f292e8ac57..81d74aff0d 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -231,6 +231,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, @@ -355,6 +356,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), + ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true", AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", @@ -422,6 +424,10 @@ type ChannelMonitorRuntime struct { DefaultIntervalSeconds int // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. HideThroughput bool + // ShowQuota: when true, user-facing monitor views keep the quota/balance + // snapshots; otherwise the user handler strips them server-side. + // Parsed fail-closed (only literal "true" enables). Admin always sees them. + ShowQuota bool } // ActiveProbesAllowed reports whether V1 active provider probes may run. @@ -450,6 +456,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, + SettingKeyChannelMonitorShowQuota, }) if err != nil { return ChannelMonitorRuntime{ @@ -464,6 +471,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), + ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true", } } @@ -606,12 +614,15 @@ type PublicSettingsInjectionPayload struct { // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM // without waiting for API redaction alone (defense in depth). ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` + // ChannelMonitorShowQuota gates the user-facing quota/balance display on + // monitors; fail-closed (absent/false = hidden). Admin UI always shows it. + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -685,6 +696,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 67bab243ca..3fa0d40426 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -116,6 +116,29 @@ func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing } } +func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) { + // 缺省(迁移插入 'false' / 老库无行)一律不展示。 + missingRuntime := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, missingRuntime.ShowQuota) + missingPublic, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}). + GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, missingPublic.ChannelMonitorShowQuota) + + // 仅字面 "true" 视为开启;其余值(含异常值)fail-closed。 + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: "true", + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, runtime.ShowQuota) + + for _, value := range []string{"false", "TRUE", "1", "yes", "on", "garbage"} { + rt := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorShowQuota: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, rt.ShowQuota, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index f77a914fd7..a4fadded0c 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -417,6 +417,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) } updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) + updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota) // Grok model mapping policy if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8580f0c8fb..a939000947 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -201,6 +201,7 @@ type SystemSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -378,6 +379,7 @@ type PublicSettings struct { ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index d6bb56fac4..88c3a1f185 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -907,6 +907,7 @@ var ProviderSet = wire.NewSet( ProvideBalanceNotifyService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, + NewChannelMonitorQuotaFetcher, ProvideChannelMonitorV2Service, ProvideChannelMonitorV2Aggregator, NewChannelMonitorRequestTemplateService, @@ -965,13 +966,20 @@ func ProvideChannelMonitorService( // 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor, // 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。 // settingService 用于 runner 每次 fire 读取功能开关。 -func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { +// quotaFetcher(账号侧用量聚合)也在此注入:accountUsage/CN 服务在 wire 图中 +// 晚于 channelMonitorService 构造,走 setter 注入避免调整既有构造顺序。 +func ProvideChannelMonitorRunner( + svc *ChannelMonitorService, + settingService *SettingService, + quotaFetcher *ChannelMonitorQuotaFetcher, +) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) if svc != nil { // Ensure runtime reader is set even if ProvideChannelMonitorService // was constructed without settings (tests / alternate providers). svc.SetRuntimeReader(settingService) svc.SetScheduler(r) + svc.SetQuotaFetcher(quotaFetcher) } r.Start() return r diff --git a/backend/migrations/226_channel_monitor_quota_mode.sql b/backend/migrations/226_channel_monitor_quota_mode.sql new file mode 100644 index 0000000000..a0774e3dbf --- /dev/null +++ b/backend/migrations/226_channel_monitor_quota_mode.sql @@ -0,0 +1,78 @@ +-- Migration: 226_channel_monitor_quota_mode +-- 渠道监控配额模式: +-- 1. provider 扩容到全部 8 平台(antigravity/kimi/zhipu/deepseek) +-- (antigravity 仅支持配额模式,无探活 adapter;国产 3 家复用 OpenAI 兼容探活) +-- 2. check_mode:probe(默认,现状探活)/ quota(仅查关联账号用量,零 LLM 成本) +-- / quota_probe(探活 + 配额并存) +-- 3. account_id 关联已有账号(配额模式的数据源,复用账号侧用量服务); +-- 账号删除时置空,监控保留并报「账号未关联」 +-- 4. channel_monitor_histories.quota 持久化归一化配额快照(JSONB) +-- 5. 新增公开设置 channel_monitor_show_quota(默认关闭): +-- 控制用户端监控页是否展示配额/余额;管理端始终可见 + +DO $$ +DECLARE + monitor_constraint_def TEXT; + template_constraint_def TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) + INTO monitor_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitors' + AND c.conname = 'channel_monitors_provider_check'; + + IF monitor_constraint_def IS NULL OR position('kimi' IN monitor_constraint_def) = 0 THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT IF EXISTS channel_monitors_provider_check; + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; + + SELECT pg_get_constraintdef(c.oid) + INTO template_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitor_request_templates' + AND c.conname = 'channel_monitor_request_templates_provider_check'; + + IF template_constraint_def IS NULL OR position('kimi' IN template_constraint_def) = 0 THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check; + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek')); + END IF; +END $$; + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'; + +ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_check_mode_check + CHECK (check_mode IN ('probe', 'quota', 'quota_probe')); + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id); + +COMMENT ON COLUMN channel_monitors.check_mode IS + 'probe = LLM 探活(默认);quota = 仅查关联账号用量;quota_probe = 探活 + 配额'; +COMMENT ON COLUMN channel_monitors.account_id IS + '配额模式关联的账号 ID(数据源);账号删除时置空'; + +ALTER TABLE channel_monitor_histories + ADD COLUMN IF NOT EXISTS quota JSONB; + +COMMENT ON COLUMN channel_monitor_histories.quota IS + '配额模式监控的归一化配额快照(domain.MonitorQuotaSnapshot);探活模式为 NULL'; + +-- 用户端是否展示配额/余额(默认关闭,fail-closed 解析:仅 "true" 视为开启)。 +-- 管理端不受此开关影响。 +INSERT INTO settings (key, value) +VALUES ('channel_monitor_show_quota', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/channel_monitor_quota_mode_migration_test.go b/backend/migrations/channel_monitor_quota_mode_migration_test.go new file mode 100644 index 0000000000..69d2dacaef --- /dev/null +++ b/backend/migrations/channel_monitor_quota_mode_migration_test.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorQuotaModeMigration(t *testing.T) { + content, err := FS.ReadFile("226_channel_monitor_quota_mode.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // provider CHECK 两张表扩到 8 平台,且带幂等守卫(仿 176 grok 迁移)。 + require.Contains(t, sql, "channel_monitors_provider_check") + require.Contains(t, sql, "channel_monitor_request_templates_provider_check") + require.Contains(t, sql, "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek'))") + require.Contains(t, sql, "position('kimi' IN monitor_constraint_def) = 0") + require.Contains(t, sql, "position('kimi' IN template_constraint_def) = 0") + + // check_mode 三态,默认 probe。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS check_mode VARCHAR(32) NOT NULL DEFAULT 'probe'") + require.Contains(t, sql, "CHECK (check_mode IN ('probe', 'quota', 'quota_probe'))") + + // account_id 关联账号,账号删除置空(监控保留,运行时报「账号未关联」)。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS account_id BIGINT REFERENCES accounts(id) ON DELETE SET NULL") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS idx_channel_monitors_account_id ON channel_monitors(account_id)") + + // 历史表配额快照列。 + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS quota JSONB") + + // 公开设置默认关闭。 + require.Contains(t, sql, "VALUES ('channel_monitor_show_quota', 'false')") + require.Contains(t, sql, "ON CONFLICT (key) DO NOTHING") +} diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index 5eb78b798f..f3353ebd45 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -5,10 +5,57 @@ import { apiClient } from '../client' -export type Provider = 'openai' | 'anthropic' | 'gemini' | 'grok' +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'grok' + | 'antigravity' + | 'kimi' + | 'zhipu' + | 'deepseek' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' +/** + * probe = LLM 探活(默认);quota = 仅查关联账号用量(零 LLM 成本); + * quota_probe = 探活 + 配额快照挂主模型行。 + */ +export type CheckMode = 'probe' | 'quota' | 'quota_probe' + +/** 配额快照中的单个用量窗口(与后端 domain.MonitorQuotaTier 一致)。 */ +export interface MonitorQuotaTier { + /** 5h | 7d | 7d-sonnet | 7d-fable | 30d | daily | weekly | total */ + window: string + /** 同窗口多档时的机器标识(gemini shared/pro/flash、grok requests/tokens、antigravity 模型名) */ + label?: string + used_percent: number + used?: number + limit?: number + /** RFC3339;空表示无重置时间 */ + reset_at?: string +} + +export interface MonitorBalance { + currency: string + balance: number +} + +/** 归一化配额快照(与后端 domain.MonitorQuotaSnapshot 一致)。 */ +export interface MonitorQuotaSnapshot { + /** usage | cn_quota | cn_balance */ + source: string + success: boolean + tiers?: MonitorQuotaTier[] + balance?: number | null + balances?: MonitorBalance[] + currency?: string + plan_level?: string + /** 401/403 鉴权失败标记(推导为 failed 状态) */ + credential_invalid?: boolean + error?: string + fetched_at: string +} export interface ChannelMonitor { id: number @@ -47,6 +94,12 @@ export interface ChannelMonitor { extra_headers: Record body_override_mode: BodyOverrideMode body_override: Record | null + /** 检测模式:probe(默认)/ quota / quota_probe */ + check_mode: CheckMode + /** 配额模式关联的账号 ID;探活模式为 null */ + account_id: number | null + /** 主模型最近一次配额快照(配额模式;无历史时为 null) */ + latest_quota?: MonitorQuotaSnapshot | null } export interface ExtraModelStatus { @@ -75,8 +128,14 @@ export interface CreateParams { name: string provider: Provider api_mode?: APIMode + /** 探活模式必填(base origin);quota 模式可留空 */ endpoint: string + /** 探活模式必填;quota 模式可留空 */ api_key: string + /** 缺省 probe;antigravity 仅支持 quota */ + check_mode?: CheckMode + /** 配额模式必填:数据源账号(provider 需与账号平台一致) */ + account_id?: number | null primary_model: string extra_models?: string[] group_name?: string @@ -101,6 +160,8 @@ export interface CheckResult { ping_latency_ms: number | null message: string checked_at: string + /** 配额模式(quota / quota_probe 主模型行)附带的配额快照 */ + quota?: MonitorQuotaSnapshot | null } export interface RunNowResponse { @@ -115,6 +176,8 @@ export interface HistoryItem { ping_latency_ms: number | null message: string checked_at: string + /** 配额快照(配额模式行;探活行为空) */ + quota?: MonitorQuotaSnapshot | null } export interface HistoryParams { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index c8dbe8fb78..f5f19918a8 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -718,6 +718,7 @@ export interface SystemSettings { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -1016,6 +1017,7 @@ export interface UpdateSettingsRequest { channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; channel_monitor_hide_throughput?: boolean; + channel_monitor_show_quota?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; diff --git a/frontend/src/api/channelMonitor.ts b/frontend/src/api/channelMonitor.ts index 38dd0c99a8..68076db77d 100644 --- a/frontend/src/api/channelMonitor.ts +++ b/frontend/src/api/channelMonitor.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { Provider, MonitorStatus } from './admin/channelMonitor' +import type { MonitorQuotaSnapshot, Provider, MonitorStatus } from './admin/channelMonitor' export type { Provider, MonitorStatus } from './admin/channelMonitor' @@ -33,6 +33,11 @@ export interface UserMonitorView { availability_7d: number extra_models: UserMonitorExtraModel[] timeline: MonitorTimelinePoint[] + /** + * 主模型最近配额快照。仅当系统开启 channel_monitor_show_quota 时 + * 服务端才会下发(关闭时服务端已剥离,前端 flag 仅作纵深防御)。 + */ + latest_quota?: MonitorQuotaSnapshot | null } export interface UserMonitorListResponse { diff --git a/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts index 31f9eca738..a7989ffeaa 100644 --- a/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts +++ b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts @@ -34,6 +34,8 @@ function makeMonitor(overrides: Partial = {}): ChannelMonitor { extra_headers: {}, body_override_mode: 'off', body_override: null, + check_mode: 'probe', + account_id: null, ...overrides, } } diff --git a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue index 544238f49c..d79e374dce 100644 --- a/frontend/src/components/admin/monitor/MonitorFiltersBar.vue +++ b/frontend/src/components/admin/monitor/MonitorFiltersBar.vue @@ -71,6 +71,10 @@ import { PROVIDER_ANTHROPIC, PROVIDER_GEMINI, PROVIDER_GROK, + PROVIDER_ANTIGRAVITY, + PROVIDER_KIMI, + PROVIDER_ZHIPU, + PROVIDER_DEEPSEEK, } from '@/constants/channelMonitor' defineProps<{ @@ -96,6 +100,10 @@ const providerFilterOptions = computed(() => [ { value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') }, { value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') }, { value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') }, + { value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') }, + { value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') }, + { value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') }, + { value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') }, ]) const enabledFilterOptions = computed(() => [ diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue index 14a9e2dd15..ea73b929ed 100644 --- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue @@ -11,6 +11,27 @@ + +
+ +
+ +
+
+
@@ -30,7 +51,34 @@
-
+ +
+ +
+ @@ -58,7 +106,7 @@
-
+
@@ -77,7 +125,7 @@

{{ editing.api_key_masked }}

-
+
-
+
- -
+ +
{{ t('admin.channelMonitor.advanced.section') }} @@ -197,6 +245,7 @@ import type { ChannelMonitor, CreateParams, APIMode, + CheckMode, Provider, UpdateParams, } from '@/api/admin/channelMonitor' @@ -216,10 +265,20 @@ import { PROVIDER_ANTHROPIC, PROVIDER_GEMINI, PROVIDER_GROK, + PROVIDER_ANTIGRAVITY, + PROVIDER_KIMI, + PROVIDER_ZHIPU, + PROVIDER_DEEPSEEK, API_MODE_CHAT_COMPLETIONS, API_MODE_RESPONSES, + CHECK_MODE_PROBE, + CHECK_MODE_QUOTA, + CHECK_MODE_QUOTA_PROBE, DEFAULT_GROK_ENDPOINT, DEFAULT_GROK_MODEL, + DEFAULT_KIMI_ENDPOINT, + DEFAULT_ZHIPU_ENDPOINT, + DEFAULT_DEEPSEEK_ENDPOINT, DEFAULT_INTERVAL_SECONDS, } from '@/constants/channelMonitor' @@ -259,6 +318,8 @@ interface MonitorForm { name: string provider: Provider api_mode: APIMode + check_mode: CheckMode + account_id: number | null endpoint: string api_key: string primary_model: string @@ -278,6 +339,8 @@ const form = reactive({ name: '', provider: PROVIDER_ANTHROPIC, api_mode: API_MODE_CHAT_COMPLETIONS, + check_mode: CHECK_MODE_PROBE, + account_id: null, endpoint: '', api_key: '', primary_model: '', @@ -292,6 +355,10 @@ const form = reactive({ body_override: null, }) +// quota / quota_probe 需要关联账号;probe / quota_probe 需要探活字段。 +const usesQuotaMode = computed(() => form.check_mode !== CHECK_MODE_PROBE) +const usesProbePart = computed(() => form.check_mode !== CHECK_MODE_QUOTA) + // jitter 上限与后端校验一致:interval - jitter 不得低于最小检测间隔 15 秒。 const maxJitterSeconds = computed(() => Math.max(0, (form.interval_seconds || 0) - 15)) @@ -402,8 +469,194 @@ const providerOptions = computed(() => [ { value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') }, { value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') }, { value: PROVIDER_GROK, label: t('monitorCommon.providers.grok') }, + { value: PROVIDER_ANTIGRAVITY, label: t('monitorCommon.providers.antigravity') }, + { value: PROVIDER_KIMI, label: t('monitorCommon.providers.kimi') }, + { value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') }, + { value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') }, ]) +// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。 +const PROVIDER_DEFAULT_ENDPOINTS: Partial> = { + [PROVIDER_KIMI]: DEFAULT_KIMI_ENDPOINT, + [PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT, + [PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT, +} + +interface CheckModeOption { + value: CheckMode + label: string + hint: string + disabled: boolean +} + +const checkModeOptions = computed(() => [ + { + value: CHECK_MODE_PROBE, + label: t('admin.channelMonitor.form.checkModeProbe'), + hint: t('admin.channelMonitor.form.checkModeProbeHint'), + // antigravity 无探活 adapter,仅配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, + { + value: CHECK_MODE_QUOTA, + label: t('admin.channelMonitor.form.checkModeQuota'), + hint: t('admin.channelMonitor.form.checkModeQuotaHint'), + disabled: false, + }, + { + value: CHECK_MODE_QUOTA_PROBE, + label: t('admin.channelMonitor.form.checkModeQuotaProbe'), + hint: t('admin.channelMonitor.form.checkModeQuotaProbeHint'), + // antigravity 无探活 adapter,只支持配额模式。 + disabled: form.provider === PROVIDER_ANTIGRAVITY, + }, +]) + +function checkModeButtonClass(mode: CheckMode): string { + const active = form.check_mode === mode + if (active) { + return 'border-primary-500 bg-white text-primary-700 shadow-sm dark:border-primary-400 dark:bg-primary-500/15 dark:text-primary-300' + } + return 'border-blue-100 bg-white/70 text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400' +} + +function selectCheckMode(mode: CheckMode) { + if (checkModeOptions.value.find((opt) => opt.value === mode)?.disabled) return + form.check_mode = mode + if (!usesQuotaMode.value) form.account_id = null +} + +// --- 关联账号选择器 --- + +interface LinkedAccount { + id: number + name: string +} + +const linkedAccounts = ref([]) +const accountsLoading = ref(false) +// 当前搜索词(用于空态文案区分「平台无账号」与「搜索无命中」)。 +const accountSearchQuery = ref('') +// 已绑定账号回填失败(getById 失败或平台失配)时提示用户重新选择。 +const accountHydrationFailed = ref(false) +// 固定选项:已绑定/已选中但不在当前结果页里的账号,保证搜索后 label 仍可见。 +const pinnedAccount = ref(null) +let accountSearchSeq = 0 +let accountSearchAbort: AbortController | null = null +const hydrationAttempted = new Set() + +const accountOptions = computed(() => { + const opts = linkedAccounts.value.map((a) => ({ + value: String(a.id), + label: `${a.name} (#${a.id})`, + })) + const pinned = pinnedAccount.value + if (pinned && !linkedAccounts.value.some((a) => a.id === pinned.id)) { + opts.unshift({ value: String(pinned.id), label: `${pinned.name} (#${pinned.id})` }) + } + return opts +}) + +// Select 组件绑定 string,与 number | null 互转。 +const accountSelectValue = computed({ + get: () => (form.account_id == null ? '' : String(form.account_id)), + set: (raw: string) => { + if (raw === '') { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false + return + } + const id = Number(raw) + if (Number.isFinite(id)) { + form.account_id = id + pinnedAccount.value = linkedAccounts.value.find((a) => a.id === id) ?? pinnedAccount.value + } + }, +}) + +// 服务端搜索当前 provider 平台的账号(支持关键字,避免大分页截断取不齐)。 +// seq + abort 防止快速切换 provider / 连续输入时乱序响应覆盖新结果。 +// 失败不阻塞表单:下拉为空 + 空态提示。 +async function loadLinkedAccounts(search = '') { + if (!usesQuotaMode.value || !props.show) return + accountSearchQuery.value = search + const seq = ++accountSearchSeq + accountSearchAbort?.abort() + const controller = new AbortController() + accountSearchAbort = controller + accountsLoading.value = true + try { + const res = await adminAPI.accounts.list( + 1, + 50, + { platform: form.provider, ...(search ? { search } : {}) }, + { signal: controller.signal }, + ) + if (seq !== accountSearchSeq) return + linkedAccounts.value = (res.items || []).map((a) => ({ id: a.id, name: a.name })) + await ensureSelectedAccountHydrated() + } catch (err: unknown) { + if (controller.signal.aborted) return + console.warn('load linked accounts failed', err) + if (!search) linkedAccounts.value = [] + } finally { + if (seq === accountSearchSeq) accountsLoading.value = false + } +} + +// 编辑已有 quota 监控时,已绑定账号可能不在搜索结果第一页:用 getById +// 回填为固定选项,绑定不因分页截断而丢失。仅当账号确实无法加载或平台 +// 失配时才清空绑定(带可见提示),否则绑定只在用户显式切换 provider 时清空。 +async function ensureSelectedAccountHydrated() { + const id = form.account_id + if (id == null || !usesQuotaMode.value) return + if (linkedAccounts.value.some((a) => a.id === id) || pinnedAccount.value?.id === id) return + if (hydrationAttempted.has(id)) return + hydrationAttempted.add(id) + try { + const account = await adminAPI.accounts.getById(id) + if (form.account_id !== id) return + if (String(account.platform) !== form.provider) { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = true + return + } + pinnedAccount.value = { id: account.id, name: account.name } + } catch { + if (form.account_id === id) { + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = true + } + } +} + +function onAccountSearch(query: string) { + void loadLinkedAccounts(query) +} + +watch( + () => [props.show, form.provider, form.check_mode] as const, + ([show, provider], prev) => { + const [prevShow, prevProvider] = prev ?? [] + if (!show) { + accountSearchAbort?.abort() + return + } + // 弹窗重开 / provider 真正变化时重置回填状态(check_mode 变化不重置, + // 避免 probe↔quota 切换时无谓地重拉列表)。 + if (show !== prevShow || provider !== prevProvider) { + hydrationAttempted.clear() + accountHydrationFailed.value = false + pinnedAccount.value = null + } + void loadLinkedAccounts() + }, + { immediate: true }, +) + function selectProvider(provider: Provider) { if (form.provider === provider) return const previousProvider = form.provider @@ -411,14 +664,26 @@ function selectProvider(provider: Provider) { previousProvider === PROVIDER_GROK && form.endpoint === DEFAULT_GROK_ENDPOINT const clearGrokModel = previousProvider === PROVIDER_GROK && form.primary_model === DEFAULT_GROK_MODEL + const clearPrevDefaultEndpoint = + !!PROVIDER_DEFAULT_ENDPOINTS[previousProvider] && form.endpoint === PROVIDER_DEFAULT_ENDPOINTS[previousProvider] form.provider = provider + // 关联账号与平台绑定:切换 provider 时显式清空(这是唯一主动清空的入口)。 + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false + // antigravity 仅配额模式:切到它时强制 quota(checkModeOptions 同步禁用其余项)。 + if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) { + form.check_mode = CHECK_MODE_QUOTA + } if (provider === PROVIDER_GROK) { if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL return } - if (clearGrokEndpoint) form.endpoint = '' + if (clearGrokEndpoint || clearPrevDefaultEndpoint) form.endpoint = '' if (clearGrokModel) form.primary_model = '' + const defaultEndpoint = PROVIDER_DEFAULT_ENDPOINTS[provider] + if (defaultEndpoint && !form.endpoint.trim()) form.endpoint = defaultEndpoint } // Clear api_key whenever provider changes to avoid cross-provider key mismatch. @@ -447,6 +712,10 @@ function resetForm() { form.name = '' form.provider = PROVIDER_ANTHROPIC form.api_mode = API_MODE_CHAT_COMPLETIONS + form.check_mode = CHECK_MODE_PROBE + form.account_id = null + pinnedAccount.value = null + accountHydrationFailed.value = false form.endpoint = '' form.api_key = '' form.primary_model = '' @@ -467,6 +736,8 @@ function loadFromMonitor(m: ChannelMonitor) { form.name = m.name form.provider = m.provider form.api_mode = normalizeAPIMode(m.api_mode) + form.check_mode = m.check_mode || CHECK_MODE_PROBE + form.account_id = m.account_id ?? null form.endpoint = m.endpoint form.api_key = '' form.primary_model = m.primary_model @@ -533,15 +804,17 @@ function buildPayload(): CreateParams { name: form.name.trim(), provider: form.provider, api_mode: form.provider === PROVIDER_OPENAI ? form.api_mode : API_MODE_CHAT_COMPLETIONS, - endpoint: form.endpoint.trim(), - api_key: form.api_key.trim(), - primary_model: form.primary_model.trim(), - extra_models: form.extra_models, + check_mode: form.check_mode, + account_id: usesQuotaMode.value ? form.account_id : null, + endpoint: usesProbePart.value ? form.endpoint.trim() : '', + api_key: usesProbePart.value ? form.api_key.trim() : '', + primary_model: usesProbePart.value ? form.primary_model.trim() : 'quota', + extra_models: usesProbePart.value ? form.extra_models : [], group_name: form.group_name.trim(), enabled: form.enabled, interval_seconds: form.interval_seconds, jitter_seconds: form.jitter_seconds || 0, - template_id: form.template_id, + template_id: usesProbePart.value ? form.template_id : null, extra_headers: form.extra_headers, body_override_mode: form.body_override_mode, body_override: form.body_override, @@ -554,7 +827,11 @@ async function handleSubmit() { appStore.showError(t('admin.channelMonitor.nameRequired')) return } - if (!form.primary_model.trim()) { + if (usesQuotaMode.value && form.account_id == null) { + appStore.showError(t('admin.channelMonitor.linkedAccountRequired')) + return + } + if (usesProbePart.value && !form.primary_model.trim()) { appStore.showError(t('admin.channelMonitor.primaryModelRequired')) return } @@ -568,7 +845,7 @@ async function handleSubmit() { // Only send api_key if user typed a new value if (api_key) req.api_key = api_key // template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义) - if (form.template_id == null) { + if (usesProbePart.value && form.template_id == null) { req.clear_template = true delete req.template_id } diff --git a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue index eccec82848..67797904c7 100644 --- a/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue +++ b/frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue @@ -1,7 +1,8 @@