Merge pull request #5761 from Randark-JMT/feat/channel-monitor-quota-mode

feat(monitor): 渠道监控配额模式——关联账号展示用量/余额(重启 #5387)
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-08-18 17:36:27 +08:00
commit e0c48a19ed
79 files changed
+4827 -166

No files matched your search

+2 -1
View File
@@ -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)
+28 -3
View File
@@ -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(", ")
+29 -5
View File
@@ -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()
+125
View File
@@ -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))
+168
View File
@@ -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) {
+116
View File
@@ -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)
}
+17
View File
@@ -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(')')
@@ -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,
}
@@ -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))
@@ -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) {
@@ -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)
}
@@ -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)
+1 -1
View File
@@ -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"`
+18 -10
View File
@@ -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},
+236 -2
View File
@@ -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
+19 -27
View File
@@ -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()
+21 -3
View File
@@ -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"),
}
}
@@ -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),
}
@@ -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).
@@ -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"`
}
@@ -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)
@@ -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,
@@ -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,
@@ -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})
}
+2
View File
@@ -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"`
@@ -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,
@@ -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)
}
@@ -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, &quota); 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{}
@@ -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,
@@ -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
}
@@ -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) {
@@ -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 {
@@ -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",
@@ -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 ""
}
@@ -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)
}
@@ -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 := &quotaModeRepoStub{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 := &quotaModeRepoStub{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 := &quotaModeRepoStub{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 := &quotaModeRepoStub{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 := &quotaModeRepoStub{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")
}
@@ -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
}
@@ -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,
}
@@ -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 // 主模型最近配额快照(配额模式)
}
@@ -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
}
@@ -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"
@@ -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])
+18 -6
View File
@@ -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,
@@ -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{
@@ -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 != "" {
@@ -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"`
+9 -1
View File
@@ -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
@@ -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;
@@ -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")
}
+64 -1
View File
@@ -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<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | 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 {
+2
View File
@@ -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;
+6 -1
View File
@@ -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 {
@@ -34,6 +34,8 @@ function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
@@ -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(() => [
@@ -11,6 +11,27 @@
<input v-model="form.name" type="text" required class="input" :placeholder="t('admin.channelMonitor.form.namePlaceholder')" />
</div>
<!-- 检测模式:probe(探活)/ quota(仅配额)/ quota_probe(探活+配额) -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.checkMode') }}</label>
<div class="grid gap-3 sm:grid-cols-3" data-testid="monitor-check-mode">
<button
v-for="opt in checkModeOptions"
:key="opt.value"
type="button"
:data-testid="`monitor-check-mode-${opt.value}`"
:aria-pressed="form.check_mode === opt.value"
:disabled="opt.disabled"
class="rounded-lg border-2 px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="checkModeButtonClass(opt.value)"
@click="selectCheckMode(opt.value)"
>
<span class="block text-sm font-semibold">{{ opt.label }}</span>
<span class="mt-0.5 block text-xs opacity-80">{{ opt.hint }}</span>
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.provider') }} <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -30,7 +51,34 @@
</div>
</div>
<div v-if="form.provider === PROVIDER_OPENAI" class="rounded-lg border border-blue-100 bg-blue-50/50 p-3 dark:border-blue-500/20 dark:bg-blue-500/10">
<!-- 配额模式数据源:关联账号(复用账号侧用量/余额服务) -->
<div v-if="usesQuotaMode">
<label class="input-label">
{{ t('admin.channelMonitor.form.linkedAccount') }} <span class="text-red-500">*</span>
</label>
<div data-testid="monitor-linked-account">
<Select
v-model="accountSelectValue"
:options="accountOptions"
:placeholder="t('admin.channelMonitor.form.linkedAccountPlaceholder')"
remote
:loading="accountsLoading"
@search="onAccountSearch"
/>
</div>
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.form.linkedAccountHint') }}</p>
<p v-if="form.provider === PROVIDER_OPENAI" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.channelMonitor.form.openAIQuotaProbeHint') }}
</p>
<p v-if="accountHydrationFailed" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.channelMonitor.form.linkedAccountMissing') }}
</p>
<p v-if="accountOptions.length === 0 && !accountsLoading && !accountSearchQuery" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.channelMonitor.form.linkedAccountEmpty') }}
</p>
</div>
<div v-if="form.provider === PROVIDER_OPENAI && usesProbePart" class="rounded-lg border border-blue-100 bg-blue-50/50 p-3 dark:border-blue-500/20 dark:bg-blue-500/10">
<label class="input-label">{{ t('admin.channelMonitor.form.apiMode') }}</label>
<div class="grid gap-3 sm:grid-cols-2">
<button
@@ -48,7 +96,7 @@
</div>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.endpoint') }} <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input v-model="form.endpoint" data-testid="monitor-endpoint" type="text" required class="input flex-1" :placeholder="t('admin.channelMonitor.form.endpointPlaceholder')" />
@@ -58,7 +106,7 @@
</div>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">
{{ t('admin.channelMonitor.form.apiKey') }}<span v-if="!editing" class="text-red-500"> *</span>
</label>
@@ -77,7 +125,7 @@
<p v-if="editing && editing.api_key_masked" class="mt-1 text-xs text-gray-400">{{ editing.api_key_masked }}</p>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.primaryModel') }} <span class="text-red-500">*</span></label>
<input
v-model="form.primary_model"
@@ -90,7 +138,7 @@
/>
</div>
<div>
<div v-if="usesProbePart">
<label class="input-label">{{ t('admin.channelMonitor.form.extraModels') }}</label>
<ModelTagInput
:models="form.extra_models"
@@ -122,8 +170,8 @@
<Toggle v-model="form.enabled" />
</div>
<!-- 高级设置区:请求模板 + 自定义 headers/body -->
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<!-- 高级设置区:请求模板 + 自定义 headers/body(仅探活模式有意义) -->
<details v-if="usesProbePart" class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channelMonitor.advanced.section') }}
</summary>
@@ -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<MonitorForm>({
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<MonitorForm>({
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<number>(() => Math.max(0, (form.interval_seconds || 0) - 15))
@@ -402,8 +469,194 @@ const providerOptions = computed<ProviderOption[]>(() => [
{ 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<Record<Provider, string>> = {
[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<CheckModeOption[]>(() => [
{
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<LinkedAccount[]>([])
const accountsLoading = ref(false)
// 当前搜索词(用于空态文案区分「平台无账号」与「搜索无命中」)。
const accountSearchQuery = ref('')
// 已绑定账号回填失败(getById 失败或平台失配)时提示用户重新选择。
const accountHydrationFailed = ref(false)
// 固定选项:已绑定/已选中但不在当前结果页里的账号,保证搜索后 label 仍可见。
const pinnedAccount = ref<LinkedAccount | null>(null)
let accountSearchSeq = 0
let accountSearchAbort: AbortController | null = null
const hydrationAttempted = new Set<number>()
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<string>({
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
}
@@ -1,7 +1,8 @@
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<HelpTooltip>
<template #trigger>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
@@ -52,7 +53,10 @@
</table>
</div>
</div>
</HelpTooltip>
</HelpTooltip>
</div>
<!-- 配额模式监控:主模型行内联展示最新用量/余额快照(管理端不受用户端开关限制) -->
<MonitorQuotaView :snapshot="row.latest_quota" />
</div>
</template>
@@ -60,6 +64,7 @@
import { useI18n } from 'vue-i18n'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
@@ -14,6 +14,7 @@
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ r.model }}</span>
<span v-if="r.message" class="text-xs text-gray-500 dark:text-gray-400">{{ r.message }}</span>
<MonitorQuotaView :snapshot="r.quota" class="mt-1" />
</div>
<div class="flex items-center gap-2">
<span
@@ -40,6 +41,7 @@
import { useI18n } from 'vue-i18n'
import type { CheckResult } from '@/api/admin/channelMonitor'
import BaseDialog from '@/components/common/BaseDialog.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
defineProps<{
@@ -249,6 +249,11 @@ import {
PROVIDER_OPENAI,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDERS,
API_MODE_CHAT_COMPLETIONS,
API_MODE_RESPONSES,
} from '@/constants/channelMonitor'
@@ -269,6 +274,10 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
{ 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') },
])
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
@@ -280,12 +289,7 @@ const templatesForActiveProvider = computed(() =>
)
const countByProvider = computed<Record<Provider, number>>(() => {
const out: Record<Provider, number> = {
anthropic: 0,
openai: 0,
gemini: 0,
grok: 0,
}
const out = Object.fromEntries(PROVIDERS.map((p) => [p, 0])) as Record<Provider, number>
for (const t of templates.value) out[t.provider]++
return out
})
@@ -0,0 +1,156 @@
<template>
<div v-if="snapshot" class="space-y-1" data-testid="monitor-quota-view">
<!-- 套餐等级徽章(如智谱 plan level / Claude 订阅档) -->
<div v-if="snapshot.plan_level" class="flex flex-wrap items-center gap-1.5">
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
{{ snapshot.plan_level }}
</span>
</div>
<!-- 用量窗口条形图(样式/阈值对齐账号页 CNProviderQuotaCell) -->
<div v-if="snapshot.success && tierRows.length" class="space-y-1">
<div v-for="row in tierRows" :key="row.key" class="flex items-center gap-1.5 text-[10px]">
<span class="w-14 shrink-0 truncate text-gray-500 dark:text-gray-400" :title="row.title">
{{ row.label }}
</span>
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-full rounded-full transition-all"
:class="utilizationColor(row.tier.used_percent)"
:style="{ width: `${Math.min(100, Math.max(0, row.tier.used_percent))}%` }"
/>
</div>
<span :class="['shrink-0 font-medium', utilizationTextColor(row.tier.used_percent)]">
{{ Math.round(row.tier.used_percent) }}%
</span>
<span v-if="row.tier.reset_at" class="truncate text-gray-400 dark:text-gray-500" :title="row.tier.reset_at">
· {{ formatReset(row.tier.reset_at) }}
</span>
</div>
</div>
<!-- 余额(国产 payg;支持多币种) -->
<div v-if="snapshot.success && balanceRows.length" class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px]">
<span
v-for="b in balanceRows"
:key="b.currency"
:class="['font-medium', b.balance <= 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-600 dark:text-gray-300']"
>
{{ b.balance.toFixed(2) }} {{ b.currency }}
</span>
</div>
<div v-if="!snapshot.success" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="snapshot.error" data-testid="monitor-quota-error">
{{ truncatedError }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MonitorQuotaSnapshot, MonitorQuotaTier } from '@/api/admin/channelMonitor'
/**
* 配额快照渲染(管理端监控列表/运行结果 + 用户端监控卡片共用)。
* 展示形态对齐账号管理侧的用量视图(CNProviderQuotaCell:同阈值配色、
* 同倒计时格式);tier 的 Window/Label 是后端约定的机器 token,
* 已知 token 走 i18n,未知 token 原样展示(前向兼容)。
*/
const props = defineProps<{
snapshot?: MonitorQuotaSnapshot | null
}>()
const { t, te } = useI18n()
interface QuotaTierRow {
key: string
label: string
title: string
tier: MonitorQuotaTier
}
// 已知的 window/label 机器 token → i18n key(monitorCommon.quota.*)。
const windowI18nKeys: Record<string, string> = {
'5h': 'monitorCommon.quota.windows.5h',
'7d': 'monitorCommon.quota.windows.7d',
'7d-sonnet': 'monitorCommon.quota.windows.7dSonnet',
'7d-fable': 'monitorCommon.quota.windows.7dFable',
weekly: 'monitorCommon.quota.windows.weekly',
daily: 'monitorCommon.quota.windows.daily',
'30d': 'monitorCommon.quota.windows.30d',
total: 'monitorCommon.quota.windows.total',
}
const labelI18nKeys: Record<string, string> = {
requests: 'monitorCommon.quota.labels.requests',
tokens: 'monitorCommon.quota.labels.tokens',
shared: 'monitorCommon.quota.labels.shared',
pro: 'monitorCommon.quota.labels.pro',
flash: 'monitorCommon.quota.labels.flash',
}
function windowLabel(window: string): string {
const key = windowI18nKeys[window]
return key && te(key) ? t(key) : window
}
function tierLabel(tier: MonitorQuotaTier): string {
const window = windowLabel(tier.window)
if (!tier.label) return window
const labelKey = labelI18nKeys[tier.label]
const label = labelKey && te(labelKey) ? t(labelKey) : tier.label
return `${label}/${window}`
}
const tierRows = computed<QuotaTierRow[]>(() =>
(props.snapshot?.tiers || []).map((tier, idx) => ({
key: `${tier.window}-${tier.label || ''}-${idx}`,
label: tierLabel(tier),
title: tierLabel(tier),
tier,
})),
)
const balanceRows = computed(() => {
const snapshot = props.snapshot
if (!snapshot) return []
if (snapshot.balances?.length) return snapshot.balances
if (snapshot.balance != null) {
return [{ currency: snapshot.currency || '?', balance: snapshot.balance }]
}
return []
})
const truncatedError = computed(() => {
const error = props.snapshot?.error || t('monitorCommon.quota.unavailable')
return error.length > 48 ? `${error.slice(0, 48)}…` : error
})
const utilizationColor = (pct: number) => {
if (pct >= 90) return 'bg-red-500'
if (pct >= 75) return 'bg-amber-500'
return 'bg-emerald-500'
}
const utilizationTextColor = (pct: number) => {
if (pct >= 90) return 'text-red-600 dark:text-red-400'
if (pct >= 75) return 'text-amber-600 dark:text-amber-400'
return 'text-emerald-600 dark:text-emerald-400'
}
// 重置时间相对/绝对简短显示(与账号页一致)。
const formatReset = (iso: string) => {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const now = Date.now()
const diffMs = d.getTime() - now
if (diffMs <= 0) return t('monitorCommon.quota.resetSoon')
if (diffMs < 3_600_000) return `${Math.max(1, Math.round(diffMs / 60_000))}m`
const hours = Math.round(diffMs / 3_600_000)
if (hours < 48) return `${hours}h`
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${mm}-${dd}`
}
</script>
+36 -3
View File
@@ -111,7 +111,7 @@
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
{{ props.loading ? t('common.loading') : emptyTextDisplay }}
</div>
</div>
</div>
@@ -154,11 +154,16 @@ interface Props {
id?: string
ariaLabel?: string
ariaDescribedby?: string
/** 远程搜索模式:输入不在本地过滤 options,而是防抖后 emit('search', query),由父组件请求数据更新 options */
remote?: boolean
/** 远程搜索模式下的加载态:options 为空时下拉显示 loading 文案 */
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
(e: 'search', query: string): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -169,7 +174,9 @@ const props = withDefaults(defineProps<Props>(), {
creatablePrefix: '',
clearable: false,
valueKey: 'value',
labelKey: 'label'
labelKey: 'label',
remote: false,
loading: false
})
const emit = defineEmits<Emits>()
@@ -192,7 +199,13 @@ const placeholderText = computed(() => props.placeholder ?? t('common.selectOpti
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
// 远程搜索的防抖间隔(对齐 OpenAIFastPolicyUserSelector 的 300ms 惯例)。
const REMOTE_SEARCH_DEBOUNCE_MS = 300
let remoteSearchTimer: ReturnType<typeof setTimeout> | null = null
const isSearchable = computed(() => {
// 远程搜索模式始终显示搜索框(选项只是服务端结果的一页)。
if (props.remote) return true
if (props.searchable === 'auto') return props.options.length > 5
return props.searchable
})
@@ -276,7 +289,8 @@ const hasValue = computed(
const filteredOptions = computed(() => {
let opts = props.options as any[]
if (isSearchable.value && searchQuery.value) {
// 远程搜索模式不在本地过滤(选项即服务端搜索结果的一页)。
if (isSearchable.value && searchQuery.value && !props.remote) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => {
// Match label
@@ -377,11 +391,26 @@ watch(isOpen, (open) => {
} else {
searchQuery.value = ''
focusedIndex.value = -1
// 关闭时取消仍在排队的远程搜索(避免关闭后尾随 emit 一次 search(''))。
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
}
})
// 远程搜索:输入防抖后交给父组件请求(!isOpen 抑制关闭重置 searchQuery 触发的空 query)。
watch(searchQuery, (query) => {
if (!props.remote || !isOpen.value) return
if (remoteSearchTimer) clearTimeout(remoteSearchTimer)
remoteSearchTimer = setTimeout(() => {
remoteSearchTimer = null
emit('search', query.trim())
}, REMOTE_SEARCH_DEBOUNCE_MS)
})
const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
@@ -467,6 +496,10 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
})
</script>
@@ -0,0 +1,128 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { MonitorQuotaSnapshot } from '@/api/admin/channelMonitor'
import MonitorQuotaView from '../MonitorQuotaView.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
// te() 恒真:已知 token 直接返回 i18n key,便于断言 window/label 映射。
useI18n: () => ({ t: (key: string) => key, te: () => true }),
}
})
function makeSnapshot(overrides: Partial<MonitorQuotaSnapshot> = {}): MonitorQuotaSnapshot {
return {
source: 'usage',
success: true,
fetched_at: '2026-08-18T00:00:00Z',
...overrides,
}
}
describe('MonitorQuotaView', () => {
it('renders nothing without a snapshot', () => {
const wrapper = mount(MonitorQuotaView, { props: { snapshot: null } })
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('maps tier window/label tokens through i18n and colors utilization', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
tiers: [
{ window: '5h', used_percent: 42.4 },
{ window: '7d', label: 'pro', used_percent: 80 },
{ window: 'weekly', label: 'unknown-token', used_percent: 95 },
],
}),
},
})
const rows = wrapper.findAll('[data-testid="monitor-quota-view"] .flex.items-center')
// tier 行只在 success 且有数据时渲染
expect(rows.length).toBeGreaterThanOrEqual(3)
const text = wrapper.text()
// 已知 window token 走 i18n
expect(text).toContain('monitorCommon.quota.windows.5h')
expect(text).toContain('monitorCommon.quota.windows.7d')
// 已知 label token 拼成 label/window
expect(text).toContain('monitorCommon.quota.labels.pro/monitorCommon.quota.windows.7d')
// 未知 label 原样透出(前向兼容)
expect(text).toContain('unknown-token/monitorCommon.quota.windows.weekly')
// 百分比取整
expect(text).toContain('42%')
expect(text).toContain('80%')
expect(text).toContain('95%')
const html = wrapper.html()
// 阈值配色:≥90 红 / ≥75 黄 / 其余绿(与账号页 CNProviderQuotaCell 对齐)
expect(html).toContain('bg-emerald-500')
expect(html).toContain('bg-amber-500')
expect(html).toContain('bg-red-500')
})
it('clamps the tier bar width into 0-100', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({ tiers: [{ window: '5h', used_percent: 240 }] }),
},
})
expect(wrapper.html()).toContain('width: 100%')
})
it('shows the plan level badge and multi-currency balances', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
plan_level: 'Max20',
balances: [
{ currency: 'CNY', balance: 12.5 },
{ currency: 'USD', balance: 0 },
],
}),
},
})
expect(wrapper.text()).toContain('Max20')
expect(wrapper.text()).toContain('12.50 CNY')
expect(wrapper.text()).toContain('0.00 USD')
// 余额为 0 用红色警示
expect(wrapper.html()).toContain('text-red-600')
})
it('falls back to the single balance + currency pair', () => {
const wrapper = mount(MonitorQuotaView, {
props: { snapshot: makeSnapshot({ balance: 3.2, currency: 'CNY' }) },
})
expect(wrapper.text()).toContain('3.20 CNY')
})
it('renders a truncated error state when the fetch failed', () => {
const longError = 'x'.repeat(60)
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({ success: false, error: longError }),
},
})
const error = wrapper.get('[data-testid="monitor-quota-error"]')
expect(error.text()).toBe(`${'x'.repeat(48)}…`)
expect(error.attributes('title')).toBe(longError)
})
it('keeps failed snapshots from rendering tier rows', () => {
const wrapper = mount(MonitorQuotaView, {
props: {
snapshot: makeSnapshot({
success: false,
tiers: [{ window: '5h', used_percent: 10 }],
}),
},
})
expect(wrapper.text()).not.toContain('10%')
})
})
@@ -61,6 +61,7 @@ afterEach(() => {
unmountWrapper = undefined
document.body.innerHTML = ''
setViewportWidth(originalInnerWidth)
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -113,3 +114,106 @@ describe('Select dropdown viewport constraints', () => {
expect(dropdown?.style.maxWidth).toBe('0px')
})
})
describe('Select remote search', () => {
const mountRemoteSelect = (props: Record<string, unknown> = {}) => {
const wrapper = mount(Select, {
props: {
modelValue: null,
remote: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
...props,
},
})
unmountWrapper = () => wrapper.unmount()
return wrapper
}
const openDropdown = async () => {
const dropdown = document.body.querySelector<HTMLElement>('.select-dropdown-portal')
expect(dropdown).not.toBeNull()
return dropdown as HTMLElement
}
const typeSearchQuery = async (query: string) => {
const dropdown = await openDropdown()
const input = dropdown.querySelector<HTMLInputElement>('.select-search-input')
expect(input).not.toBeNull()
input!.value = query
input!.dispatchEvent(new Event('input'))
await nextTick()
}
it('emits debounced search events and skips local filtering in remote mode', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('zzz')
// 防抖窗口内不触发。
expect(wrapper.emitted('search')).toBeUndefined()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toEqual([['zzz']])
// 远程模式不做本地过滤:无命中的 query 下选项仍完整展示(由父组件更新 options)。
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toContain('Alpha account')
expect(labels).toContain('Beta account')
})
it('does not emit search when the dropdown closes and the query resets', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('hidden')
// 关闭下拉:排队中的防抖定时器应被取消,也不应因 query 重置而尾随 emit。
await wrapper.get('button').trigger('click')
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
})
it('shows the loading text instead of empty text while loading with no options', async () => {
const wrapper = mountRemoteSelect({ options: [], loading: true })
await wrapper.get('button').trigger('click')
await nextTick()
const dropdown = await openDropdown()
expect(dropdown.querySelector('.select-empty')?.textContent).toContain('common.loading')
})
it('keeps local filtering and emits nothing when remote is not set', async () => {
vi.useFakeTimers()
const wrapper = mount(Select, {
props: {
modelValue: null,
searchable: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
},
})
unmountWrapper = () => wrapper.unmount()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('alpha')
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toEqual(['Alpha account'])
})
})
@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { UserMonitorView } from '@/api/channelMonitor'
import MonitorCard from '../monitor/MonitorCard.vue'
const { isQuotaVisible } = vi.hoisted(() => ({
isQuotaVisible: vi.fn(() => false),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorQuotaVisible: () => isQuotaVisible(),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key, te: () => true }),
}
})
function makeItem(overrides: Partial<UserMonitorView> = {}): UserMonitorView {
return {
id: 1,
name: 'claude-main',
provider: 'kimi',
group_name: '',
primary_model: 'quota',
primary_status: 'operational',
primary_latency_ms: null,
primary_ping_latency_ms: null,
availability_7d: 100,
extra_models: [],
timeline: [],
...overrides,
}
}
function mountCard(item: UserMonitorView) {
return mount(MonitorCard, {
props: {
item,
window: '7d',
availabilityValue: 100,
countdownSeconds: 0,
},
global: {
stubs: {
MonitorMetricPair: true,
MonitorAvailabilityRow: true,
MonitorTimeline: true,
},
},
})
}
describe('MonitorCard quota snapshot visibility', () => {
it('hides the quota block when the system switch is off even if data exists', () => {
isQuotaVisible.mockReturnValue(false)
const wrapper = mountCard(
makeItem({
latest_quota: { source: 'cn_quota', success: true, fetched_at: '2026-08-18T00:00:00Z' },
}),
)
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
})
it('renders the quota block when the switch is on and a snapshot exists', () => {
isQuotaVisible.mockReturnValue(true)
const wrapper = mountCard(
makeItem({
latest_quota: {
source: 'cn_quota',
success: true,
plan_level: 'kimi-plus',
tiers: [{ window: 'daily', label: 'requests', used_percent: 60 }],
fetched_at: '2026-08-18T00:00:00Z',
},
}),
)
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(true)
expect(wrapper.text()).toContain('kimi-plus')
})
it('never renders the quota block without a snapshot', () => {
isQuotaVisible.mockReturnValue(true)
const wrapper = mountCard(makeItem())
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
})
})
@@ -54,6 +54,9 @@
secondary-unit="ms"
/>
<!-- 配额模式:最新用量/余额快照(服务端已按系统开关剥离,此处 flag 为纵深防御) -->
<MonitorQuotaView v-if="quotaVisible" :snapshot="item.latest_quota" class="mt-2" />
<!-- Divider -->
<div class="mt-4 border-t border-gray-100 dark:border-dark-700/60"></div>
@@ -80,16 +83,23 @@ import {
useChannelMonitorFormat,
providerGradient,
} from '@/composables/useChannelMonitorFormat'
import { isChannelMonitorQuotaVisible } from '@/utils/featureFlags'
import ProviderIcon from './ProviderIcon.vue'
import MonitorMetricPair from './MonitorMetricPair.vue'
import MonitorAvailabilityRow from './MonitorAvailabilityRow.vue'
import MonitorTimeline from './MonitorTimeline.vue'
import MonitorQuotaView from '@/components/common/MonitorQuotaView.vue'
// 图标配色与 utils/platformColors.ts 的平台色对齐(新 4 家)。
const PROVIDER_TINT: Record<string, string> = {
openai: 'text-emerald-600 dark:text-emerald-300',
anthropic: 'text-orange-600 dark:text-orange-300',
gemini: 'text-sky-600 dark:text-sky-300',
grok: 'text-zinc-700 dark:text-zinc-200',
antigravity: 'text-purple-600 dark:text-purple-300',
kimi: 'text-pink-600 dark:text-pink-300',
zhipu: 'text-indigo-600 dark:text-indigo-300',
deepseek: 'text-teal-600 dark:text-teal-300',
}
const props = defineProps<{
@@ -116,6 +126,10 @@ const providerTintClass = computed(() =>
PROVIDER_TINT[props.item.provider] ?? 'text-gray-500 dark:text-gray-300'
)
const quotaVisible = computed(
() => isChannelMonitorQuotaVisible() && !!props.item.latest_quota
)
const availabilityLabel = computed(() => {
const win = t(`channelStatus.windowTab.${props.window}`)
return `${t('monitorCommon.availabilityPrefix')} · ${win}`
@@ -56,6 +56,28 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815',
],
},
// antigravity / kimi / zhipu / deepseek 的官方 logo mark 搬自
// src/components/common/PlatformIcon.vue,保持同步。
antigravity: {
paths: [
'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z',
],
},
kimi: {
paths: [
'M21.765.351C22.998.351 24 1.353 24 2.586S22.998 4.82 21.765 4.82h-1.974c-.15 0-.26-.12-.26-.26V2.586A2.237 2.237 0 0 1 21.765.35M9.41 13.388l8.447-8.377c.16-.16.07-.471-.14-.471h-4.55s-.1.02-.14.06l-9.099 9.029c-.14.14-.35.02-.35-.21V4.81c0-.15-.1-.27-.221-.27H.22c-.12 0-.22.12-.22.27v18.57c0 .15.1.27.22.27h3.137c.12 0 .22-.12.22-.27v-3.79c0-.08.03-.16.08-.21l2.826-2.796c.07-.07.16-.08.241-.03l7.546 5.551a8.9 8.9 0 0 0 4.018 1.493c.12.01.23-.11.23-.27V19.76c0-.14-.08-.25-.19-.26a5.8 5.8 0 0 1-2.355-.942l-6.533-4.73c-.14-.09-.15-.32-.03-.441',
],
},
zhipu: {
paths: [
'M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248z',
],
},
deepseek: {
paths: [
'M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45',
],
},
}
const props = withDefaults(defineProps<{
@@ -11,12 +11,17 @@
*/
import { useI18n } from 'vue-i18n'
import type { MonitorStatus, Provider } from '@/api/admin/channelMonitor'
import type { CheckMode, MonitorStatus, Provider } from '@/api/admin/channelMonitor'
import {
PROVIDER_OPENAI,
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
PROVIDERS,
STATUS_OPERATIONAL,
STATUS_DEGRADED,
STATUS_FAILED,
@@ -58,17 +63,19 @@ export function useChannelMonitorFormat() {
}
function providerLabel(p: Provider | string): string {
if (
p === PROVIDER_OPENAI ||
p === PROVIDER_ANTHROPIC ||
p === PROVIDER_GEMINI ||
p === PROVIDER_GROK
) {
if (PROVIDERS.includes(p as Provider)) {
return t(`monitorCommon.providers.${p}`)
}
return p || '-'
}
function checkModeLabel(m: CheckMode | string): string {
if (m === 'probe' || m === 'quota' || m === 'quota_probe') {
return t(`monitorCommon.checkMode.${m}`)
}
return m || '-'
}
function providerBadgeClass(p: Provider | string): string {
switch (p) {
case PROVIDER_OPENAI:
@@ -79,6 +86,16 @@ export function useChannelMonitorFormat() {
return 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300'
case PROVIDER_GROK:
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-500/15 dark:text-zinc-300'
// 配色与 utils/platformColors.ts 的平台色对齐:antigravity=purple /
// kimi=pink / zhipu=indigo / deepseek=teal。
case PROVIDER_ANTIGRAVITY:
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300'
case PROVIDER_KIMI:
return 'bg-pink-100 text-pink-700 dark:bg-pink-500/15 dark:text-pink-300'
case PROVIDER_ZHIPU:
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
case PROVIDER_DEEPSEEK:
return 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300'
default:
return NEUTRAL_BADGE
}
@@ -107,6 +124,22 @@ export function useChannelMonitorFormat() {
return active
? 'border-zinc-500 bg-zinc-50 text-zinc-800 dark:bg-zinc-500/15 dark:text-zinc-200 dark:border-zinc-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-zinc-400 hover:text-zinc-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-zinc-500/50'
case PROVIDER_ANTIGRAVITY:
return active
? 'border-purple-500 bg-purple-50 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300 dark:border-purple-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-purple-300 hover:text-purple-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-purple-500/50'
case PROVIDER_KIMI:
return active
? 'border-pink-500 bg-pink-50 text-pink-700 dark:bg-pink-500/15 dark:text-pink-300 dark:border-pink-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-pink-300 hover:text-pink-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-pink-500/50'
case PROVIDER_ZHIPU:
return active
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-indigo-300 hover:text-indigo-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-indigo-500/50'
case PROVIDER_DEEPSEEK:
return active
? 'border-teal-500 bg-teal-50 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300 dark:border-teal-400'
: 'border-gray-200 bg-white text-gray-600 hover:border-teal-300 hover:text-teal-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-teal-500/50'
default:
return active
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
@@ -147,6 +180,7 @@ export function useChannelMonitorFormat() {
statusLabel,
statusBadgeClass,
providerLabel,
checkModeLabel,
providerBadgeClass,
providerPickerClass,
formatLatency,
@@ -180,6 +214,14 @@ export function providerGradient(provider: string): string {
return 'bg-gradient-to-br from-sky-50 to-indigo-100 dark:from-sky-500/10 dark:to-indigo-500/20'
case PROVIDER_GROK:
return 'bg-gradient-to-br from-zinc-50 to-neutral-200 dark:from-zinc-500/10 dark:to-neutral-500/20'
case PROVIDER_ANTIGRAVITY:
return 'bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-500/10 dark:to-purple-500/20'
case PROVIDER_KIMI:
return 'bg-gradient-to-br from-pink-50 to-pink-100 dark:from-pink-500/10 dark:to-pink-500/20'
case PROVIDER_ZHIPU:
return 'bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-500/10 dark:to-indigo-500/20'
case PROVIDER_DEEPSEEK:
return 'bg-gradient-to-br from-teal-50 to-teal-100 dark:from-teal-500/10 dark:to-teal-500/20'
default:
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
}
+27 -1
View File
@@ -7,16 +7,29 @@
* `useChannelMonitorFormat`.
*/
import type { APIMode, Provider, MonitorStatus } from '@/api/admin/channelMonitor'
import type { APIMode, CheckMode, Provider, MonitorStatus } from '@/api/admin/channelMonitor'
export const PROVIDER_OPENAI: Provider = 'openai'
export const PROVIDER_ANTHROPIC: Provider = 'anthropic'
export const PROVIDER_GEMINI: Provider = 'gemini'
export const PROVIDER_GROK: Provider = 'grok'
export const PROVIDER_ANTIGRAVITY: Provider = 'antigravity'
export const PROVIDER_KIMI: Provider = 'kimi'
export const PROVIDER_ZHIPU: Provider = 'zhipu'
export const PROVIDER_DEEPSEEK: Provider = 'deepseek'
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
export const DEFAULT_GROK_MODEL = 'grok-4.5'
/** 国产 provider 的官方 endpoint(探活模式预填;配额模式可留空)。 */
export const DEFAULT_KIMI_ENDPOINT = 'https://api.moonshot.cn'
export const DEFAULT_ZHIPU_ENDPOINT = 'https://open.bigmodel.cn'
export const DEFAULT_DEEPSEEK_ENDPOINT = 'https://api.deepseek.com'
export const CHECK_MODE_PROBE: CheckMode = 'probe'
export const CHECK_MODE_QUOTA: CheckMode = 'quota'
export const CHECK_MODE_QUOTA_PROBE: CheckMode = 'quota_probe'
export const API_MODE_CHAT_COMPLETIONS: APIMode = 'chat_completions'
export const API_MODE_RESPONSES: APIMode = 'responses'
@@ -25,6 +38,19 @@ export const PROVIDERS: readonly Provider[] = [
PROVIDER_ANTHROPIC,
PROVIDER_GEMINI,
PROVIDER_GROK,
PROVIDER_ANTIGRAVITY,
PROVIDER_KIMI,
PROVIDER_ZHIPU,
PROVIDER_DEEPSEEK,
]
/** 仅支持配额模式(无探活 adapter)的 provider。 */
export const QUOTA_ONLY_PROVIDERS: readonly Provider[] = [PROVIDER_ANTIGRAVITY]
export const CHECK_MODES: readonly CheckMode[] = [
CHECK_MODE_PROBE,
CHECK_MODE_QUOTA,
CHECK_MODE_QUOTA_PROBE,
]
export const API_MODES: readonly APIMode[] = [
@@ -512,6 +512,7 @@ export default {
deleteConfirm: 'Are you sure you want to delete monitor "{name}"? This action cannot be undone.',
nameRequired: 'Please enter a monitor name',
primaryModelRequired: 'Please enter a primary model',
linkedAccountRequired: 'Please select a linked account',
columns: {
name: 'Name',
provider: 'Provider',
@@ -525,6 +526,19 @@ export default {
name: 'Name',
namePlaceholder: 'Enter monitor name',
provider: 'Platform',
checkMode: 'Check Mode',
checkModeProbe: 'Probe',
checkModeProbeHint: 'Sends a lightweight LLM request to measure availability and latency',
checkModeQuota: 'Quota',
checkModeQuotaHint: 'Only queries the linked account usage windows / balance without probe requests',
checkModeQuotaProbe: 'Probe + Quota',
checkModeQuotaProbeHint: 'Probes the channel and attaches the quota snapshot to the primary model result',
linkedAccount: 'Linked Account',
linkedAccountPlaceholder: 'Select an account',
linkedAccountHint: 'Quota data comes from the selected account (reuses the account-side usage/balance queries)',
linkedAccountEmpty: 'No accounts on this platform yet. Add one in Account Management first',
linkedAccountMissing: 'The linked account no longer exists or is not accessible. Please re-select an account',
openAIQuotaProbeHint: 'Note: on the OpenAI platform the usage query may trigger a Codex probe request that consumes the account\'s own quota (at most once every 10 minutes)',
apiMode: 'OpenAI protocol',
apiModeChatCompletions: 'OpenAI Compatible',
apiModeChatCompletionsHint: 'Use /v1/chat/completions with messages; works for most compatible providers.',
@@ -31,6 +31,9 @@ export default {
hideThroughput: 'Hide throughput rates from users (RPM / TPM)',
hideThroughputHint:
'When on, the user Channel Monitor page and user APIs omit RPM and TPM so fleet volume cannot be reverse-estimated from rates × window. Admins still see full metrics. Error rates, latency, and cache rates remain visible.',
showQuota: 'Show channel usage/balance to users',
showQuotaHint:
'When on, quota-mode channel monitors expose the linked account usage windows/balance on the user Channel Status page. Disabled by default; admins always see it.',
},
availableChannels: {
title: 'Available Channels',
+33 -1
View File
@@ -430,7 +430,39 @@ export default {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
grok: 'Grok'
grok: 'Grok',
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: 'Zhipu GLM',
deepseek: 'DeepSeek'
},
// Check modes (how a monitor performs its checks)
checkMode: {
probe: 'Probe',
quota: 'Quota',
quota_probe: 'Probe + Quota'
},
// Quota snapshot rendering (MonitorQuotaView, shared by admin + user views)
quota: {
unavailable: 'Quota unavailable',
resetSoon: 'resetting',
windows: {
'5h': '5h',
'7d': '7d',
'7dSonnet': '7d Sonnet',
'7dFable': '7d Fable',
weekly: 'Weekly',
daily: 'Daily',
'30d': '30d',
total: 'Total'
},
labels: {
requests: 'Requests',
tokens: 'Tokens',
shared: 'Shared',
pro: 'Pro',
flash: 'Flash'
}
},
extraModelsHeader: 'Extra Models',
extraModelsEmpty: 'No extra models',
@@ -512,6 +512,7 @@ export default {
deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。',
nameRequired: '请输入监控名称',
primaryModelRequired: '请输入主模型',
linkedAccountRequired: '请选择关联账号',
columns: {
name: '名称',
provider: '供应商',
@@ -525,6 +526,19 @@ export default {
name: '名称',
namePlaceholder: '输入监控名称',
provider: '平台',
checkMode: '检查方式',
checkModeProbe: '探活',
checkModeProbeHint: '向上游发送轻量 LLM 请求,检测可用性与延迟',
checkModeQuota: '配额',
checkModeQuotaHint: '只查询关联账号的用量滚动窗口/余额,不发送探活请求',
checkModeQuotaProbe: '探活 + 配额',
checkModeQuotaProbeHint: '探活的同时查询配额,用量快照附加在主模型结果上',
linkedAccount: '关联账号',
linkedAccountPlaceholder: '选择账号',
linkedAccountHint: '配额数据来自所选账号(复用账号管理侧的用量/余额查询)',
linkedAccountEmpty: '当前平台暂无账号,请先在账号管理中添加',
linkedAccountMissing: '关联账号已不存在或不可访问,请重新选择账号',
openAIQuotaProbeHint: '注意:OpenAI 平台的用量查询可能触发 Codex 探测请求,会消耗账号自身的额度(每 10 分钟最多触发一次)',
apiMode: 'OpenAI 协议',
apiModeChatCompletions: 'OpenAI Compatible',
apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。',
@@ -31,6 +31,9 @@ export default {
hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)',
hideThroughputHint:
'开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。',
showQuota: '向用户展示渠道用量/余额',
showQuotaHint:
'开启后,配额模式的渠道监控会在用户端渠道状态页展示关联账号的用量滚动窗口/余额。默认关闭;管理员始终可见。',
},
availableChannels: {
title: '可用渠道',
+33 -1
View File
@@ -435,7 +435,39 @@ export default {
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini',
grok: 'Grok'
grok: 'Grok',
antigravity: 'Antigravity',
kimi: 'Kimi',
zhipu: '智谱 GLM',
deepseek: 'DeepSeek'
},
// 检查模式(监控条目的工作方式)
checkMode: {
probe: '探活',
quota: '配额',
quota_probe: '探活 + 配额'
},
// 配额快照展示(MonitorQuotaView,管理端与用户端共用)
quota: {
unavailable: '配额信息不可用',
resetSoon: '即将重置',
windows: {
'5h': '5 小时',
'7d': '7 天',
'7dSonnet': '7 天 Sonnet',
'7dFable': '7 天 Fable',
weekly: '周',
daily: '日',
'30d': '30 天',
total: '总量'
},
labels: {
requests: '请求',
tokens: 'Token',
shared: '共享',
pro: 'Pro',
flash: 'Flash'
}
},
extraModelsHeader: '附加模型',
extraModelsEmpty: '无附加模型',
+2
View File
@@ -268,6 +268,8 @@ export interface PublicSettings {
channel_monitor_default_interval_seconds: number
/** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */
channel_monitor_hide_throughput?: boolean
/** When true, user monitor shows account quota/balance snapshots (default off). */
channel_monitor_show_quota?: boolean
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
+11
View File
@@ -186,3 +186,14 @@ export function isChannelMonitorThroughputHidden(): boolean {
const appStore = useAppStore()
return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_throughput)
}
/**
* Show quota/balance snapshots on the user-facing monitor page
* (channel_monitor_show_quota, default off). The backend strips
* latest_quota server-side when the switch is off; this flag is
* defense-in-depth only. Admin views always show quota.
*/
export function isChannelMonitorQuotaVisible(): boolean {
const appStore = useAppStore()
return appStore.cachedPublicSettings?.channel_monitor_show_quota === true
}
+18
View File
@@ -7089,6 +7089,18 @@
</div>
<Toggle v-model="form.channel_monitor_hide_throughput" />
</div>
<div v-if="form.channel_monitor_mode === 'v1'" class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.showQuota') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.showQuotaHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_show_quota" />
</div>
</div>
</div>
</div>
@@ -9420,6 +9432,7 @@ type SettingsForm = Omit<
> & {
/** Form always binds a concrete boolean (SystemSettings marks this optional). */
channel_monitor_hide_throughput: boolean;
channel_monitor_show_quota: boolean;
smtp_password: string;
turnstile_secret_key: string;
tencent_captcha_app_secret_key: string;
@@ -9732,6 +9745,7 @@ const form = reactive<SettingsForm>({
channel_monitor_mode: 'v1' as 'v1' | 'v2',
channel_monitor_default_interval_seconds: 60,
channel_monitor_hide_throughput: false,
channel_monitor_show_quota: false,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
@@ -10733,6 +10747,9 @@ async function loadSettings() {
form.channel_monitor_hide_throughput = Boolean(
settings.channel_monitor_hide_throughput
);
form.channel_monitor_show_quota = Boolean(
settings.channel_monitor_show_quota
);
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
@@ -11384,6 +11401,7 @@ async function saveSettings() {
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
channel_monitor_hide_throughput: Boolean(form.channel_monitor_hide_throughput),
channel_monitor_show_quota: Boolean(form.channel_monitor_show_quota),
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
@@ -96,6 +96,8 @@ function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
@@ -10,8 +10,10 @@ import {
PROVIDER_GROK,
} from '@/constants/channelMonitor'
const { listTemplates } = vi.hoisted(() => ({
const { listTemplates, accountsList, accountsGetById } = vi.hoisted(() => ({
listTemplates: vi.fn(),
accountsList: vi.fn(),
accountsGetById: vi.fn(),
}))
@@ -34,6 +36,10 @@ vi.mock('@/api/admin', () => ({
channelMonitorTemplate: {
list: listTemplates,
},
accounts: {
list: (...args: unknown[]) => accountsList(...args),
getById: (...args: unknown[]) => accountsGetById(...args),
},
},
}))
@@ -85,6 +91,8 @@ function mountDialog() {
describe('channel monitor Grok provider', () => {
beforeEach(() => {
listTemplates.mockReset().mockResolvedValue({ items: [] })
accountsList.mockReset().mockResolvedValue({ items: [] })
accountsGetById.mockReset()
})
it('offers Grok in the responsive provider grid and prefills its official defaults', async () => {
@@ -93,7 +101,7 @@ describe('channel monitor Grok provider', () => {
expect(PROVIDERS).toContain(PROVIDER_GROK)
const providerButtons = wrapper.findAll('[data-testid^="monitor-provider-"]')
expect(providerButtons).toHaveLength(4)
expect(providerButtons).toHaveLength(8)
expect(providerButtons[0].element.parentElement?.className).toContain('grid-cols-2')
expect(providerButtons[0].element.parentElement?.className).toContain('sm:grid-cols-4')
@@ -0,0 +1,311 @@
import { defineComponent, nextTick } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
const {
listTemplates,
accountsList,
accountsGetById,
monitorCreate,
monitorUpdate,
} = vi.hoisted(() => ({
listTemplates: vi.fn(),
accountsList: vi.fn(),
accountsGetById: vi.fn(),
monitorCreate: vi.fn(),
monitorUpdate: vi.fn(),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => true,
isChannelMonitorV2Mode: () => false,
getChannelMonitorMode: () => 'v1' as const,
}))
vi.mock('@/features/channel-monitor-v2/MonitorSettingsPanel.vue', () => ({
default: { name: 'MonitorSettingsPanel', template: '<div data-testid="v2-settings" />' },
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channelMonitor: {
create: monitorCreate,
update: monitorUpdate,
},
channelMonitorTemplate: {
list: listTemplates,
},
accounts: {
list: (...args: unknown[]) => accountsList(...args),
getById: (...args: unknown[]) => accountsGetById(...args),
},
},
}))
vi.mock('@/api/keys', () => ({
keysAPI: { list: vi.fn() },
}))
vi.mock('@/api/groups', () => ({
userGroupsAPI: { getUserGroupRates: vi.fn() },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
cachedPublicSettings: null,
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const BaseDialogStub = defineComponent({
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
})
function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 42,
name: 'primary',
provider: 'openai',
api_mode: 'chat_completions',
endpoint: 'https://api.example.com',
api_key_masked: 'sk-t***',
primary_model: 'gpt-4o-mini',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
primary_status: '',
primary_latency_ms: null,
availability_7d: 0,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
let unmountWrapper: (() => void) | undefined
function mountDialog(monitor: ChannelMonitor | null = null) {
const wrapper = mount(MonitorFormDialog, {
props: { show: true, monitor },
global: {
stubs: {
BaseDialog: BaseDialogStub,
Toggle: true,
ModelTagInput: true,
MonitorKeyPickerDialog: true,
MonitorAdvancedRequestConfig: true,
},
},
})
unmountWrapper = () => wrapper.unmount()
return wrapper
}
const accountTrigger = (wrapper: VueWrapper) =>
wrapper.get('[data-testid="monitor-linked-account"] button')
const openAccountDropdown = async (wrapper: VueWrapper) => {
await accountTrigger(wrapper).trigger('click')
await nextTick()
const dropdown = document.body.querySelector<HTMLElement>('.select-dropdown-portal')
expect(dropdown).not.toBeNull()
return dropdown as HTMLElement
}
const clickOption = (dropdown: HTMLElement, text: string) => {
const option = [...dropdown.querySelectorAll('.select-option')].find((el) =>
el.textContent?.includes(text),
)
expect(option, `option containing "${text}" not found`).toBeDefined()
;(option as HTMLElement).click()
}
const typeAccountSearch = async (dropdown: HTMLElement, query: string) => {
const input = dropdown.querySelector<HTMLInputElement>('.select-search-input')
expect(input).not.toBeNull()
input!.value = query
input!.dispatchEvent(new Event('input'))
await nextTick()
}
afterEach(() => {
unmountWrapper?.()
unmountWrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('MonitorFormDialog linked account selector', () => {
beforeEach(() => {
listTemplates.mockReset().mockResolvedValue({ items: [] })
accountsList.mockReset().mockResolvedValue({ items: [] })
accountsGetById.mockReset()
monitorCreate.mockReset().mockResolvedValue({})
monitorUpdate.mockReset().mockResolvedValue({})
})
it('loads the first page of provider accounts when quota mode is enabled', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'a', platform: 'anthropic' }] })
const wrapper = mountDialog()
await flushPromises()
expect(accountsList).not.toHaveBeenCalled()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
expect(accountsList).toHaveBeenCalledTimes(1)
expect(accountsList).toHaveBeenCalledWith(
1,
50,
{ platform: 'anthropic' },
{ signal: expect.any(AbortSignal) },
)
})
it('debounced typing hits server-side search and aborts the previous request', async () => {
vi.useFakeTimers()
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await vi.advanceTimersByTimeAsync(0)
const dropdown = await openAccountDropdown(wrapper)
await typeAccountSearch(dropdown, 'hidden')
expect(accountsList).toHaveBeenCalledTimes(1, 'debounce window must not fire a request')
await vi.advanceTimersByTimeAsync(300)
expect(accountsList).toHaveBeenCalledTimes(2)
const lastCall = accountsList.mock.calls[accountsList.mock.calls.length - 1]
expect(lastCall[2]).toMatchObject({ platform: 'anthropic', search: 'hidden' })
const firstSignal = (accountsList.mock.calls[0][3] as { signal: AbortSignal }).signal
expect(firstSignal.aborted).toBe(true)
})
it('hydrates a bound account missing from the first page and keeps the binding', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'first page account', platform: 'anthropic' }] })
accountsGetById.mockResolvedValue({ id: 999, name: 'hidden gem', platform: 'anthropic' })
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
expect(accountsGetById).toHaveBeenCalledWith(999)
expect(accountTrigger(wrapper).text()).toContain('hidden gem (#999)')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({ account_id: 999 }))
})
it('clears the binding with a visible hint when the bound account cannot be loaded', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
expect(wrapper.text()).toContain('admin.channelMonitor.form.linkedAccountMissing')
expect(accountTrigger(wrapper).text()).not.toContain('hidden gem')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).not.toHaveBeenCalled()
})
it('shows the OpenAI codex probe hint only in quota mode on openai', async () => {
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).not.toContain('openAIQuotaProbeHint')
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
// anthropic 平台不显示。
expect(wrapper.text()).not.toContain('openAIQuotaProbeHint')
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('openAIQuotaProbeHint')
})
it('keeps a picked account visible when a later search hides it', async () => {
vi.useFakeTimers()
accountsList
.mockResolvedValueOnce({ items: [
{ id: 1, name: 'alpha', platform: 'anthropic' },
{ id: 2, name: 'beta', platform: 'anthropic' },
] })
.mockResolvedValueOnce({ items: [{ id: 2, name: 'beta', platform: 'anthropic' }] })
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await vi.advanceTimersByTimeAsync(0)
const dropdown = await openAccountDropdown(wrapper)
clickOption(dropdown, 'alpha (#1)')
await nextTick()
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
// 搜索 beta:结果页只剩 beta,但已选中的 alpha 仍固定展示、提交仍保留绑定。
const reopened = await openAccountDropdown(wrapper)
await typeAccountSearch(reopened, 'beta')
await vi.advanceTimersByTimeAsync(300)
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
await wrapper.findAll('input[type="text"]')[0].setValue('my monitor')
await wrapper.get('#channel-monitor-form').trigger('submit')
await vi.advanceTimersByTimeAsync(0)
expect(monitorCreate).toHaveBeenCalledWith(expect.objectContaining({ account_id: 1 }))
})
it('clears the account binding when the provider switches', async () => {
accountsList.mockResolvedValue({ items: [{ id: 1, name: 'alpha', platform: 'anthropic' }] })
const wrapper = mountDialog()
await wrapper.get('[data-testid="monitor-check-mode-quota"]').trigger('click')
await flushPromises()
const dropdown = await openAccountDropdown(wrapper)
clickOption(dropdown, 'alpha (#1)')
await nextTick()
expect(accountTrigger(wrapper).text()).toContain('alpha (#1)')
await wrapper.get('[data-testid="monitor-provider-openai"]').trigger('click')
await flushPromises()
expect(accountTrigger(wrapper).text()).not.toContain('alpha')
expect(accountTrigger(wrapper).text()).toContain('linkedAccountPlaceholder')
})
})