| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "regexp"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
- )
- const (
- forbiddenTypeValidation = "validation"
- forbiddenTypeViolation = "violation"
- forbiddenTypeForbidden = "forbidden"
- // 机器可读的错误码
- errorCodeForbidden = "forbidden"
- errorCodeUnauthenticated = "unauthenticated"
- errorCodeRateLimited = "rate_limited"
- errorCodeNetworkError = "network_error"
- )
- // AntigravityQuotaFetcher 从 Antigravity API 获取额度
- type AntigravityQuotaFetcher struct {
- proxyRepo ProxyRepository
- cfg *config.Config
- }
- // NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher
- func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher {
- return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg}
- }
- // CanFetch 检查是否可以获取此账户的额度
- func (f *AntigravityQuotaFetcher) CanFetch(account *Account) bool {
- if account.Platform != PlatformAntigravity {
- return false
- }
- accessToken := account.GetCredential("access_token")
- return accessToken != ""
- }
- // FetchQuota 获取 Antigravity 账户额度信息
- func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Account, proxyURL string) (*QuotaResult, error) {
- accessToken := account.GetCredential("access_token")
- projectID := account.GetCredential("project_id")
- client, err := antigravity.NewClient(proxyURL)
- if err != nil {
- return nil, fmt.Errorf("create antigravity client failed: %w", err)
- }
- // 调用 API 获取配额
- modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg))
- if err != nil {
- // 403 Forbidden: 不报错,返回 is_forbidden 标记
- var forbiddenErr *antigravity.ForbiddenError
- if errors.As(err, &forbiddenErr) {
- now := time.Now()
- fbType := classifyForbiddenType(forbiddenErr.Body)
- return &QuotaResult{
- UsageInfo: &UsageInfo{
- UpdatedAt: &now,
- IsForbidden: true,
- ForbiddenReason: forbiddenErr.Body,
- ForbiddenType: fbType,
- ValidationURL: extractValidationURL(forbiddenErr.Body),
- NeedsVerify: fbType == forbiddenTypeValidation,
- IsBanned: fbType == forbiddenTypeViolation,
- ErrorCode: errorCodeForbidden,
- },
- }, nil
- }
- return nil, err
- }
- // 调用 LoadCodeAssist 获取订阅等级和 AI Credits 余额(非关键路径,失败不影响主流程)
- tierRaw, tierNormalized, loadResp := f.fetchSubscriptionTier(ctx, client, accessToken)
- // 转换为 UsageInfo
- usageInfo := f.buildUsageInfo(modelsResp, tierRaw, tierNormalized, loadResp)
- return &QuotaResult{
- UsageInfo: usageInfo,
- Raw: modelsRaw,
- }, nil
- }
- // fetchSubscriptionTier 获取账号订阅等级,失败返回空字符串。
- // 同时返回 LoadCodeAssistResponse,以便提取 AI Credits 余额。
- func (f *AntigravityQuotaFetcher) fetchSubscriptionTier(ctx context.Context, client *antigravity.Client, accessToken string) (raw, normalized string, loadResp *antigravity.LoadCodeAssistResponse) {
- loadResp, _, err := client.LoadCodeAssist(ctx, accessToken)
- if err != nil {
- slog.Warn("failed to fetch subscription tier", "error", err)
- return "", "", nil
- }
- if loadResp == nil {
- return "", "", nil
- }
- raw = loadResp.GetTier() // 已有方法:paidTier > currentTier
- normalized = normalizeTier(raw)
- return raw, normalized, loadResp
- }
- // normalizeTier 将原始 tier 字符串归一化为 FREE/PRO/ULTRA/UNKNOWN
- func normalizeTier(raw string) string {
- if raw == "" {
- return ""
- }
- lower := strings.ToLower(raw)
- switch {
- case strings.Contains(lower, "ultra"):
- return "ULTRA"
- case strings.Contains(lower, "pro"):
- return "PRO"
- case strings.Contains(lower, "free"):
- return "FREE"
- default:
- return "UNKNOWN"
- }
- }
- // buildUsageInfo 将 API 响应转换为 UsageInfo。
- func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse, tierRaw, tierNormalized string, loadResp *antigravity.LoadCodeAssistResponse) *UsageInfo {
- now := time.Now()
- info := &UsageInfo{
- UpdatedAt: &now,
- AntigravityQuota: make(map[string]*AntigravityModelQuota),
- AntigravityQuotaDetails: make(map[string]*AntigravityModelDetail),
- SubscriptionTier: tierNormalized,
- SubscriptionTierRaw: tierRaw,
- }
- // 遍历所有模型,填充 AntigravityQuota 和 AntigravityQuotaDetails
- for modelName, modelInfo := range modelsResp.Models {
- if modelInfo.QuotaInfo == nil {
- continue
- }
- // remainingFraction 是剩余比例 (0.0-1.0),转换为使用率百分比
- utilization := int((1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100)
- info.AntigravityQuota[modelName] = &AntigravityModelQuota{
- Utilization: utilization,
- ResetTime: modelInfo.QuotaInfo.ResetTime,
- }
- // 填充模型详细能力信息
- detail := &AntigravityModelDetail{
- DisplayName: modelInfo.DisplayName,
- SupportsImages: modelInfo.SupportsImages,
- SupportsThinking: modelInfo.SupportsThinking,
- ThinkingBudget: modelInfo.ThinkingBudget,
- Recommended: modelInfo.Recommended,
- MaxTokens: modelInfo.MaxTokens,
- MaxOutputTokens: modelInfo.MaxOutputTokens,
- SupportedMimeTypes: modelInfo.SupportedMimeTypes,
- }
- info.AntigravityQuotaDetails[modelName] = detail
- }
- // 废弃模型转发规则
- if len(modelsResp.DeprecatedModelIDs) > 0 {
- info.ModelForwardingRules = make(map[string]string, len(modelsResp.DeprecatedModelIDs))
- for oldID, deprecated := range modelsResp.DeprecatedModelIDs {
- info.ModelForwardingRules[oldID] = deprecated.NewModelID
- }
- }
- // 同时设置 FiveHour 用于兼容展示(取主要模型)
- priorityModels := []string{"claude-sonnet-4-20250514", "claude-sonnet-4", "gemini-2.5-pro"}
- for _, modelName := range priorityModels {
- if modelInfo, ok := modelsResp.Models[modelName]; ok && modelInfo.QuotaInfo != nil {
- utilization := (1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100
- progress := &UsageProgress{
- Utilization: utilization,
- }
- if modelInfo.QuotaInfo.ResetTime != "" {
- if resetTime, err := time.Parse(time.RFC3339, modelInfo.QuotaInfo.ResetTime); err == nil {
- progress.ResetsAt = &resetTime
- progress.RemainingSeconds = int(time.Until(resetTime).Seconds())
- }
- }
- info.FiveHour = progress
- break
- }
- }
- if loadResp != nil {
- for _, credit := range loadResp.GetAvailableCredits() {
- info.AICredits = append(info.AICredits, AICredit{
- CreditType: credit.CreditType,
- Amount: credit.GetAmount(),
- MinimumBalance: credit.GetMinimumAmount(),
- })
- }
- }
- return info
- }
- // GetProxyURL 获取账户的代理 URL
- func (f *AntigravityQuotaFetcher) GetProxyURL(ctx context.Context, account *Account) string {
- if account.ProxyID == nil || f.proxyRepo == nil {
- return ""
- }
- proxy, err := f.proxyRepo.GetByID(ctx, *account.ProxyID)
- if err != nil || proxy == nil {
- return ""
- }
- return proxy.URL()
- }
- // classifyForbiddenType 根据 403 响应体判断禁止类型
- func classifyForbiddenType(body string) string {
- lower := strings.ToLower(body)
- switch {
- case strings.Contains(lower, "validation_required") ||
- strings.Contains(lower, "verify your account") ||
- strings.Contains(lower, "validation_url"):
- return forbiddenTypeValidation
- case strings.Contains(lower, "terms of service") ||
- strings.Contains(lower, "violation"):
- return forbiddenTypeViolation
- default:
- return forbiddenTypeForbidden
- }
- }
- // urlPattern 用于从 403 响应体中提取 URL(降级方案)
- var urlPattern = regexp.MustCompile(`https://[^\s"'\\]+`)
- // extractValidationURL 从 403 响应 JSON 中提取验证/申诉链接
- func extractValidationURL(body string) string {
- // 1. 尝试结构化 JSON 提取: /error/details[*]/metadata/validation_url 或 appeal_url
- var parsed struct {
- Error struct {
- Details []struct {
- Metadata map[string]string `json:"metadata"`
- } `json:"details"`
- } `json:"error"`
- }
- if json.Unmarshal([]byte(body), &parsed) == nil {
- for _, detail := range parsed.Error.Details {
- if u := detail.Metadata["validation_url"]; u != "" {
- return u
- }
- if u := detail.Metadata["appeal_url"]; u != "" {
- return u
- }
- }
- }
- // 2. 降级:正则匹配 URL
- lower := strings.ToLower(body)
- if !strings.Contains(lower, "validation") &&
- !strings.Contains(lower, "verify") &&
- !strings.Contains(lower, "appeal") {
- return ""
- }
- // 先解码常见转义再匹配
- normalized := strings.ReplaceAll(body, `\u0026`, "&")
- if m := urlPattern.FindString(normalized); m != "" {
- return m
- }
- return ""
- }
|