channel_monitor_quota_mode_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/domain"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // --- repo / fetcher 装配 ---
  11. // quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。
  12. type quotaModeRepoStub struct {
  13. ChannelMonitorRepository
  14. monitor *ChannelMonitor
  15. history []*ChannelMonitorHistoryRow
  16. markedIDs []int64
  17. updated []*ChannelMonitor
  18. }
  19. func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) {
  20. if r.monitor == nil || r.monitor.ID != id {
  21. return nil, ErrChannelMonitorNotFound
  22. }
  23. clone := *r.monitor
  24. return &clone, nil
  25. }
  26. func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error {
  27. r.history = append(r.history, rows...)
  28. return nil
  29. }
  30. func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error {
  31. r.markedIDs = append(r.markedIDs, id)
  32. return nil
  33. }
  34. func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error {
  35. clone := *m
  36. r.updated = append(r.updated, &clone)
  37. return nil
  38. }
  39. // newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。
  40. func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService {
  41. svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
  42. svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
  43. Enabled: true,
  44. Mode: ChannelMonitorModeV1,
  45. }})
  46. return svc
  47. }
  48. func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher {
  49. if accounts == nil {
  50. accounts = make(map[int64]*Account)
  51. }
  52. if usage == nil {
  53. usage = &stubMonitorUsageSource{}
  54. }
  55. return &ChannelMonitorQuotaFetcher{
  56. usage: usage,
  57. accounts: &stubMonitorAccountSource{accounts: accounts},
  58. cache: make(map[int64]monitorQuotaCacheEntry),
  59. }
  60. }
  61. // --- RunCheck 分派 ---
  62. func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) {
  63. repo := &quotaModeRepoStub{monitor: &ChannelMonitor{
  64. ID: 1,
  65. Name: "kimi-quota",
  66. Provider: MonitorProviderKimi,
  67. APIMode: MonitorAPIModeChatCompletions,
  68. PrimaryModel: "quota",
  69. Enabled: true,
  70. IntervalSeconds: 60,
  71. CheckMode: MonitorCheckModeQuota,
  72. AccountID: int64Ptr(9),
  73. }}
  74. svc := newQuotaModeService(repo)
  75. fetcher := newQuotaModeFetcher(map[int64]*Account{
  76. 9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  77. }, nil)
  78. fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{
  79. Success: true,
  80. CredentialValid: true,
  81. Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}},
  82. }}
  83. svc.SetQuotaFetcher(fetcher)
  84. results, err := svc.RunCheck(context.Background(), 1)
  85. require.NoError(t, err)
  86. require.Len(t, results, 1)
  87. res := results[0]
  88. require.Equal(t, "quota", res.Model)
  89. require.Equal(t, MonitorStatusOperational, res.Status)
  90. require.Nil(t, res.LatencyMs)
  91. require.Nil(t, res.PingLatencyMs)
  92. require.NotNil(t, res.Quota)
  93. require.True(t, res.Quota.Success)
  94. require.Equal(t, "cn_quota", res.Quota.Source)
  95. // 历史行携带配额快照,并推进 last_checked_at。
  96. require.Len(t, repo.history, 1)
  97. require.Equal(t, "quota", repo.history[0].Model)
  98. require.NotNil(t, repo.history[0].Quota)
  99. require.Equal(t, []int64{1}, repo.markedIDs)
  100. }
  101. func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) {
  102. repo := &quotaModeRepoStub{monitor: &ChannelMonitor{
  103. ID: 2,
  104. Provider: MonitorProviderDeepseek,
  105. APIMode: MonitorAPIModeChatCompletions,
  106. Endpoint: "",
  107. PrimaryModel: "quota",
  108. Enabled: true,
  109. IntervalSeconds: 60,
  110. CheckMode: MonitorCheckModeQuota,
  111. AccountID: nil, // FK ON DELETE SET NULL 后的形态
  112. }}
  113. svc := newQuotaModeService(repo)
  114. svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil))
  115. results, err := svc.RunCheck(context.Background(), 2)
  116. require.NoError(t, err)
  117. require.Len(t, results, 1)
  118. require.Equal(t, MonitorStatusDegraded, results[0].Status)
  119. require.Contains(t, results[0].Message, "linked account not found")
  120. require.False(t, results[0].Quota.Success)
  121. }
  122. func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) {
  123. repo := &quotaModeRepoStub{monitor: &ChannelMonitor{
  124. ID: 3,
  125. Provider: MonitorProviderZhipu,
  126. APIMode: MonitorAPIModeChatCompletions,
  127. PrimaryModel: "quota",
  128. Enabled: true,
  129. IntervalSeconds: 60,
  130. CheckMode: MonitorCheckModeQuota,
  131. AccountID: int64Ptr(5),
  132. }}
  133. svc := newQuotaModeService(repo) // 不注入 fetcher
  134. results, err := svc.RunCheck(context.Background(), 3)
  135. require.NoError(t, err)
  136. require.Len(t, results, 1)
  137. require.Equal(t, MonitorStatusError, results[0].Status)
  138. require.Contains(t, results[0].Message, "not configured")
  139. }
  140. func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) {
  141. h := &openAICaptureHandler{}
  142. endpoint := setupFakeOpenAI(t, h)
  143. repo := &quotaModeRepoStub{monitor: &ChannelMonitor{
  144. ID: 4,
  145. Provider: MonitorProviderOpenAI,
  146. APIMode: MonitorAPIModeChatCompletions,
  147. Endpoint: endpoint,
  148. APIKey: "OLD:sk-openai",
  149. PrimaryModel: "gpt-test",
  150. ExtraModels: []string{"gpt-extra"},
  151. Enabled: true,
  152. IntervalSeconds: 60,
  153. CheckMode: MonitorCheckModeQuotaProbe,
  154. AccountID: int64Ptr(12),
  155. }}
  156. svc := newQuotaModeService(repo)
  157. usage := &stubMonitorUsageSource{usage: &UsageInfo{
  158. FiveHour: &UsageProgress{Utilization: 20},
  159. }}
  160. svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
  161. 12: {ID: 12, Platform: domain.PlatformOpenAI},
  162. }, usage))
  163. results, err := svc.RunCheck(context.Background(), 4)
  164. require.NoError(t, err)
  165. require.Len(t, results, 2)
  166. // 探活状态为准,配额只挂主模型行。
  167. require.Equal(t, MonitorStatusOperational, results[0].Status)
  168. require.NotNil(t, results[0].Quota)
  169. require.True(t, results[0].Quota.Success)
  170. require.Equal(t, "usage", results[0].Quota.Source)
  171. require.Nil(t, results[1].Quota, "extra model rows must not carry quota")
  172. // 历史落库时同样只有主模型行带快照。
  173. require.Len(t, repo.history, 2)
  174. require.NotNil(t, repo.history[0].Quota)
  175. require.Equal(t, "gpt-test", repo.history[0].Model)
  176. require.Nil(t, repo.history[1].Quota)
  177. }
  178. func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) {
  179. h := &openAICaptureHandler{}
  180. endpoint := setupFakeOpenAI(t, h)
  181. repo := &quotaModeRepoStub{monitor: &ChannelMonitor{
  182. ID: 5,
  183. Provider: MonitorProviderOpenAI,
  184. APIMode: MonitorAPIModeChatCompletions,
  185. Endpoint: endpoint,
  186. APIKey: "OLD:sk-openai",
  187. PrimaryModel: "gpt-test",
  188. Enabled: true,
  189. IntervalSeconds: 60,
  190. CheckMode: MonitorCheckModeQuotaProbe,
  191. AccountID: nil, // 配额侧失效
  192. }}
  193. svc := newQuotaModeService(repo)
  194. svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil))
  195. results, err := svc.RunCheck(context.Background(), 5)
  196. require.NoError(t, err)
  197. require.Len(t, results, 1)
  198. require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status")
  199. require.False(t, results[0].Quota.Success)
  200. }
  201. // --- attachQuotaSnapshot 细节 ---
  202. func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) {
  203. results := []*CheckResult{
  204. {Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"},
  205. {Model: "extra"},
  206. }
  207. failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"}
  208. attachQuotaSnapshot(results, failed)
  209. require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins")
  210. require.Equal(t, failed, results[0].Quota)
  211. require.Nil(t, results[1].Quota)
  212. quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}}
  213. attachQuotaSnapshot(quiet, failed)
  214. require.Contains(t, quiet[0].Message, "quota fetch failed: boom")
  215. attachQuotaSnapshot(nil, failed) // 空结果不 panic
  216. attachQuotaSnapshot(results, nil) // 空快照不动结果
  217. }
  218. // --- 校验矩阵 ---
  219. func TestValidateCreateParams_CheckModeMatrix(t *testing.T) {
  220. accountID := int64(9)
  221. cases := []struct {
  222. name string
  223. params ChannelMonitorCreateParams
  224. wantErr error
  225. }{
  226. {
  227. name: "probe requires endpoint",
  228. params: ChannelMonitorCreateParams{
  229. Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe,
  230. APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5",
  231. },
  232. wantErr: ErrChannelMonitorInvalidEndpoint,
  233. },
  234. {
  235. name: "probe requires api key",
  236. params: ChannelMonitorCreateParams{
  237. Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe,
  238. Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5",
  239. },
  240. wantErr: ErrChannelMonitorMissingAPIKey,
  241. },
  242. {
  243. name: "quota drops endpoint and api key requirements",
  244. params: ChannelMonitorCreateParams{
  245. Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota,
  246. IntervalSeconds: 60, AccountID: &accountID,
  247. },
  248. wantErr: nil, // primary_model 默认 "quota"
  249. },
  250. {
  251. name: "quota requires account",
  252. params: ChannelMonitorCreateParams{
  253. Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota,
  254. IntervalSeconds: 60, PrimaryModel: "quota",
  255. },
  256. wantErr: ErrChannelMonitorAccountRequired,
  257. },
  258. {
  259. name: "quota_probe requires endpoint and api key too",
  260. params: ChannelMonitorCreateParams{
  261. Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe,
  262. IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2",
  263. },
  264. wantErr: ErrChannelMonitorInvalidEndpoint,
  265. },
  266. {
  267. name: "antigravity probe unsupported",
  268. params: ChannelMonitorCreateParams{
  269. Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe,
  270. Endpoint: "https://example.com", APIKey: "k",
  271. IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro",
  272. },
  273. wantErr: ErrChannelMonitorInvalidCheckMode,
  274. },
  275. {
  276. name: "antigravity quota_probe unsupported",
  277. params: ChannelMonitorCreateParams{
  278. Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe,
  279. Endpoint: "https://example.com", APIKey: "k",
  280. IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro",
  281. },
  282. wantErr: ErrChannelMonitorInvalidCheckMode,
  283. },
  284. {
  285. name: "unknown mode rejected",
  286. params: ChannelMonitorCreateParams{
  287. Provider: MonitorProviderOpenAI, CheckMode: "auto",
  288. Endpoint: "https://api.openai.com", APIKey: "sk",
  289. IntervalSeconds: 60, PrimaryModel: "gpt-5",
  290. },
  291. wantErr: ErrChannelMonitorInvalidCheckMode,
  292. },
  293. {
  294. // quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。
  295. name: "quota_probe requires primary model",
  296. params: ChannelMonitorCreateParams{
  297. Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe,
  298. Endpoint: "https://api.kimi.com", APIKey: "sk",
  299. IntervalSeconds: 60, AccountID: &accountID,
  300. },
  301. wantErr: ErrChannelMonitorMissingPrimaryModel,
  302. },
  303. }
  304. for _, tc := range cases {
  305. t.Run(tc.name, func(t *testing.T) {
  306. err := validateCreateParams(tc.params)
  307. if tc.wantErr == nil {
  308. require.NoError(t, err)
  309. } else {
  310. require.ErrorIs(t, err, tc.wantErr)
  311. }
  312. })
  313. }
  314. }
  315. func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) {
  316. require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, ""))
  317. require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " "))
  318. // quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel),
  319. // 不再用 "quota" 占位打 model="quota" 的请求。
  320. require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, ""))
  321. // grok 分支在纯 quota 占位之后:grok+quota 占位 "quota",
  322. // grok 探活(probe/quota_probe)默认轻量测活模型。
  323. require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, ""))
  324. require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""))
  325. require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, ""))
  326. // 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。
  327. require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2"))
  328. }
  329. func TestProviderProbeCapabilityMatrix(t *testing.T) {
  330. require.False(t, providerSupportsProbe(MonitorProviderAntigravity))
  331. for _, p := range []string{
  332. MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini,
  333. MonitorProviderGrok, MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek,
  334. } {
  335. require.True(t, providerSupportsProbe(p), p)
  336. }
  337. for _, p := range []string{
  338. MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini,
  339. MonitorProviderGrok, MonitorProviderAntigravity,
  340. MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek,
  341. } {
  342. require.NoError(t, validateProvider(p), p)
  343. }
  344. }
  345. // --- 关联账号校验 ---
  346. func TestValidateLinkedAccount_Matrix(t *testing.T) {
  347. svc := NewChannelMonitorService(nil, nil)
  348. fetcher := newQuotaModeFetcher(map[int64]*Account{
  349. 1: {ID: 1, Platform: domain.PlatformKimi},
  350. }, nil)
  351. svc.SetQuotaFetcher(fetcher)
  352. require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, nil))
  353. require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(0)))
  354. require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)))
  355. require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderZhipu, int64Ptr(1)), ErrChannelMonitorProviderIncompatible)
  356. require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(404)), ErrChannelMonitorAccountRequired)
  357. noFetcher := NewChannelMonitorService(nil, nil)
  358. require.ErrorIs(t, noFetcher.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)), ErrChannelMonitorAccountRequired)
  359. }
  360. func TestRevalidateLinkedAccount_QuotaErrorsProbeUnbinds(t *testing.T) {
  361. fetcher := newQuotaModeFetcher(nil, nil) // 账号一律加载失败
  362. svc := NewChannelMonitorService(nil, nil)
  363. svc.SetQuotaFetcher(fetcher)
  364. quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9)}
  365. require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountRequired)
  366. require.NotNil(t, quota.AccountID)
  367. probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(9)}
  368. require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
  369. require.Nil(t, probe.AccountID, "probe mode should silently unbind stale account")
  370. quotaNoAccount := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota}
  371. require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quotaNoAccount), ErrChannelMonitorAccountRequired)
  372. }
  373. func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) {
  374. svc := NewChannelMonitorService(nil, nil)
  375. svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
  376. 2: {ID: 2, Platform: domain.PlatformDeepseek},
  377. }, nil))
  378. quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)}
  379. require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorProviderIncompatible)
  380. probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)}
  381. require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
  382. require.Nil(t, probe.AccountID)
  383. }
  384. // 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。
  385. func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) {
  386. cases := []struct {
  387. name string
  388. account *Account
  389. wantErr error
  390. }{
  391. {
  392. name: "deepseek coding has no quota endpoint",
  393. account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  394. wantErr: ErrChannelMonitorAccountNotSupportable,
  395. },
  396. {
  397. // 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。
  398. name: "custom-domain kimi coding unsupported",
  399. account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey,
  400. Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}},
  401. wantErr: ErrChannelMonitorAccountNotSupportable,
  402. },
  403. {
  404. name: "kimi coding default endpoint ok",
  405. account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  406. },
  407. {
  408. name: "zhipu coding default endpoint ok",
  409. account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  410. },
  411. {
  412. name: "minimax coding default endpoint ok",
  413. account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  414. },
  415. {
  416. name: "custom-domain minimax coding unsupported",
  417. account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey,
  418. Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}},
  419. wantErr: ErrChannelMonitorAccountNotSupportable,
  420. },
  421. {
  422. name: "zhipu payg has no balance endpoint",
  423. account: &Account{ID: 5, Platform: domain.PlatformZhipu},
  424. wantErr: ErrChannelMonitorAccountNotSupportable,
  425. },
  426. {
  427. name: "minimax payg has no balance endpoint",
  428. account: &Account{ID: 15, Platform: domain.PlatformMiniMax},
  429. wantErr: ErrChannelMonitorAccountNotSupportable,
  430. },
  431. {
  432. name: "kimi payg ok",
  433. account: &Account{ID: 6, Platform: domain.PlatformKimi},
  434. },
  435. {
  436. name: "deepseek payg ok",
  437. account: &Account{ID: 7, Platform: domain.PlatformDeepseek},
  438. },
  439. {
  440. name: "anthropic api key cannot query usage",
  441. account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey},
  442. wantErr: ErrChannelMonitorAccountNotSupportable,
  443. },
  444. {
  445. name: "anthropic oauth ok",
  446. account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth},
  447. },
  448. {
  449. name: "anthropic setup token ok (local estimation)",
  450. account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken},
  451. },
  452. {
  453. name: "openai api key cannot query usage",
  454. account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey},
  455. wantErr: ErrChannelMonitorAccountNotSupportable,
  456. },
  457. {
  458. name: "openai oauth ok",
  459. account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth},
  460. },
  461. {
  462. // 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。
  463. name: "gemini api key ok",
  464. account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey},
  465. },
  466. {
  467. name: "grok ok",
  468. account: &Account{ID: 14, Platform: domain.PlatformGrok},
  469. },
  470. {
  471. name: "antigravity ok",
  472. account: &Account{ID: 15, Platform: domain.PlatformAntigravity},
  473. },
  474. }
  475. for _, tc := range cases {
  476. t.Run(tc.name, func(t *testing.T) {
  477. err := monitorAccountQuotaCapability(tc.account)
  478. if tc.wantErr == nil {
  479. require.NoError(t, err)
  480. } else {
  481. require.ErrorIs(t, err, tc.wantErr)
  482. }
  483. })
  484. }
  485. }
  486. func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) {
  487. svc := NewChannelMonitorService(nil, nil)
  488. svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
  489. 1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  490. }, nil))
  491. err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1))
  492. require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable)
  493. }
  494. func TestRevalidateLinkedAccount_Capability(t *testing.T) {
  495. svc := NewChannelMonitorService(nil, nil)
  496. svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
  497. 2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
  498. }, nil))
  499. quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)}
  500. require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable)
  501. require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix")
  502. probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)}
  503. require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
  504. require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account")
  505. }
  506. // provider-only 更新不得绕过 provider × check_mode 组合校验。
  507. func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) {
  508. probeKimi := func() *ChannelMonitor {
  509. return &ChannelMonitor{
  510. Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions,
  511. Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2",
  512. CheckMode: MonitorCheckModeProbe,
  513. }
  514. }
  515. provider := MonitorProviderAntigravity
  516. err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider})
  517. require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode)
  518. // 带上 check_mode/account_id 的完整切换合法。
  519. accountID := int64(3)
  520. err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{
  521. Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID,
  522. })
  523. require.NoError(t, err)
  524. // 存量非法行(antigravity+probe)仅改名/停用不被砖化。
  525. legacy := &ChannelMonitor{
  526. Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions,
  527. Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro",
  528. CheckMode: MonitorCheckModeProbe,
  529. }
  530. newName := "renamed"
  531. require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName}))
  532. }
  533. // --- quota → probe 切换的 key 管控(validateProbeAPIKey) ---
  534. func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) {
  535. svc := NewChannelMonitorService(nil, &duplicateChannelMonitorEncryptor{})
  536. quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, APIKey: "NEW:"}
  537. require.NoError(t, svc.validateProbeAPIKey(quota, "")) // quota 模式不管 key
  538. quota.CheckMode = MonitorCheckModeProbe
  539. // 存量密文解出空明文(quota 监控存的加密空串)→ 必须重填 key。
  540. require.ErrorIs(t, svc.validateProbeAPIKey(quota, ""), ErrChannelMonitorMissingAPIKey)
  541. // 提供新明文 key → 放行。
  542. require.NoError(t, svc.validateProbeAPIKey(quota, "sk-fresh"))
  543. // 密文解出非空明文 → 放行。
  544. require.NoError(t, svc.validateProbeAPIKey(
  545. &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, APIKey: "OLD:sk-live"}, ""))
  546. }
  547. // --- Duplicate:quota 模式空明文重加密 ---
  548. func TestDuplicateChannelMonitorQuotaModeReencryptsEmptyKey(t *testing.T) {
  549. accountID := int64(9)
  550. source := &ChannelMonitor{
  551. ID: 42,
  552. Name: "kimi-quota",
  553. Provider: MonitorProviderKimi,
  554. APIMode: MonitorAPIModeChatCompletions,
  555. Endpoint: "",
  556. APIKey: "OLD:", // 解密为空串(quota 监控的加密空 key)
  557. PrimaryModel: "quota",
  558. Enabled: true,
  559. IntervalSeconds: 60,
  560. CheckMode: MonitorCheckModeQuota,
  561. AccountID: &accountID,
  562. }
  563. repo := &duplicateChannelMonitorRepoStub{source: source}
  564. service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
  565. dup, err := service.Duplicate(context.Background(), 42, 7, "admin:7", "op-1")
  566. require.NoError(t, err)
  567. require.Equal(t, MonitorCheckModeQuota, dup.CheckMode)
  568. require.NotNil(t, dup.AccountID)
  569. require.Equal(t, accountID, *dup.AccountID)
  570. require.Empty(t, dup.APIKey, "plaintext stays empty for quota monitors")
  571. require.Len(t, repo.created, 1)
  572. require.Equal(t, "NEW:", repo.created[0].APIKey, "empty key must be re-encrypted, not dropped")
  573. }