From c41ae19e529e0297df1d3212627410d731cb27ea Mon Sep 17 00:00:00 2001 From: Randark Date: Tue, 18 Aug 2026 09:54:33 +0000 Subject: [PATCH] fix(monitor): reject unusable quota data sources and invalid mode combos at write time P2-2/P2-4/P2-5 from review: - validateLinkedAccount/revalidateLinkedAccount now check monitorAccountQuotaCapability after the platform match, blocking combos that would permanently error at runtime: deepseek coding and custom-domain kimi coding (no quota endpoint), zhipu payg (no balance endpoint), anthropic/openai API-Key accounts (usage query requires oauth; anthropic setup-token still allowed via local estimation). gemini/grok/antigravity stay permissive. Quota mode surfaces CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE on edit; probe mode silently unbinds (same as platform mismatch). - applyMonitorUpdate re-runs validateCheckMode on the effective provider+check_mode whenever either field is patched, so a provider-only update can no longer persist antigravity+probe (the check is conditioned on provider/check_mode presence so legacy illegal rows can still be renamed/disabled). - normalizeMonitorPrimaryModel only substitutes the "quota" placeholder for pure quota mode; quota_probe with an empty model now fails with MissingPrimaryModel instead of probing model="quota". The quota branch also moves ahead of the grok default, so grok+quota now gets "quota" rather than "grok-4.5" (review L-item). Tests: capability matrix (15 cases incl. anti-over-blocking rows), linked-account wiring, revalidate quota/probe split, provider-only update bypass + legacy-row rename, quota_probe missing-model create, normalize matrix. --- .../internal/service/channel_monitor_const.go | 3 + .../channel_monitor_quota_mode_test.go | 163 +++++++++++++++++- .../service/channel_monitor_service.go | 25 ++- .../service/channel_monitor_validate.go | 54 +++++- 4 files changed, 232 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index bf7676ed50..6a2b9b92e8 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -162,6 +162,9 @@ var ( ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest( "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", ) + ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)", + ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", ) diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go index 0e4b3ea7da..7309d2cf2a 100644 --- a/backend/internal/service/channel_monitor_quota_mode_test.go +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -325,6 +325,16 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { }, wantErr: ErrChannelMonitorInvalidCheckMode, }, + { + // quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。 + name: "quota_probe requires primary model", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://api.kimi.com", APIKey: "sk", + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: ErrChannelMonitorMissingPrimaryModel, + }, } for _, tc := range cases { @@ -342,8 +352,15 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) { require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, "")) require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " ")) - // 探活模式沿用原语义:grok 默认模型,其余必填(空串报错在 validateCreateParams)。 + // quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel), + // 不再用 "quota" 占位打 model="quota" 的请求。 + require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "")) + // grok 分支在纯 quota 占位之后:grok+quota 占位 "quota", + // grok 探活(probe/quota_probe)默认轻量测活模型。 + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, "")) require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, "")) + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, "")) + // 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。 require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2")) } @@ -414,6 +431,150 @@ func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) { require.Nil(t, probe.AccountID) } +// 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。 +func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantErr error + }{ + { + name: "deepseek coding has no quota endpoint", + account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + // 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。 + name: "custom-domain kimi coding unsupported", + account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi coding default endpoint ok", + account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu coding default endpoint ok", + account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu payg has no balance endpoint", + account: &Account{ID: 5, Platform: domain.PlatformZhipu}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi payg ok", + account: &Account{ID: 6, Platform: domain.PlatformKimi}, + }, + { + name: "deepseek payg ok", + account: &Account{ID: 7, Platform: domain.PlatformDeepseek}, + }, + { + name: "anthropic api key cannot query usage", + account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "anthropic oauth ok", + account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth}, + }, + { + name: "anthropic setup token ok (local estimation)", + account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken}, + }, + { + name: "openai api key cannot query usage", + account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "openai oauth ok", + account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth}, + }, + { + // 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。 + name: "gemini api key ok", + account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey}, + }, + { + name: "grok ok", + account: &Account{ID: 14, Platform: domain.PlatformGrok}, + }, + { + name: "antigravity ok", + account: &Account{ID: 15, Platform: domain.PlatformAntigravity}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := monitorAccountQuotaCapability(tc.account) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1)) + require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable) +} + +func TestRevalidateLinkedAccount_Capability(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable) + require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix") + + probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account") +} + +// provider-only 更新不得绕过 provider × check_mode 组合校验。 +func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) { + probeKimi := func() *ChannelMonitor { + return &ChannelMonitor{ + Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2", + CheckMode: MonitorCheckModeProbe, + } + } + + provider := MonitorProviderAntigravity + err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider}) + require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode) + + // 带上 check_mode/account_id 的完整切换合法。 + accountID := int64(3) + err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{ + Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID, + }) + require.NoError(t, err) + + // 存量非法行(antigravity+probe)仅改名/停用不被砖化。 + legacy := &ChannelMonitor{ + Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro", + CheckMode: MonitorCheckModeProbe, + } + newName := "renamed" + require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName})) +} + // --- quota → probe 切换的 key 管控(validateProbeAPIKey) --- func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) { diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 9b0bb07639..b08a623ce5 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -406,7 +406,8 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { return nil } -// validateLinkedAccount 校验关联账号存在且平台与监控 provider 一致。 +// validateLinkedAccount 校验关联账号存在、平台与监控 provider 一致、且能充当 +// 配额数据源(能力拦截,见 monitorAccountQuotaCapability)。 // fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。 func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error { if accountID == nil || *accountID <= 0 { @@ -422,7 +423,7 @@ func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provi if account.Platform != provider { return ErrChannelMonitorProviderIncompatible } - return nil + return monitorAccountQuotaCapability(account) } // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。 @@ -533,6 +534,15 @@ func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m * m.AccountID = nil return nil } + // 能力失配(如 deepseek coding / zhipu payg / API-Key 型海外账号): + // quota 模式显式报错(有该类存量监控时编辑会被拦,出路是换账号或切 probe), + // probe 模式账号无用途,静默解绑。 + if err := monitorAccountQuotaCapability(account); err != nil { + if usesQuota { + return err + } + m.AccountID = nil + } return nil } @@ -886,11 +896,16 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) existing.Provider = *p.Provider } if p.CheckMode != nil { - mode := defaultCheckMode(*p.CheckMode) - if err := validateCheckMode(existing.Provider, mode); err != nil { + existing.CheckMode = defaultCheckMode(*p.CheckMode) + } + // provider 与 check_mode 任一变化后统一复核组合矩阵:provider-only 更新 + // (如把 probe 监控的 provider 改成 antigravity)也不得落库非法组合,否则 + // 运行期恒 error。条件限定避免把存量非法行的 name/enabled-only 更新也判死 + // (否则连改名/停用都无法操作)。 + if p.Provider != nil || p.CheckMode != nil { + if err := validateCheckMode(existing.Provider, defaultCheckMode(existing.CheckMode)); err != nil { return err } - existing.CheckMode = mode } if p.AccountID != nil { if *p.AccountID > 0 { diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index faf7247fbc..6b3b9fd81f 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -190,21 +190,61 @@ func normalizeModels(in []string) []string { return out } -// normalizeMonitorPrimaryModel applies provider/check_mode defaults while -// preserving the existing required-model behavior: -// - Grok 探活默认轻量测活模型 -// - quota 模式占位 "quota"(primary_model 列 NotEmpty;历史行/时间线机制无需特判) +// normalizeMonitorPrimaryModel applies provider/check_mode defaults: +// - pure quota mode never sends requests: placeholder "quota" keeps +// primary_model NOT NULL (history rows / timeline need no special-casing) +// - quota_probe still sends a real probe request: empty model returns "" +// so validateCreateParams / applyMonitorUpdate report +// ErrChannelMonitorMissingPrimaryModel instead of probing model="quota" +// - Grok probing (probe/quota_probe) defaults to the lightweight check model func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) + if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota { + return MonitorDefaultQuotaModel + } if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } - if model == "" && monitorCheckModeUsesQuota(defaultCheckMode(checkMode)) { - return MonitorDefaultQuotaModel - } return model } +// monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 +// fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / +// 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: +// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu +// (deepseek coding、自定义域名 kimi coding 无法路由额度端点) +// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) +// - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) +// - openai:OAuth(API-Key 型无 usage 通道) +// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 +func monitorAccountQuotaCapability(account *Account) error { + switch account.Platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + if account.IsCodingPlan() { + if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + } + if account.Platform == PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + case PlatformAnthropic: + if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken { + return nil + } + return ErrChannelMonitorAccountNotSupportable + case PlatformOpenAI: + if account.Type == AccountTypeOAuth { + return nil + } + return ErrChannelMonitorAccountNotSupportable + default: + return nil + } +} + // defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。 func defaultAPIMode(apiMode string) string { if strings.TrimSpace(apiMode) == "" {