cn_provider_balance_service.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/tidwall/gjson"
  14. "golang.org/x/sync/singleflight"
  15. )
  16. // 国产供应商 payg(按量付费)账号余额探测服务。
  17. //
  18. // 仅覆盖有公开余额端点的供应商:
  19. // - Kimi/Moonshot:GET https://api.moonshot.cn/v1/users/me/balance (Bearer) → data.available_balance
  20. // - DeepSeek: GET https://api.deepseek.com/user/balance (Bearer) → balance_infos[].total_balance + is_available
  21. //
  22. // 智谱(zhipu)无公开余额端点(OpenAPI 规格验证),仅靠响应式 429/402(见
  23. // ratelimit_cn_providers.go)。解析逻辑对齐 cc-switch services/balance.rs::query_deepseek。
  24. const (
  25. cnBalanceUpstreamTimeout = 15 * time.Second
  26. cnBalanceMaxBodyBytes = 256 * 1024
  27. // Extra 余额快照键后缀(加 provider 前缀)。
  28. cnBalanceExtraSuffixBalance = "balance"
  29. cnBalanceExtraSuffixCurrency = "balance_currency"
  30. cnBalanceExtraSuffixAvailable = "balance_available" // deepseek is_available 健康标记
  31. cnBalanceExtraSuffixUpdated = "balance_updated_at"
  32. cnBalanceExtraSuffixBalances = "balances" // 多币种明细(deepseek USD+CNY)
  33. )
  34. // CNProviderBalanceEntry 是单一币种的余额明细。
  35. type CNProviderBalanceEntry struct {
  36. Currency string `json:"currency"`
  37. Balance float64 `json:"balance"`
  38. }
  39. // CNProviderBalanceResult 是余额探测的返回结构(管理端 + UI 消费)。
  40. type CNProviderBalanceResult struct {
  41. Provider string `json:"provider"`
  42. Success bool `json:"success"`
  43. // Balance/Currency 为主币种(balance_infos 首条,兼容单币种消费方);
  44. // 完整明细见 Balances(deepseek 双币种账号含 CNY + USD 两条)。
  45. Balance float64 `json:"balance"`
  46. Currency string `json:"currency,omitempty"`
  47. Balances []CNProviderBalanceEntry `json:"balances,omitempty"`
  48. Available bool `json:"available"` // 健康标记(deepseek is_available;kimi 无此概念恒 true)
  49. StatusCode int `json:"status_code,omitempty"`
  50. FetchedAt int64 `json:"fetched_at"`
  51. Persisted bool `json:"persisted"`
  52. Error string `json:"error,omitempty"`
  53. }
  54. // CNProviderBalanceService 探测 Kimi / DeepSeek payg 账号的账户余额。
  55. type CNProviderBalanceService struct {
  56. accountRepo AccountRepository
  57. proxyRepo ProxyRepository
  58. httpUpstream HTTPUpstream
  59. cfg *config.Config
  60. flight singleflight.Group
  61. }
  62. // NewCNProviderBalanceService 构造余额探测服务。
  63. func NewCNProviderBalanceService(
  64. accountRepo AccountRepository,
  65. proxyRepo ProxyRepository,
  66. httpUpstream HTTPUpstream,
  67. cfg *config.Config,
  68. ) *CNProviderBalanceService {
  69. return &CNProviderBalanceService{
  70. accountRepo: accountRepo,
  71. proxyRepo: proxyRepo,
  72. httpUpstream: httpUpstream,
  73. cfg: cfg,
  74. }
  75. }
  76. // QueryBalance 探测指定 payg 账号的余额并落 Extra 快照。
  77. func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) {
  78. account, err := s.loadPayGAccount(ctx, accountID)
  79. if err != nil {
  80. return nil, err
  81. }
  82. return s.QueryBalanceForAccount(ctx, account)
  83. }
  84. // QueryBalanceForAccount 探测已加载账号(配额监控 fetcher / 周期余额检测复用,
  85. // 避免二次 GetByID)。singleflight key 与 QueryBalance 相同,按账号 ID 合并。
  86. func (s *CNProviderBalanceService) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
  87. if s == nil || s.accountRepo == nil || s.httpUpstream == nil {
  88. return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_NOT_CONFIGURED", "cn provider balance service is not configured")
  89. }
  90. if err := validatePayGAccount(account); err != nil {
  91. return nil, err
  92. }
  93. key := "cn_balance:" + strconv.FormatInt(account.ID, 10)
  94. resultCh := s.flight.DoChan(key, func() (any, error) {
  95. probeCtx, cancel := context.WithTimeout(context.Background(), cnBalanceUpstreamTimeout+5*time.Second)
  96. defer cancel()
  97. return s.queryBalanceForAccount(probeCtx, account)
  98. })
  99. select {
  100. case <-ctx.Done():
  101. return nil, ctx.Err()
  102. case flightResult := <-resultCh:
  103. if flightResult.Err != nil {
  104. return nil, flightResult.Err
  105. }
  106. result, ok := flightResult.Val.(*CNProviderBalanceResult)
  107. if !ok || result == nil {
  108. return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_PROBE_RESULT_INVALID", "invalid cn provider balance probe result")
  109. }
  110. cloned := *result
  111. return &cloned, nil
  112. }
  113. }
  114. func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
  115. provider := account.Platform
  116. if provider != PlatformKimi && provider != PlatformDeepseek {
  117. return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint")
  118. }
  119. apiKey := strings.TrimSpace(account.GetCNAPIKey())
  120. if apiKey == "" {
  121. return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty")
  122. }
  123. targetURL := cnBalanceURL(account)
  124. // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
  125. // DeepSeek 端点由账号 base_url 衍生,不得把 API key 发往策略外主机。
  126. validatedURL, err := cnValidateProbeURL(s.cfg, targetURL)
  127. if err != nil {
  128. return nil, infraerrors.New(http.StatusForbidden, "CN_BALANCE_URL_REJECTED", err.Error())
  129. }
  130. targetURL = validatedURL
  131. proxyURL := s.resolveProxyURL(ctx, account)
  132. callCtx, cancel := context.WithTimeout(ctx, cnBalanceUpstreamTimeout)
  133. defer cancel()
  134. req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil)
  135. if err != nil {
  136. return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_BALANCE_REQUEST_BUILD_FAILED", "build request: %v", err)
  137. }
  138. req.Header.Set("Authorization", "Bearer "+apiKey)
  139. req.Header.Set("Accept", "application/json")
  140. account.ApplyHeaderOverrides(req.Header)
  141. resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1))
  142. if err != nil {
  143. return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "upstream request failed: %v", err)
  144. }
  145. defer func() { _ = resp.Body.Close() }()
  146. bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnBalanceMaxBodyBytes))
  147. now := time.Now().UTC()
  148. result := &CNProviderBalanceResult{
  149. Provider: provider,
  150. FetchedAt: now.Unix(),
  151. StatusCode: resp.StatusCode,
  152. Available: true,
  153. }
  154. if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
  155. result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode)
  156. return result, nil
  157. }
  158. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  159. result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240))
  160. return result, nil
  161. }
  162. var entries []CNProviderBalanceEntry
  163. available := true
  164. switch provider {
  165. case PlatformKimi:
  166. // Moonshot:code==0 成功;data.available_balance(number),单币种 CNY。
  167. balance, _ := cnParseF64(gjson.GetBytes(bodyBytes, "data.available_balance").Value())
  168. entries = append(entries, CNProviderBalanceEntry{Currency: "CNY", Balance: balance})
  169. case PlatformDeepseek:
  170. // is_available 缺省视为 true(健康);显式存在时取其值。
  171. if v := gjson.GetBytes(bodyBytes, "is_available"); v.Exists() {
  172. available = v.Bool()
  173. }
  174. // balance_infos 逐条解析:双币种账号同时返回 CNY + USD(数组顺序即
  175. // 主次序,首条为主币种)。
  176. balanceInfos := gjson.GetBytes(bodyBytes, "balance_infos")
  177. if !balanceInfos.Exists() || !balanceInfos.IsArray() {
  178. result.Error = "Invalid balance response: missing balance_infos"
  179. return result, nil
  180. }
  181. balanceInfos.ForEach(func(_, info gjson.Result) bool {
  182. currency := strings.ToUpper(strings.TrimSpace(info.Get("currency").String()))
  183. totalBalance := info.Get("total_balance")
  184. balance, ok := cnParseF64(totalBalance.Value())
  185. if !totalBalance.Exists() || !ok {
  186. return true
  187. }
  188. if currency == "" {
  189. currency = "CNY"
  190. }
  191. entries = append(entries, CNProviderBalanceEntry{Currency: currency, Balance: balance})
  192. return true
  193. })
  194. if len(entries) == 0 {
  195. result.Error = "Invalid balance response: no valid balance entries"
  196. return result, nil
  197. }
  198. }
  199. result.Balances = entries
  200. result.Balance = entries[0].Balance
  201. result.Currency = entries[0].Currency
  202. result.Available = available
  203. result.Success = true
  204. balanceUpdates := make([]any, 0, len(entries))
  205. for _, entry := range entries {
  206. balanceUpdates = append(balanceUpdates, map[string]any{
  207. "currency": entry.Currency,
  208. "balance": entry.Balance,
  209. })
  210. }
  211. updates := map[string]any{
  212. cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance,
  213. cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency,
  214. cnExtraKey(provider, cnBalanceExtraSuffixAvailable): available,
  215. cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339),
  216. cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates,
  217. // 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。
  218. cnExtraKey(provider, cnBalanceExtraSuffixLow): false,
  219. }
  220. if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
  221. slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", provider, "error", err)
  222. } else {
  223. result.Persisted = true
  224. }
  225. return result, nil
  226. }
  227. // loadPayGAccount 加载 payg 模式的国产供应商账号(余额仅对 payg 有意义;coding 走额度)。
  228. func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountID int64) (*Account, error) {
  229. account, err := s.accountRepo.GetByID(ctx, accountID)
  230. if err != nil {
  231. return nil, infraerrors.Newf(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found: %v", err)
  232. }
  233. if err := validatePayGAccount(account); err != nil {
  234. return nil, err
  235. }
  236. return account, nil
  237. }
  238. // validatePayGAccount 加载后的非 DB 校验(ForAccount 入口同样复用,
  239. // 保证直传 account 也不绕过平台/模式检查)。
  240. func validatePayGAccount(account *Account) error {
  241. if account == nil {
  242. return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found")
  243. }
  244. if !account.IsCNProvider() {
  245. return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account")
  246. }
  247. // coding 账号走额度探测,余额端点不适用。
  248. if account.IsCodingPlan() {
  249. return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe")
  250. }
  251. return nil
  252. }
  253. func (s *CNProviderBalanceService) resolveProxyURL(ctx context.Context, account *Account) string {
  254. if account == nil || account.ProxyID == nil {
  255. return ""
  256. }
  257. if account.Proxy != nil {
  258. return account.Proxy.URL()
  259. }
  260. if s != nil && s.proxyRepo != nil {
  261. if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil {
  262. account.Proxy = proxy
  263. return proxy.URL()
  264. }
  265. }
  266. return ""
  267. }
  268. // cnBalanceURL 解析账号的余额端点。
  269. //
  270. // - Kimi:固定 https://api.moonshot.cn/v1/users/me/balance(与 base_url 无关,Moonshot 仅此一处)
  271. // - DeepSeek:基于 base_url 拼接 /user/balance(支持自定义域名)
  272. func cnBalanceURL(account *Account) string {
  273. switch account.Platform {
  274. case PlatformKimi:
  275. return "https://api.moonshot.cn/v1/users/me/balance"
  276. case PlatformDeepseek:
  277. // Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,余额探测需回退
  278. // 到 OpenAI 格式 base(协议感知)再拼接 /user/balance。
  279. return strings.TrimRight(account.GetOpenAIFormatBaseURL(), "/") + "/user/balance"
  280. default:
  281. return ""
  282. }
  283. }