channel_monitor_quota_fetcher.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "sort"
  7. "strconv"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/domain"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  14. "golang.org/x/sync/singleflight"
  15. )
  16. // 渠道监控「配额模式」的配额抓取器。
  17. //
  18. // 不直接对接上游,而是把账号侧现成的用量服务归一成 domain.MonitorQuotaSnapshot:
  19. // - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsageForAccount
  20. // - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsageForAccount
  21. // - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalanceForAccount
  22. // (zhipu payg 无公开余额端点,探测会返回该错误,原样透出)
  23. // 数据源统一接受已加载的 *Account:fetchUncached 路由前 GetByID 一次并传下去,
  24. // 下游服务不再各自重载(每次 GetByID 含 proxies/groups 联查)。
  25. //
  26. // Fetch 永不返回 error:所有失败都降级为 Success=false 的快照照常入库,
  27. // 由 deriveQuotaCheckResult 推导为 failed/error 状态。
  28. //
  29. // 多个监控可能关联同一账号,而 interval 最小 15s 且国产配额服务自身无缓存,
  30. // 所以快照统一带 TTL 缓存(成功 monitorQuotaFetchCacheTTL、失败
  31. // monitorQuotaErrorCacheTTL 负缓存),防止打爆上游配额端点;同账号的并发
  32. // 抓取由 singleflight 合并为一次上游查询。
  33. // monitorUsageSource 海外平台账号用量查询(AccountUsageService 天然满足)。
  34. // 传已加载的 *Account:fetchUncached 只 GetByID 一次,下游不再重复加载。
  35. type monitorUsageSource interface {
  36. GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error)
  37. }
  38. // monitorCNQuotaSource 国产 coding plan 滚动窗口额度探测(CNProviderQuotaService 天然满足)。
  39. type monitorCNQuotaSource interface {
  40. QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error)
  41. }
  42. // monitorCNBalanceSource 国产 payg 余额探测(CNProviderBalanceService 天然满足)。
  43. type monitorCNBalanceSource interface {
  44. QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error)
  45. }
  46. // monitorAccountSource 账号加载(AccountRepository 天然满足)。
  47. type monitorAccountSource interface {
  48. GetByID(ctx context.Context, id int64) (*Account, error)
  49. }
  50. // ChannelMonitorQuotaFetcher 配额抓取器(成功/失败快照均带 TTL 缓存,
  51. // 同账号并发抓取由 singleflight 合并)。
  52. type ChannelMonitorQuotaFetcher struct {
  53. usage monitorUsageSource
  54. cnQuota monitorCNQuotaSource
  55. cnBalance monitorCNBalanceSource
  56. accounts monitorAccountSource
  57. // balanceThreshold cn_balance 余额告警阈值(与账号停调共用配置,见 monitorBalanceThreshold)。
  58. balanceThreshold float64
  59. mu sync.Mutex
  60. cache map[int64]monitorQuotaCacheEntry
  61. flight singleflight.Group
  62. }
  63. type monitorQuotaCacheEntry struct {
  64. snapshot *domain.MonitorQuotaSnapshot
  65. expiry time.Time
  66. }
  67. // NewChannelMonitorQuotaFetcher 构造配额抓取器。
  68. // 参数取具体服务类型以便 wire 直连;单元测试在同包内用 struct 字面量注入 stub。
  69. func NewChannelMonitorQuotaFetcher(
  70. usage *AccountUsageService,
  71. cnQuota *CNProviderQuotaService,
  72. cnBalance *CNProviderBalanceService,
  73. accounts AccountRepository,
  74. cfg *config.Config,
  75. ) *ChannelMonitorQuotaFetcher {
  76. f := &ChannelMonitorQuotaFetcher{
  77. cache: make(map[int64]monitorQuotaCacheEntry),
  78. balanceThreshold: monitorBalanceThreshold(cfg),
  79. }
  80. if usage != nil {
  81. f.usage = usage
  82. }
  83. if cnQuota != nil {
  84. f.cnQuota = cnQuota
  85. }
  86. if cnBalance != nil {
  87. f.cnBalance = cnBalance
  88. }
  89. if accounts != nil {
  90. f.accounts = accounts
  91. }
  92. return f
  93. }
  94. // monitorBalanceThreshold 余额告警阈值,与账号停调(CNProviderBalanceCheckService)
  95. // 共用 gateway.cn_providers.balance_threshold,保证监控 degraded 与调度器停调
  96. // 口径一致(任一币种达标即健康)。未配置/非正值时回退 viper 默认 0.5(config.go),
  97. // 避免 0 阈值下「余额=0 也不告警」相对旧 `<=0` 判定的回归。
  98. func monitorBalanceThreshold(cfg *config.Config) float64 {
  99. if cfg != nil && cfg.Gateway.CNProviders.BalanceThreshold > 0 {
  100. return cfg.Gateway.CNProviders.BalanceThreshold
  101. }
  102. return 0.5
  103. }
  104. // LoadAccount 加载账号(不走缓存)。供 Create/Update 时校验
  105. // provider 与 account.platform 一致;账号不存在时返回错误。
  106. func (f *ChannelMonitorQuotaFetcher) LoadAccount(ctx context.Context, id int64) (*Account, error) {
  107. if f == nil || f.accounts == nil {
  108. return nil, fmt.Errorf("quota fetcher is not configured")
  109. }
  110. return f.accounts.GetByID(ctx, id)
  111. }
  112. // Fetch 抓取账号的最新配额快照。永不返回 error:失败降级为
  113. // Success=false 快照(Error 带摘要),保证检测历史的时间线连续。
  114. func (f *ChannelMonitorQuotaFetcher) Fetch(ctx context.Context, accountID int64) *domain.MonitorQuotaSnapshot {
  115. if f == nil {
  116. // fail-closed:fetcher 未注入(存量测试构造)时不 panic,降级为错误快照。
  117. return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now())
  118. }
  119. now := time.Now()
  120. if cached, ok := f.cachedSnapshot(accountID, now); ok {
  121. return cached
  122. }
  123. // singleflight 合并同账号并发抓取;脱离调用方 ctx(仿 CN 配额服务),
  124. // 避免某个监控的取消波及共享同一账号的其他监控。
  125. key := "monitor-quota:" + strconv.FormatInt(accountID, 10)
  126. ch := f.flight.DoChan(key, func() (any, error) {
  127. return f.fetchShared(accountID), nil
  128. })
  129. select {
  130. case <-ctx.Done():
  131. return quotaErrorSnapshot("usage", "context canceled", now)
  132. case res := <-ch:
  133. snapshot, ok := res.Val.(*domain.MonitorQuotaSnapshot)
  134. if res.Err != nil || !ok || snapshot == nil {
  135. return quotaErrorSnapshot("usage", "quota fetch failed", now)
  136. }
  137. return snapshot
  138. }
  139. }
  140. // fetchShared 是 singleflight 的执行体:抓取一次并写入缓存,结果由同一 key 上
  141. // 所有等待者共享。
  142. //
  143. // 开头必须重查缓存。Fetch 顶部的 cachedSnapshot 与下面的 flight.DoChan 之间有一个
  144. // 窗口:期间另一个 goroutine 的 flight 可能已经跑完、写好缓存,并且它的 singleflight
  145. // key 也已被摘掉,于是本 goroutine 不会并入那次飞行,而是另起一个新的、对同一账号
  146. // 再打一次上游——正是 singleflight 要消除的那种重复查询。
  147. //
  148. // 这次重查一定命中,所以合并是确定的而不是尽力而为:storeSnapshot 发生在本函数
  149. // 返回之前,而 singleflight 删 key 发生在返回之后,因此「能新起一次飞行」必然蕴含
  150. // 「上一次的快照已经可见」。
  151. func (f *ChannelMonitorQuotaFetcher) fetchShared(accountID int64) *domain.MonitorQuotaSnapshot {
  152. if cached, ok := f.cachedSnapshot(accountID, time.Now()); ok {
  153. return cached
  154. }
  155. fetchCtx, cancel := context.WithTimeout(context.Background(), monitorQuotaFetchTimeout)
  156. defer cancel()
  157. snapshot := f.fetchUncached(fetchCtx, accountID, time.Now())
  158. // 失败也进短 TTL 负缓存:凭据失效/故障期间不必每次调度都打上游。
  159. ttl := monitorQuotaFetchCacheTTL
  160. if !snapshot.Success {
  161. ttl = monitorQuotaErrorCacheTTL
  162. }
  163. f.storeSnapshot(accountID, snapshot, time.Now().Add(ttl))
  164. return snapshot
  165. }
  166. func (f *ChannelMonitorQuotaFetcher) cachedSnapshot(accountID int64, now time.Time) (*domain.MonitorQuotaSnapshot, bool) {
  167. f.mu.Lock()
  168. defer f.mu.Unlock()
  169. entry, ok := f.cache[accountID]
  170. if !ok || now.After(entry.expiry) {
  171. return nil, false
  172. }
  173. return entry.snapshot, true
  174. }
  175. func (f *ChannelMonitorQuotaFetcher) storeSnapshot(accountID int64, snapshot *domain.MonitorQuotaSnapshot, expiry time.Time) {
  176. f.mu.Lock()
  177. defer f.mu.Unlock()
  178. f.cache[accountID] = monitorQuotaCacheEntry{snapshot: snapshot, expiry: expiry}
  179. }
  180. func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot {
  181. if f == nil {
  182. return quotaErrorSnapshot("usage", "quota fetcher is not configured", now)
  183. }
  184. account, err := f.LoadAccount(ctx, accountID)
  185. if err != nil || account == nil {
  186. // FK ON DELETE SET NULL 后 account_id 可能为空/失效;显式报「账号未关联」,
  187. // 推导为 degraded(配置问题,不是渠道故障)。
  188. slog.Warn("channel_monitor: load linked account failed",
  189. "account_id", accountID, "error", err)
  190. return quotaErrorSnapshot("usage", "linked account not found", now)
  191. }
  192. // 账号只在路由前加载这一次;已加载的 account 直接传给数据源
  193. // (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount),
  194. // 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。
  195. switch account.Platform {
  196. case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax:
  197. if account.IsCodingPlan() {
  198. return f.fetchCNQuota(ctx, account, now)
  199. }
  200. return f.fetchCNBalance(ctx, account, now)
  201. default:
  202. return f.fetchUsage(ctx, account, now)
  203. }
  204. }
  205. // fetchUsage 海外平台:AccountUsageService.GetUsageForAccount → 快照。
  206. func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot {
  207. if f.usage == nil {
  208. return quotaErrorSnapshot("usage", "usage service is not configured", now)
  209. }
  210. usage, err := f.usage.GetUsageForAccount(ctx, account)
  211. if err != nil {
  212. msg := truncateMessage(sanitizeErrorMessage(err.Error()))
  213. return &domain.MonitorQuotaSnapshot{
  214. Source: "usage",
  215. Success: false,
  216. CredentialInvalid: isCredentialErrorMessage(msg),
  217. Error: msg,
  218. FetchedAt: now,
  219. }
  220. }
  221. if usage == nil {
  222. return quotaErrorSnapshot("usage", "usage service returned no data", now)
  223. }
  224. // openai/gemini/antigravity/grok 的失败多走「值通道」(err==nil 但错误
  225. // 降级在 UsageInfo 字段里),必须显式识别,否则会被误判为 operational。
  226. if failed, credInvalid, msg := usageFailureInfo(usage); failed {
  227. return &domain.MonitorQuotaSnapshot{
  228. Source: "usage",
  229. Success: false,
  230. CredentialInvalid: credInvalid,
  231. Error: truncateMessage(sanitizeErrorMessage(msg)),
  232. FetchedAt: now,
  233. }
  234. }
  235. snapshot := &domain.MonitorQuotaSnapshot{
  236. Source: "usage",
  237. Success: true,
  238. PlanLevel: usage.SubscriptionTier,
  239. Tiers: usageQuotaTiers(usage),
  240. FetchedAt: now,
  241. }
  242. if snapshot.PlanLevel == "" {
  243. snapshot.PlanLevel = usage.SubscriptionTierRaw
  244. }
  245. return snapshot
  246. }
  247. // usageQuotaTiers 把 UsageInfo 的各平台窗口归一为 tier 列表(无数据的窗口跳过)。
  248. func usageQuotaTiers(usage *UsageInfo) []domain.MonitorQuotaTier {
  249. if usage == nil {
  250. return nil
  251. }
  252. tiers := make([]domain.MonitorQuotaTier, 0, 8)
  253. appendProgressTier(&tiers, "5h", "", usage.FiveHour)
  254. appendProgressTier(&tiers, "7d", "", usage.SevenDay)
  255. appendProgressTier(&tiers, "7d-sonnet", "", usage.SevenDaySonnet)
  256. appendProgressTier(&tiers, "7d-fable", "", usage.SevenDayFable)
  257. appendProgressTier(&tiers, "30d", "", usage.ThirtyDay)
  258. // Gemini 多档日配额:同 Window 不同 Label。
  259. appendProgressTier(&tiers, "daily", "shared", usage.GeminiSharedDaily)
  260. appendProgressTier(&tiers, "daily", "pro", usage.GeminiProDaily)
  261. appendProgressTier(&tiers, "daily", "flash", usage.GeminiFlashDaily)
  262. // Grok requests/tokens 两个日窗口 + 月度计费窗口。
  263. appendQuotaWindowTier(&tiers, "daily", "requests", usage.GrokRequestQuota)
  264. appendQuotaWindowTier(&tiers, "daily", "tokens", usage.GrokTokenQuota)
  265. // Antigravity per-model 总量额度,Label = 模型名(按名排序保证输出稳定)。
  266. for _, model := range sortedQuotaModelNames(usage.AntigravityQuota) {
  267. q := usage.AntigravityQuota[model]
  268. if q == nil {
  269. continue
  270. }
  271. tiers = append(tiers, domain.MonitorQuotaTier{
  272. Window: "total",
  273. Label: model,
  274. UsedPercent: float64(q.Utilization),
  275. ResetAt: q.ResetTime,
  276. })
  277. }
  278. if len(tiers) == 0 {
  279. return nil
  280. }
  281. return tiers
  282. }
  283. func appendProgressTier(tiers *[]domain.MonitorQuotaTier, window, label string, p *UsageProgress) {
  284. if p == nil {
  285. return
  286. }
  287. tier := domain.MonitorQuotaTier{
  288. Window: window,
  289. Label: label,
  290. UsedPercent: p.Utilization,
  291. }
  292. if p.ResetsAt != nil {
  293. tier.ResetAt = p.ResetsAt.UTC().Format(time.RFC3339)
  294. }
  295. if p.LimitRequests > 0 {
  296. tier.Used = float64(p.UsedRequests)
  297. tier.Limit = float64(p.LimitRequests)
  298. }
  299. *tiers = append(*tiers, tier)
  300. }
  301. func appendQuotaWindowTier(tiers *[]domain.MonitorQuotaTier, window, label string, q *xai.QuotaWindow) {
  302. if q == nil || q.Limit == nil || *q.Limit <= 0 {
  303. return
  304. }
  305. used := *q.Limit
  306. if q.Remaining != nil {
  307. used = *q.Limit - *q.Remaining
  308. if used < 0 {
  309. used = 0
  310. }
  311. }
  312. tier := domain.MonitorQuotaTier{
  313. Window: window,
  314. Label: label,
  315. Used: float64(used),
  316. Limit: float64(*q.Limit),
  317. UsedPercent: float64(used) / float64(*q.Limit) * 100,
  318. }
  319. if q.ResetAt != "" {
  320. tier.ResetAt = q.ResetAt
  321. } else if q.ResetUnix != nil && *q.ResetUnix > 0 {
  322. tier.ResetAt = time.Unix(*q.ResetUnix, 0).UTC().Format(time.RFC3339)
  323. }
  324. *tiers = append(*tiers, tier)
  325. }
  326. func sortedQuotaModelNames(quotas map[string]*AntigravityModelQuota) []string {
  327. names := make([]string, 0, len(quotas))
  328. for name := range quotas {
  329. names = append(names, name)
  330. }
  331. sort.Strings(names)
  332. return names
  333. }
  334. // fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsageForAccount → 快照。
  335. func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot {
  336. if f.cnQuota == nil {
  337. return quotaErrorSnapshot("cn_quota", "cn quota service is not configured", now)
  338. }
  339. result, err := f.cnQuota.QueryUsageForAccount(ctx, account)
  340. if err != nil {
  341. msg := truncateMessage(sanitizeErrorMessage(err.Error()))
  342. return &domain.MonitorQuotaSnapshot{
  343. Source: "cn_quota",
  344. Success: false,
  345. CredentialInvalid: isCredentialErrorMessage(msg),
  346. Error: msg,
  347. FetchedAt: now,
  348. }
  349. }
  350. snapshot := &domain.MonitorQuotaSnapshot{
  351. Source: "cn_quota",
  352. Success: result.Success,
  353. PlanLevel: result.PlanLevel,
  354. Error: result.Error,
  355. FetchedAt: now,
  356. }
  357. // 只有 401/403 判凭据失效(与 fetchCNBalance 口径一致):CN quota 服务的
  358. // CredentialValid 仅在成功路径置 true,若按 `!Success && !CredentialValid`
  359. // 推导,500/429/智谱业务错误全会被误判为 failed。
  360. if !result.Success && (result.StatusCode == 401 || result.StatusCode == 403) {
  361. snapshot.CredentialInvalid = true
  362. }
  363. if len(result.Tiers) > 0 {
  364. snapshot.Tiers = make([]domain.MonitorQuotaTier, 0, len(result.Tiers))
  365. for _, t := range result.Tiers {
  366. snapshot.Tiers = append(snapshot.Tiers, domain.MonitorQuotaTier{
  367. Window: t.Window,
  368. UsedPercent: t.UsedPercent,
  369. ResetAt: t.ResetAt,
  370. })
  371. }
  372. }
  373. if !snapshot.Success {
  374. snapshot.Error = firstNonEmpty(snapshot.Error, "cn quota probe failed")
  375. }
  376. return snapshot
  377. }
  378. // fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalanceForAccount → 快照。
  379. func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot {
  380. if f.cnBalance == nil {
  381. return quotaErrorSnapshot("cn_balance", "cn balance service is not configured", now)
  382. }
  383. result, err := f.cnBalance.QueryBalanceForAccount(ctx, account)
  384. if err != nil {
  385. msg := truncateMessage(sanitizeErrorMessage(err.Error()))
  386. return &domain.MonitorQuotaSnapshot{
  387. Source: "cn_balance",
  388. Success: false,
  389. CredentialInvalid: isCredentialErrorMessage(msg),
  390. Error: msg,
  391. FetchedAt: now,
  392. }
  393. }
  394. snapshot := &domain.MonitorQuotaSnapshot{
  395. Source: "cn_balance",
  396. Success: result.Success,
  397. Currency: result.Currency,
  398. Error: result.Error,
  399. FetchedAt: now,
  400. }
  401. if result.Success {
  402. balance := result.Balance
  403. snapshot.Balance = &balance
  404. // 与账号停调(checkOne)同口径:上游标记不可用或全部币种低于阈值
  405. // 才告警,任一币种达标即健康(余额 5 元/阈值 10 元的账号调度器已
  406. // 停调,监控不能仍绿灯)。
  407. snapshot.BalanceLow = !result.Available || allCNBalancesBelowThreshold(result, f.balanceThreshold)
  408. } else if result.StatusCode == 401 || result.StatusCode == 403 {
  409. snapshot.CredentialInvalid = true
  410. }
  411. if len(result.Balances) > 0 {
  412. snapshot.Balances = make([]domain.MonitorBalance, 0, len(result.Balances))
  413. for _, b := range result.Balances {
  414. snapshot.Balances = append(snapshot.Balances, domain.MonitorBalance{
  415. Currency: b.Currency,
  416. Balance: b.Balance,
  417. })
  418. }
  419. }
  420. if !snapshot.Success {
  421. snapshot.Error = firstNonEmpty(snapshot.Error, "cn balance probe failed")
  422. }
  423. return snapshot
  424. }
  425. // quotaErrorSnapshot 构造统一错误快照。
  426. func quotaErrorSnapshot(source, message string, now time.Time) *domain.MonitorQuotaSnapshot {
  427. return &domain.MonitorQuotaSnapshot{
  428. Source: source,
  429. Success: false,
  430. Error: truncateMessage(sanitizeErrorMessage(message)),
  431. FetchedAt: now,
  432. }
  433. }
  434. // isCredentialErrorMessage 上游 401/403 鉴权失败的启发式识别
  435. // (海外 GetUsage 的错误没有结构化状态码,只能看文本)。
  436. func isCredentialErrorMessage(msg string) bool {
  437. msg = strings.ToLower(msg)
  438. return strings.Contains(msg, "401") ||
  439. strings.Contains(msg, "403") ||
  440. strings.Contains(msg, "unauthorized") ||
  441. strings.Contains(msg, "forbidden") ||
  442. strings.Contains(msg, "invalid_api_key") ||
  443. strings.Contains(msg, "authentication")
  444. }
  445. // usageFailureInfo 识别 GetUsage 经「值通道」返回的失败:antigravity/grok
  446. // 等平台 err==nil 但把错误降级在 UsageInfo 字段里(Error/ErrorCode/状态标记)。
  447. // 返回 failed=false 表示可用;credentialInvalid 表示凭据失效(401/403 语义,
  448. // 推导为 failed 状态);msg 为失败摘要。
  449. //
  450. // grok 的 ErrorCode=quota_unknown 是「尚未观测到计费快照/限流头」的已知未知态,
  451. // 不是失败(严格按 ErrorCode 判会把健康 grok 账号永久判 error),显式豁免。
  452. func usageFailureInfo(usage *UsageInfo) (failed, credentialInvalid bool, msg string) {
  453. if usage == nil {
  454. return false, false, ""
  455. }
  456. if usage.ErrorCode == "quota_unknown" {
  457. return false, false, ""
  458. }
  459. failed = usage.Error != "" || usage.NeedsReauth || usage.IsBanned ||
  460. usage.IsForbidden || usage.ErrorCode != ""
  461. if !failed {
  462. return false, false, ""
  463. }
  464. credentialInvalid = usage.NeedsReauth || usage.IsBanned || usage.IsForbidden ||
  465. usage.ErrorCode == errorCodeUnauthenticated || usage.ErrorCode == errorCodeForbidden
  466. msg = firstNonEmpty(usage.Error, usage.ForbiddenReason, usage.ErrorCode, "usage fetch failed")
  467. return failed, credentialInvalid, msg
  468. }
  469. // deriveQuotaCheckResult 把配额快照推导为检测状态(复用既有 status 枚举,
  470. // 时间线/可用率机制自动生效):
  471. // - 查询成功且无告警 → operational
  472. // - 任一窗口使用率 >= 阈值或余额低于阈值/不可用 → degraded
  473. // - 账号未关联(配置问题) → degraded
  474. // - 凭据失效(401/403) → failed
  475. // - 网络/解析等其他错误 → error
  476. func deriveQuotaCheckResult(snapshot *domain.MonitorQuotaSnapshot, model string, checkedAt time.Time) *CheckResult {
  477. res := &CheckResult{Model: model, CheckedAt: checkedAt}
  478. if snapshot == nil {
  479. res.Status = MonitorStatusError
  480. res.Message = "quota snapshot missing"
  481. return res
  482. }
  483. switch {
  484. case !snapshot.Success && snapshot.CredentialInvalid:
  485. res.Status = MonitorStatusFailed
  486. res.Message = snapshot.Error
  487. case !snapshot.Success && strings.Contains(snapshot.Error, "linked account not found"):
  488. res.Status = MonitorStatusDegraded
  489. res.Message = snapshot.Error
  490. case !snapshot.Success:
  491. res.Status = MonitorStatusError
  492. res.Message = snapshot.Error
  493. default:
  494. if hint := quotaDegradedHint(snapshot); hint != "" {
  495. res.Status = MonitorStatusDegraded
  496. res.Message = hint
  497. } else {
  498. res.Status = MonitorStatusOperational
  499. }
  500. }
  501. return res
  502. }
  503. // quotaDegradedHint 生成 degraded 的 message(指出触发告警的窗口/余额);
  504. // 空串表示无告警。
  505. func quotaDegradedHint(snapshot *domain.MonitorQuotaSnapshot) string {
  506. for _, tier := range snapshot.Tiers {
  507. if tier.UsedPercent >= monitorQuotaDegradedUsedPercent {
  508. name := tier.Window
  509. if tier.Label != "" {
  510. name = tier.Label + "/" + tier.Window
  511. }
  512. return fmt.Sprintf("quota high: %s at %s%%", name, strconv.FormatFloat(tier.UsedPercent, 'f', 1, 64))
  513. }
  514. }
  515. if snapshot.BalanceLow {
  516. if snapshot.Balance != nil {
  517. return fmt.Sprintf("balance low: %s %s", strconv.FormatFloat(*snapshot.Balance, 'f', -1, 64), firstNonEmpty(snapshot.Currency, "?"))
  518. }
  519. return fmt.Sprintf("balance low (%s)", firstNonEmpty(snapshot.Currency, "?"))
  520. }
  521. return ""
  522. }