Files
sub2api/backend/internal/service/cn_provider_balance_service.go
T
SavitarCandClaude Opus 5.5 874871a25a feat(platform): 接入 Cline 平台
Cline API(https://api.cline.bot/api/v1)是 OpenAI 兼容的多模型聚合端点,
只提供 Chat Completions(Cline 自己的客户端同样只走该端点),模型名带厂商
前缀。同一个 API Key 下有两个互不影响的额度来源,由模型名决定扣哪一边:
cline-pass/* 走 ClinePass 订阅(5 小时 / 7 天 / 30 天滚动上限),其余付费
模型扣账户积分;cline-free/* 免费模型只有逐模型的每日额度。

转发:
- 平台清单与 provider profile 新增 cline:按入站协议分流,只登记 Chat
  Completions 端点,Responses / Anthropic 入站由现有转换链转为 Chat
  Completions,无需新增转发代码。
- 只有一个接入模式,不需要选择账号类型。
- 连接测试未指定模型时使用 profile 默认模型(固定 Chat Completions 的账号
  此前落到不带厂商前缀的通用默认模型);订阅了 ClinePass 的账号改用订阅
  模型,不消耗积分,ClinePass 冷却中时仍用按量模型。

用量与余额:API Key 调 /api/v1/users/me/plan/usage-limits(三档窗口的已用
百分比与重置时间,未订阅时 404)与 /api/v1/users/me、/users/{id}/balance
(余额单位为百万分之一积分,1 积分 = 1 美元),兼容 {success, data} 信封,
只查官方主机。复用额度探测与周期检测:账号列表显示 ClinePass 窗口与积分
余额;窗口用满时 ClinePass 模型冷却到重置时间,未订阅时不调度 ClinePass
模型,积分低于阈值时冷却付费模型;数据显示恢复后自动解除,但只解除探测能
证明已恢复的原因(窗口用满、未订阅、积分不足),组织账户、所需订阅档位、推理
额度与花费上限的冷却到期后由请求重新确认。周期检测中走账号接口探测的平台
改为登记表(平台、支持判定、检测函数一一对应),按账号所属平台分派。

上游错误(依据 docs.cline.bot/api/errors 与 Cline 客户端的错误分类,按文案
识别):积分不足(含其他 402)、组织花费上限、推理额度上限、ClinePass 超限、
未订阅 ClinePass / 组织账户、免费模型每日额度。两边的超限只冷却请求模型所属
的一边(模型级冷却键 cline:pass / cline:credits,调度按请求模型检查),不停
整个账号;免费模型只限该模型,冷却到文案中的 "try again in …"。ClinePass
超限冷却到快照中对应窗口的重置时间,没有快照时 5 小时窗口 1 小时、7 天
6 小时、30 天 24 小时。普通频率限制走默认 429 逻辑。402 的冷却写入失败时
也不落入默认分支永久置 error(探测只覆盖激活账号,置 error 后无法自动恢复)。
余额类冷却时长抽为 cnBalanceCheckCooldown 共用。

上游计费探测的官方域名加入 cline.bot。

前端:官方标志(机器人头像)、violet 配色(品牌紫)、复用额度与余额单元格
(同样只对官方主机上的 API Key 账号显示);接入模式展示名抽为共用函数,编辑
弹窗不再显示原始模式值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:11:06 +08:00

331 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/tidwall/gjson"
"golang.org/x/sync/singleflight"
)
// 国产供应商 payg(按量付费)账号余额探测服务。
//
// 仅覆盖有公开余额端点的供应商:
// - Kimi/Moonshot:GET https://api.moonshot.cn/v1/users/me/balance (Bearer) → data.available_balance
// - DeepSeek: GET https://api.deepseek.com/user/balance (Bearer) → balance_infos[].total_balance + is_available
//
// 智谱(zhipu)无公开余额端点(OpenAPI 规格验证),仅靠响应式 429/402(见
// ratelimit_cn_providers.go)。解析逻辑对齐 cc-switch services/balance.rs::query_deepseek。
const (
cnBalanceUpstreamTimeout = 15 * time.Second
cnBalanceMaxBodyBytes = 256 * 1024
// Extra 余额快照键后缀(加 provider 前缀)。
cnBalanceExtraSuffixBalance = "balance"
cnBalanceExtraSuffixCurrency = "balance_currency"
cnBalanceExtraSuffixAvailable = "balance_available" // deepseek is_available 健康标记
cnBalanceExtraSuffixUpdated = "balance_updated_at"
cnBalanceExtraSuffixBalances = "balances" // 多币种明细(deepseek USD+CNY)
)
// CNProviderBalanceEntry 是单一币种的余额明细。
type CNProviderBalanceEntry struct {
Currency string `json:"currency"`
Balance float64 `json:"balance"`
}
// CNProviderBalanceResult 是余额探测的返回结构(管理端 + UI 消费)。
type CNProviderBalanceResult struct {
Provider string `json:"provider"`
Success bool `json:"success"`
// Balance/Currency 为主币种(balance_infos 首条,兼容单币种消费方);
// 完整明细见 Balances(deepseek 双币种账号含 CNY + USD 两条)。
Balance float64 `json:"balance"`
Currency string `json:"currency,omitempty"`
Balances []CNProviderBalanceEntry `json:"balances,omitempty"`
Available bool `json:"available"` // 健康标记(deepseek is_available;kimi 无此概念恒 true)
StatusCode int `json:"status_code,omitempty"`
FetchedAt int64 `json:"fetched_at"`
Persisted bool `json:"persisted"`
Error string `json:"error,omitempty"`
}
// CNProviderBalanceService 探测 Kimi / DeepSeek payg 账号的账户余额。
type CNProviderBalanceService struct {
accountRepo AccountRepository
proxyRepo ProxyRepository
httpUpstream HTTPUpstream
cfg *config.Config
flight singleflight.Group
}
// NewCNProviderBalanceService 构造余额探测服务。
func NewCNProviderBalanceService(
accountRepo AccountRepository,
proxyRepo ProxyRepository,
httpUpstream HTTPUpstream,
cfg *config.Config,
) *CNProviderBalanceService {
return &CNProviderBalanceService{
accountRepo: accountRepo,
proxyRepo: proxyRepo,
httpUpstream: httpUpstream,
cfg: cfg,
}
}
// QueryBalance 探测指定 payg 账号的余额并落 Extra 快照。
func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) {
account, err := s.loadPayGAccount(ctx, accountID)
if err != nil {
return nil, err
}
return s.QueryBalanceForAccount(ctx, account)
}
// QueryBalanceForAccount 探测已加载账号(配额监控 fetcher / 周期余额检测复用,
// 避免二次 GetByID)。singleflight key 与 QueryBalance 相同,按账号 ID 合并。
func (s *CNProviderBalanceService) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
if s == nil || s.accountRepo == nil || s.httpUpstream == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_NOT_CONFIGURED", "cn provider balance service is not configured")
}
if err := validatePayGAccount(account); err != nil {
return nil, err
}
key := "cn_balance:" + strconv.FormatInt(account.ID, 10)
resultCh := s.flight.DoChan(key, func() (any, error) {
probeCtx, cancel := context.WithTimeout(context.Background(), cnBalanceUpstreamTimeout+5*time.Second)
defer cancel()
return s.queryBalanceForAccount(probeCtx, account)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case flightResult := <-resultCh:
if flightResult.Err != nil {
return nil, flightResult.Err
}
result, ok := flightResult.Val.(*CNProviderBalanceResult)
if !ok || result == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_PROBE_RESULT_INVALID", "invalid cn provider balance probe result")
}
cloned := *result
return &cloned, nil
}
}
func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) {
if account.IsCommandCode() {
return s.queryCommandCodeBalance(ctx, account)
}
if account.IsCline() {
return s.queryClineBalance(ctx, account)
}
provider := account.Platform
if provider != PlatformKimi && provider != PlatformDeepseek {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint")
}
apiKey := strings.TrimSpace(account.GetCNAPIKey())
if apiKey == "" {
return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_APIKEY", "account api_key is empty")
}
targetURL := cnBalanceURL(account)
// 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
// DeepSeek 端点由账号 base_url 衍生,不得把 API key 发往策略外主机。
validatedURL, err := cnValidateProbeURL(s.cfg, targetURL)
if err != nil {
return nil, infraerrors.New(http.StatusForbidden, "CN_BALANCE_URL_REJECTED", err.Error())
}
targetURL = validatedURL
proxyURL := s.resolveProxyURL(ctx, account)
callCtx, cancel := context.WithTimeout(ctx, cnBalanceUpstreamTimeout)
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "CN_BALANCE_REQUEST_BUILD_FAILED", "build request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
account.ApplyHeaderOverrides(req.Header)
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1))
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "CN_BALANCE_REQUEST_FAILED", "upstream request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, cnBalanceMaxBodyBytes))
now := time.Now().UTC()
result := &CNProviderBalanceResult{
Provider: provider,
FetchedAt: now.Unix(),
StatusCode: resp.StatusCode,
Available: true,
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
result.Error = fmt.Sprintf("Authentication failed (HTTP %d)", resp.StatusCode)
return result, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Error = fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, truncate(strings.TrimSpace(string(bodyBytes)), 240))
return result, nil
}
var entries []CNProviderBalanceEntry
available := true
switch provider {
case PlatformKimi:
// Moonshot:code==0 成功;data.available_balance(number),单币种 CNY。
balance, _ := cnParseF64(gjson.GetBytes(bodyBytes, "data.available_balance").Value())
entries = append(entries, CNProviderBalanceEntry{Currency: "CNY", Balance: balance})
case PlatformDeepseek:
// is_available 缺省视为 true(健康);显式存在时取其值。
if v := gjson.GetBytes(bodyBytes, "is_available"); v.Exists() {
available = v.Bool()
}
// balance_infos 逐条解析:双币种账号同时返回 CNY + USD(数组顺序即
// 主次序,首条为主币种)。
balanceInfos := gjson.GetBytes(bodyBytes, "balance_infos")
if !balanceInfos.Exists() || !balanceInfos.IsArray() {
result.Error = "Invalid balance response: missing balance_infos"
return result, nil
}
balanceInfos.ForEach(func(_, info gjson.Result) bool {
currency := strings.ToUpper(strings.TrimSpace(info.Get("currency").String()))
totalBalance := info.Get("total_balance")
balance, ok := cnParseF64(totalBalance.Value())
if !totalBalance.Exists() || !ok {
return true
}
if currency == "" {
currency = "CNY"
}
entries = append(entries, CNProviderBalanceEntry{Currency: currency, Balance: balance})
return true
})
if len(entries) == 0 {
result.Error = "Invalid balance response: no valid balance entries"
return result, nil
}
}
result.Balances = entries
result.Balance = entries[0].Balance
result.Currency = entries[0].Currency
result.Available = available
result.Success = true
updates := cnBalanceExtraUpdates(provider, result, now)
if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
slog.Warn("cn_balance_persist_failed", "account_id", account.ID, "provider", provider, "error", err)
} else {
result.Persisted = true
}
return result, nil
}
// cnBalanceExtraUpdates 构造 provider 维度的余额快照更新。
func cnBalanceExtraUpdates(provider string, result *CNProviderBalanceResult, now time.Time) map[string]any {
balanceUpdates := make([]any, 0, len(result.Balances))
for _, entry := range result.Balances {
balanceUpdates = append(balanceUpdates, map[string]any{
"currency": entry.Currency,
"balance": entry.Balance,
})
}
return map[string]any{
cnExtraKey(provider, cnBalanceExtraSuffixBalance): result.Balance,
cnExtraKey(provider, cnBalanceExtraSuffixCurrency): result.Currency,
cnExtraKey(provider, cnBalanceExtraSuffixAvailable): result.Available,
cnExtraKey(provider, cnBalanceExtraSuffixUpdated): now.Format(time.RFC3339),
cnExtraKey(provider, cnBalanceExtraSuffixBalances): balanceUpdates,
// 余额探测成功即清除响应式 402/429 写下的 balance_low 标记。
cnExtraKey(provider, cnBalanceExtraSuffixLow): false,
}
}
// loadPayGAccount 加载 payg 模式的国产供应商账号(余额仅对 payg 有意义;coding 走额度)。
func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountID int64) (*Account, error) {
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return nil, infraerrors.Newf(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found: %v", err)
}
if err := validatePayGAccount(account); err != nil {
return nil, err
}
return account, nil
}
// validatePayGAccount 加载后的非 DB 校验(ForAccount 入口同样复用,
// 保证直传 account 也不绕过平台/模式检查)。
func validatePayGAccount(account *Account) error {
if account == nil {
return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found")
}
// Cline 积分余额只查官方主机。
if account.IsCline() {
if !account.clineAccountAPISupported() {
return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "cline balance is only available for api key accounts on the official host")
}
return nil
}
// Command Code 积分余额与套餐窗口同源,只查官方主机。
if account.IsCommandCode() {
if !account.commandCodeUsageSupported() {
return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "command code balance is only available for api key accounts on the official host")
}
return nil
}
if !account.IsCNProvider() {
return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account")
}
// coding 账号走额度探测,余额端点不适用。
if account.IsCodingPlan() {
return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe")
}
return nil
}
func (s *CNProviderBalanceService) resolveProxyURL(ctx context.Context, account *Account) string {
if account == nil || account.ProxyID == nil {
return ""
}
if account.Proxy != nil {
return account.Proxy.URL()
}
if s != nil && s.proxyRepo != nil {
if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil {
account.Proxy = proxy
return proxy.URL()
}
}
return ""
}
// cnBalanceURL 解析账号的余额端点。
//
// - Kimi:固定 https://api.moonshot.cn/v1/users/me/balance(与 base_url 无关,Moonshot 仅此一处)
// - DeepSeek:基于 base_url 拼接 /user/balance(支持自定义域名)
func cnBalanceURL(account *Account) string {
switch account.Platform {
case PlatformKimi:
return "https://api.moonshot.cn/v1/users/me/balance"
case PlatformDeepseek:
// Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,余额探测需回退
// 到 OpenAI 格式 base(协议感知)再拼接 /user/balance。
return strings.TrimRight(account.GetOpenAIFormatBaseURL(), "/") + "/user/balance"
default:
return ""
}
}