//go:build unit package service import ( "context" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/stretchr/testify/require" ) // --- repo / fetcher 装配 --- // quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。 type quotaModeRepoStub struct { ChannelMonitorRepository monitor *ChannelMonitor history []*ChannelMonitorHistoryRow markedIDs []int64 updated []*ChannelMonitor } func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) { if r.monitor == nil || r.monitor.ID != id { return nil, ErrChannelMonitorNotFound } clone := *r.monitor return &clone, nil } func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error { r.history = append(r.history, rows...) return nil } func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error { r.markedIDs = append(r.markedIDs, id) return nil } func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error { clone := *m r.updated = append(r.updated, &clone) return nil } // newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。 func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService { svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ Enabled: true, Mode: ChannelMonitorModeV1, }}) return svc } func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher { if accounts == nil { accounts = make(map[int64]*Account) } if usage == nil { usage = &stubMonitorUsageSource{} } return &ChannelMonitorQuotaFetcher{ usage: usage, accounts: &stubMonitorAccountSource{accounts: accounts}, cache: make(map[int64]monitorQuotaCacheEntry), } } // --- RunCheck 分派 --- func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) { repo := "aModeRepoStub{monitor: &ChannelMonitor{ ID: 1, Name: "kimi-quota", Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions, PrimaryModel: "quota", Enabled: true, IntervalSeconds: 60, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9), }} svc := newQuotaModeService(repo) fetcher := newQuotaModeFetcher(map[int64]*Account{ 9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, }, nil) fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{ Success: true, CredentialValid: true, Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}}, }} svc.SetQuotaFetcher(fetcher) results, err := svc.RunCheck(context.Background(), 1) require.NoError(t, err) require.Len(t, results, 1) res := results[0] require.Equal(t, "quota", res.Model) require.Equal(t, MonitorStatusOperational, res.Status) require.Nil(t, res.LatencyMs) require.Nil(t, res.PingLatencyMs) require.NotNil(t, res.Quota) require.True(t, res.Quota.Success) require.Equal(t, "cn_quota", res.Quota.Source) // 历史行携带配额快照,并推进 last_checked_at。 require.Len(t, repo.history, 1) require.Equal(t, "quota", repo.history[0].Model) require.NotNil(t, repo.history[0].Quota) require.Equal(t, []int64{1}, repo.markedIDs) } func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) { repo := "aModeRepoStub{monitor: &ChannelMonitor{ ID: 2, Provider: MonitorProviderDeepseek, APIMode: MonitorAPIModeChatCompletions, Endpoint: "", PrimaryModel: "quota", Enabled: true, IntervalSeconds: 60, CheckMode: MonitorCheckModeQuota, AccountID: nil, // FK ON DELETE SET NULL 后的形态 }} svc := newQuotaModeService(repo) svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) results, err := svc.RunCheck(context.Background(), 2) require.NoError(t, err) require.Len(t, results, 1) require.Equal(t, MonitorStatusDegraded, results[0].Status) require.Contains(t, results[0].Message, "linked account not found") require.False(t, results[0].Quota.Success) } func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) { repo := "aModeRepoStub{monitor: &ChannelMonitor{ ID: 3, Provider: MonitorProviderZhipu, APIMode: MonitorAPIModeChatCompletions, PrimaryModel: "quota", Enabled: true, IntervalSeconds: 60, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(5), }} svc := newQuotaModeService(repo) // 不注入 fetcher results, err := svc.RunCheck(context.Background(), 3) require.NoError(t, err) require.Len(t, results, 1) require.Equal(t, MonitorStatusError, results[0].Status) require.Contains(t, results[0].Message, "not configured") } func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) { h := &openAICaptureHandler{} endpoint := setupFakeOpenAI(t, h) repo := "aModeRepoStub{monitor: &ChannelMonitor{ ID: 4, Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeChatCompletions, Endpoint: endpoint, APIKey: "OLD:sk-openai", PrimaryModel: "gpt-test", ExtraModels: []string{"gpt-extra"}, Enabled: true, IntervalSeconds: 60, CheckMode: MonitorCheckModeQuotaProbe, AccountID: int64Ptr(12), }} svc := newQuotaModeService(repo) usage := &stubMonitorUsageSource{usage: &UsageInfo{ FiveHour: &UsageProgress{Utilization: 20}, }} svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ 12: {ID: 12, Platform: domain.PlatformOpenAI}, }, usage)) results, err := svc.RunCheck(context.Background(), 4) require.NoError(t, err) require.Len(t, results, 2) // 探活状态为准,配额只挂主模型行。 require.Equal(t, MonitorStatusOperational, results[0].Status) require.NotNil(t, results[0].Quota) require.True(t, results[0].Quota.Success) require.Equal(t, "usage", results[0].Quota.Source) require.Nil(t, results[1].Quota, "extra model rows must not carry quota") // 历史落库时同样只有主模型行带快照。 require.Len(t, repo.history, 2) require.NotNil(t, repo.history[0].Quota) require.Equal(t, "gpt-test", repo.history[0].Model) require.Nil(t, repo.history[1].Quota) } func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) { h := &openAICaptureHandler{} endpoint := setupFakeOpenAI(t, h) repo := "aModeRepoStub{monitor: &ChannelMonitor{ ID: 5, Provider: MonitorProviderOpenAI, APIMode: MonitorAPIModeChatCompletions, Endpoint: endpoint, APIKey: "OLD:sk-openai", PrimaryModel: "gpt-test", Enabled: true, IntervalSeconds: 60, CheckMode: MonitorCheckModeQuotaProbe, AccountID: nil, // 配额侧失效 }} svc := newQuotaModeService(repo) svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil)) results, err := svc.RunCheck(context.Background(), 5) require.NoError(t, err) require.Len(t, results, 1) require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status") require.False(t, results[0].Quota.Success) } // --- attachQuotaSnapshot 细节 --- func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) { results := []*CheckResult{ {Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"}, {Model: "extra"}, } failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"} attachQuotaSnapshot(results, failed) require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins") require.Equal(t, failed, results[0].Quota) require.Nil(t, results[1].Quota) quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}} attachQuotaSnapshot(quiet, failed) require.Contains(t, quiet[0].Message, "quota fetch failed: boom") attachQuotaSnapshot(nil, failed) // 空结果不 panic attachQuotaSnapshot(results, nil) // 空快照不动结果 } // --- 校验矩阵 --- func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { accountID := int64(9) cases := []struct { name string params ChannelMonitorCreateParams wantErr error }{ { name: "probe requires endpoint", params: ChannelMonitorCreateParams{ Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5", }, wantErr: ErrChannelMonitorInvalidEndpoint, }, { name: "probe requires api key", params: ChannelMonitorCreateParams{ Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe, Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5", }, wantErr: ErrChannelMonitorMissingAPIKey, }, { name: "quota drops endpoint and api key requirements", params: ChannelMonitorCreateParams{ Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota, IntervalSeconds: 60, AccountID: &accountID, }, wantErr: nil, // primary_model 默认 "quota" }, { name: "quota requires account", params: ChannelMonitorCreateParams{ Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, IntervalSeconds: 60, PrimaryModel: "quota", }, wantErr: ErrChannelMonitorAccountRequired, }, { name: "quota_probe requires endpoint and api key too", params: ChannelMonitorCreateParams{ Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2", }, wantErr: ErrChannelMonitorInvalidEndpoint, }, { name: "antigravity probe unsupported", params: ChannelMonitorCreateParams{ Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe, Endpoint: "https://example.com", APIKey: "k", IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", }, wantErr: ErrChannelMonitorInvalidCheckMode, }, { name: "antigravity quota_probe unsupported", params: ChannelMonitorCreateParams{ Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe, Endpoint: "https://example.com", APIKey: "k", IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro", }, wantErr: ErrChannelMonitorInvalidCheckMode, }, { name: "unknown mode rejected", params: ChannelMonitorCreateParams{ Provider: MonitorProviderOpenAI, CheckMode: "auto", Endpoint: "https://api.openai.com", APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5", }, wantErr: ErrChannelMonitorInvalidCheckMode, }, { // 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 { 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, " ")) // 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")) } 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) } // 能力矩阵:与 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) { 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") }