cn_provider_balance_check_service.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "strings"
  7. "sync"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. )
  11. // cnQuotaProber 抽象额度探测(*CNProviderQuotaService 实现,测试可替换)。
  12. type cnQuotaProber interface {
  13. QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error)
  14. }
  15. // cnQuotaProbeConcurrency 周期任务并发探测额度账号的并发度。
  16. const cnQuotaProbeConcurrency = 4
  17. // CNProviderBalanceCheckService 周期性探测国产供应商账号:
  18. // - payg(按量付费):余额低于阈值则临时停调,恢复则清除(仅清除本服务写入的停调);
  19. // - coding plan:调用 CNProviderQuotaService 探测 5h/weekly 滚动窗口并落 extra 快照,
  20. // 调度阈值评估(cnProviderThresholdCandidates)据此自动停调/恢复。
  21. //
  22. // 克隆自 AccountExpiryService 的 Start/Stop/runOnce + ticker 骨架。
  23. // 余额探测仅覆盖有公开余额端点的 kimi / deepseek;智谱无余额端点,仅靠响应式 429/402。
  24. // 额度探测覆盖 kimi / zhipu 的 coding plan 账号(deepseek 无 coding 套餐)。
  25. type CNProviderBalanceCheckService struct {
  26. accountRepo AccountRepository
  27. balanceService *CNProviderBalanceService
  28. quotaService cnQuotaProber
  29. cfg *config.Config
  30. interval time.Duration
  31. stopCh chan struct{}
  32. stopOnce sync.Once
  33. wg sync.WaitGroup
  34. }
  35. // NewCNProviderBalanceCheckService 构造周期余额/额度检测服务。
  36. // interval <= 0 时 Start() 直接返回(不启动),便于通过配置关闭。
  37. func NewCNProviderBalanceCheckService(
  38. accountRepo AccountRepository,
  39. balanceService *CNProviderBalanceService,
  40. quotaService *CNProviderQuotaService,
  41. cfg *config.Config,
  42. interval time.Duration,
  43. ) *CNProviderBalanceCheckService {
  44. return &CNProviderBalanceCheckService{
  45. accountRepo: accountRepo,
  46. balanceService: balanceService,
  47. quotaService: quotaService,
  48. cfg: cfg,
  49. interval: interval,
  50. stopCh: make(chan struct{}),
  51. }
  52. }
  53. func (s *CNProviderBalanceCheckService) Start() {
  54. if s == nil || s.accountRepo == nil || s.balanceService == nil || s.cfg == nil {
  55. return
  56. }
  57. if !s.cfg.Gateway.CNProviders.BalanceCheckEnabled {
  58. return
  59. }
  60. if s.interval <= 0 {
  61. return
  62. }
  63. log.Printf("[CNBalance] started (interval=%s threshold=%.2f)", s.interval, s.cfg.Gateway.CNProviders.BalanceThreshold)
  64. s.wg.Add(1)
  65. go func() {
  66. defer s.wg.Done()
  67. ticker := time.NewTicker(s.interval)
  68. defer ticker.Stop()
  69. // 启动后先等待一个周期再首次探测,避免与进程启动峰重叠。
  70. for {
  71. select {
  72. case <-ticker.C:
  73. s.runOnce()
  74. case <-s.stopCh:
  75. return
  76. }
  77. }
  78. }()
  79. }
  80. func (s *CNProviderBalanceCheckService) Stop() {
  81. if s == nil {
  82. return
  83. }
  84. s.stopOnce.Do(func() {
  85. close(s.stopCh)
  86. })
  87. s.wg.Wait()
  88. }
  89. func (s *CNProviderBalanceCheckService) runOnce() {
  90. // 收集 coding 探测目标(kimi/deepseek + 智谱)与 payg 检查队列。
  91. // coding 探测统一在收集完成后按 4 并发执行:单账号探测 15-20s,串行 ×
  92. // 多账号会耗尽整体预算(120s 上限),排在后面的账号快照会饥饿,
  93. // 连锁影响阈值停调的新鲜度判定。
  94. type quotaTarget struct {
  95. id int64
  96. platform string
  97. }
  98. var quotaTargets []quotaTarget
  99. var paygTargets []*Account
  100. collect := func(platform string, accounts []Account) {
  101. for i := range accounts {
  102. account := &accounts[i]
  103. if !account.IsActive() {
  104. continue
  105. }
  106. // 挂在国产平台下、base_url 指向官方 ollama.com 的账号由 Ollama Cloud
  107. // 用量窗口负责:CN 探测端点由 base_url 衍生,ollama.com 会被出站
  108. // URL 白名单拒绝(CN_BALANCE_URL_REJECTED),不跳过则每个周期都
  109. // 白跑并产生告警噪声。
  110. if IsOllamaCloudUsageAccount(account) {
  111. continue
  112. }
  113. // coding 账号:探测滚动窗口并落快照(不要求 Schedulable——已被
  114. // 阈值停调的账号也需要新鲜快照决定是否续停)。
  115. if account.IsCodingPlan() {
  116. quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform})
  117. continue
  118. }
  119. // payg 余额探测仅 kimi/deepseek(智谱 / MiniMax 无公开余额端点,
  120. // payg 账号依赖响应式 402/429 处理)。
  121. if platform != PlatformZhipu && platform != PlatformMiniMax && account.Schedulable {
  122. paygTargets = append(paygTargets, account)
  123. }
  124. }
  125. }
  126. for _, platform := range s.platforms() {
  127. accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform)
  128. if err != nil {
  129. log.Printf("[CNBalance] list %s accounts failed: %v", platform, err)
  130. continue
  131. }
  132. collect(platform, accounts)
  133. }
  134. // 智谱 / MiniMax 无余额端点,仅进额度探测。
  135. if s.quotaService != nil {
  136. for _, platform := range []string{PlatformZhipu, PlatformMiniMax} {
  137. accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform)
  138. if err != nil {
  139. log.Printf("[CNBalance] list %s accounts failed: %v", platform, err)
  140. continue
  141. }
  142. collect(platform, accounts)
  143. }
  144. }
  145. // 预算按工作量放大:4 并发 × 15s/批 + payg 每账号 5s,下限 30s 上限 300s。
  146. batches := (len(quotaTargets) + cnQuotaProbeConcurrency - 1) / cnQuotaProbeConcurrency
  147. timeout := 30*time.Second + time.Duration(batches)*15*time.Second + time.Duration(len(paygTargets))*5*time.Second
  148. if timeout > 300*time.Second {
  149. timeout = 300 * time.Second
  150. }
  151. ctx, cancel := context.WithTimeout(context.Background(), timeout)
  152. defer cancel()
  153. threshold := s.cfg.Gateway.CNProviders.BalanceThreshold
  154. paused, cleared := 0, 0
  155. for _, account := range paygTargets {
  156. switch s.checkOne(ctx, account, threshold) {
  157. case cnBalancePaused:
  158. paused++
  159. case cnBalanceCleared:
  160. cleared++
  161. }
  162. }
  163. if len(quotaTargets) > 0 && s.quotaService != nil {
  164. sem := make(chan struct{}, cnQuotaProbeConcurrency)
  165. var wg sync.WaitGroup
  166. for _, target := range quotaTargets {
  167. wg.Add(1)
  168. go func(t quotaTarget) {
  169. defer wg.Done()
  170. sem <- struct{}{}
  171. defer func() { <-sem }()
  172. s.probeQuota(ctx, t.id, t.platform)
  173. }(target)
  174. }
  175. wg.Wait()
  176. }
  177. if paused > 0 || cleared > 0 {
  178. log.Printf("[CNBalance] paused=%d cleared=%d (threshold=%.2f)", paused, cleared, threshold)
  179. }
  180. }
  181. // probeQuota 探测单个 coding plan 账号的滚动窗口用量并落 extra 快照。
  182. // 不在此处做停调/恢复决策:调度阈值评估读取快照统一判定(含暂停账号的续停)。
  183. func (s *CNProviderBalanceCheckService) probeQuota(ctx context.Context, accountID int64, platform string) {
  184. if s.quotaService == nil {
  185. return
  186. }
  187. result, err := s.quotaService.QueryUsage(ctx, accountID)
  188. if err != nil {
  189. log.Printf("[CNBalance] quota probe account %d (%s) failed: %v", accountID, platform, err)
  190. return
  191. }
  192. if result != nil && !result.Success && result.Error != "" {
  193. log.Printf("[CNBalance] quota probe account %d (%s) error: %s", accountID, platform, result.Error)
  194. }
  195. }
  196. type cnBalanceCheckOutcome int
  197. const (
  198. cnBalanceNoChange cnBalanceCheckOutcome = iota
  199. cnBalancePaused
  200. cnBalanceCleared
  201. )
  202. // checkOne 探测单账号余额并决定停调/恢复。探测失败时不动现状(避免瞬时网络抖动
  203. // 误解除或误停调)。
  204. func (s *CNProviderBalanceCheckService) checkOne(ctx context.Context, account *Account, threshold float64) cnBalanceCheckOutcome {
  205. result, err := s.balanceService.QueryBalance(ctx, account.ID)
  206. if err != nil || result == nil || !result.Success {
  207. return cnBalanceNoChange
  208. }
  209. // 双币种(deepseek CNY+USD)任一币种余额达标即可继续调度;仅当全部低于
  210. // 阈值(或不可用)才停调。
  211. low := !result.Available || allCNBalancesBelowThreshold(result, threshold)
  212. if low {
  213. // 已被(任何来源)停调时不覆盖其 reason。
  214. if !account.IsSchedulable() {
  215. return cnBalanceNoChange
  216. }
  217. reason := cnBalanceLowReason(fmt.Sprintf("余额 %.4g %s 低于阈值 %.2f", result.Balance, result.Currency, threshold))
  218. if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, time.Now().Add(s.cooldown()), reason); err != nil {
  219. log.Printf("[CNBalance] pause account %d failed: %v", account.ID, err)
  220. return cnBalanceNoChange
  221. }
  222. log.Printf("[CNBalance] paused account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency)
  223. return cnBalancePaused
  224. }
  225. // 余额健康:仅清除「本服务写入」的临时停调(reason 前缀匹配),不触碰其他子系统。
  226. if account.TempUnschedulableUntil != nil && strings.HasPrefix(account.TempUnschedulableReason, cnBalanceLowReasonPrefix) {
  227. if err := s.accountRepo.ClearTempUnschedulable(ctx, account.ID); err != nil {
  228. log.Printf("[CNBalance] clear account %d failed: %v", account.ID, err)
  229. return cnBalanceNoChange
  230. }
  231. log.Printf("[CNBalance] reactivated account %d (%s): balance=%.4g %s", account.ID, account.Platform, result.Balance, result.Currency)
  232. return cnBalanceCleared
  233. }
  234. return cnBalanceNoChange
  235. }
  236. func (s *CNProviderBalanceCheckService) platforms() []string {
  237. return []string{PlatformKimi, PlatformDeepseek}
  238. }
  239. // allCNBalancesBelowThreshold 判断全部币种余额是否均低于阈值。
  240. // 无明细时退回主币种判定(与旧行为一致)。
  241. func allCNBalancesBelowThreshold(result *CNProviderBalanceResult, threshold float64) bool {
  242. if len(result.Balances) == 0 {
  243. return result.Balance < threshold
  244. }
  245. for _, entry := range result.Balances {
  246. if entry.Balance >= threshold {
  247. return false
  248. }
  249. }
  250. return true
  251. }
  252. // cooldown 返回临时停调持续时长(= 2× 检测周期),与响应式 402/429 路径一致。
  253. func (s *CNProviderBalanceCheckService) cooldown() time.Duration {
  254. minutes := 10
  255. if s.cfg != nil {
  256. if cfgMin := s.cfg.Gateway.CNProviders.BalanceCheckIntervalMinutes; cfgMin > 0 {
  257. minutes = cfgMin
  258. }
  259. }
  260. cooldown := time.Duration(minutes) * time.Minute * 2
  261. if cooldown < time.Minute {
  262. cooldown = 10 * time.Minute
  263. }
  264. return cooldown
  265. }