cn_provider_balance_check_service_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "sync"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // 周期任务对 coding plan 账号的额度探测行为(runOnce 集成路径):
  11. // - kimi coding 账号(含已被阈值停调的)→ 额度探测被调用;
  12. // - 智谱 coding 账号 → 额度探测被调用(智谱不进 kimi/deepseek 余额循环);
  13. // - payg 账号不经过额度探测(走余额路径,本测试不放 payg 账号避免真实网络);
  14. // - 非激活账号完全跳过。
  15. // fakeCNQuotaProber 需要并发安全:runOnce 以 cnQuotaProbeConcurrency 并发调用 QueryUsage。
  16. type fakeCNQuotaProber struct {
  17. mu sync.Mutex
  18. probed []int64
  19. }
  20. func (f *fakeCNQuotaProber) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) {
  21. f.mu.Lock()
  22. defer f.mu.Unlock()
  23. f.probed = append(f.probed, accountID)
  24. return &CNProviderQuotaProbeResult{Success: true, Persisted: true}, nil
  25. }
  26. type fakeCNCheckRepo struct {
  27. AccountRepository
  28. byPlatform map[string][]Account
  29. }
  30. func (r *fakeCNCheckRepo) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
  31. return r.byPlatform[platform], nil
  32. }
  33. func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) {
  34. kimiActive := Account{ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive,
  35. Credentials: map[string]any{"account_mode": "coding"}}
  36. // 已被阈值停调的 coding 账号也要刷新快照(决定是否续停)。
  37. kimiPaused := Account{ID: 2, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: false,
  38. Credentials: map[string]any{"account_mode": "coding"}}
  39. // 非激活账号跳过。
  40. kimiInactive := Account{ID: 3, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusDisabled,
  41. Credentials: map[string]any{"account_mode": "coding"}}
  42. zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive,
  43. Credentials: map[string]any{"account_mode": "coding"}}
  44. minimaxCoding := Account{ID: 5, Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Status: StatusActive,
  45. Credentials: map[string]any{"account_mode": "coding"}}
  46. repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{
  47. PlatformKimi: {kimiActive, kimiPaused, kimiInactive},
  48. PlatformZhipu: {zhipuCoding},
  49. PlatformMiniMax: {minimaxCoding},
  50. }}
  51. prober := &fakeCNQuotaProber{}
  52. svc := &CNProviderBalanceCheckService{
  53. accountRepo: repo,
  54. quotaService: prober,
  55. cfg: &config.Config{},
  56. }
  57. svc.runOnce()
  58. require.ElementsMatch(t, []int64{1, 2, 4, 5}, prober.probed)
  59. }
  60. // runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。
  61. func TestCNProviderBalanceCheckRunOnceWithoutQuotaService(t *testing.T) {
  62. repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{
  63. PlatformZhipu: {{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive,
  64. Credentials: map[string]any{"account_mode": "coding"}}},
  65. }}
  66. svc := &CNProviderBalanceCheckService{accountRepo: repo, cfg: &config.Config{}}
  67. require.NotPanics(t, func() { svc.runOnce() })
  68. }
  69. // recordingCNBalanceLoadRepo 记录余额探测服务的 GetByID 调用:payg 账号进入
  70. // payg 检查队列必然触发 loadPayGAccount → GetByID,以此断言"未入队"这一内部
  71. // 状态。GetByID 直接报错,保证不发生真实外呼。
  72. type recordingCNBalanceLoadRepo struct {
  73. AccountRepository
  74. getByIDIDs []int64
  75. }
  76. func (r *recordingCNBalanceLoadRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
  77. r.getByIDIDs = append(r.getByIDIDs, id)
  78. return nil, errors.New("not found")
  79. }
  80. // 挂在国产平台下、base_url 指向官方 ollama.com 的账号由 Ollama Cloud 用量窗口
  81. // 负责:CN 探测端点由 base_url 衍生,ollama.com 会被出站 URL 白名单拒绝
  82. // (CN_BALANCE_URL_REJECTED),周期任务必须整体跳过——不进额度目标,也不进
  83. // payg 检查队列。
  84. func TestCNProviderBalanceCheckRunOnceSkipsOllamaCloudUsageAccounts(t *testing.T) {
  85. // 对照组:普通 kimi coding 账号仍进额度探测。
  86. kimiCoding := Account{ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive,
  87. Credentials: map[string]any{"account_mode": "coding"}}
  88. // ollama.com 挂 kimi:coding 模式不进额度目标。
  89. ollamaKimiCoding := Account{ID: 2, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive,
  90. Credentials: map[string]any{"account_mode": "coding", "base_url": "https://ollama.com"}}
  91. // ollama.com 挂 kimi:payg 模式不进 payg 检查队列。
  92. ollamaKimiPayg := Account{ID: 3, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true,
  93. Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "sk-ollama"}}
  94. loadRepo := &recordingCNBalanceLoadRepo{}
  95. repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{
  96. PlatformKimi: {kimiCoding, ollamaKimiCoding, ollamaKimiPayg},
  97. }}
  98. prober := &fakeCNQuotaProber{}
  99. svc := &CNProviderBalanceCheckService{
  100. accountRepo: repo,
  101. balanceService: NewCNProviderBalanceService(loadRepo, nil, nil, &config.Config{}),
  102. quotaService: prober,
  103. cfg: &config.Config{},
  104. }
  105. svc.runOnce()
  106. require.Equal(t, []int64{1}, prober.probed)
  107. require.Empty(t, loadRepo.getByIDIDs, "ollama 账号不得进入 payg 检查队列")
  108. }
  109. // 双币种(deepseek CNY+USD)停调判定:任一币种达标即不停调,全部低于阈值才停;
  110. // 无明细时退回主币种(兼容旧结果)。
  111. func TestAllCNBalancesBelowThreshold(t *testing.T) {
  112. dualLow := &CNProviderBalanceResult{
  113. Balance: 1.0,
  114. Currency: "CNY",
  115. Balances: []CNProviderBalanceEntry{
  116. {Currency: "CNY", Balance: 1.0},
  117. {Currency: "USD", Balance: 0.5},
  118. },
  119. }
  120. require.True(t, allCNBalancesBelowThreshold(dualLow, 5.0))
  121. dualMixed := &CNProviderBalanceResult{
  122. Balance: 1.0,
  123. Currency: "CNY",
  124. Balances: []CNProviderBalanceEntry{
  125. {Currency: "CNY", Balance: 1.0},
  126. {Currency: "USD", Balance: 20.0},
  127. },
  128. }
  129. require.False(t, allCNBalancesBelowThreshold(dualMixed, 5.0))
  130. // 无明细:按主币种判定(旧行为)。
  131. singleLow := &CNProviderBalanceResult{Balance: 1.0, Currency: "CNY"}
  132. require.True(t, allCNBalancesBelowThreshold(singleLow, 5.0))
  133. singleOK := &CNProviderBalanceResult{Balance: 10.0, Currency: "CNY"}
  134. require.False(t, allCNBalancesBelowThreshold(singleOK, 5.0))
  135. }