antigravity_oauth_service.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
  9. )
  10. type AntigravityOAuthService struct {
  11. sessionStore *antigravity.SessionStore
  12. proxyRepo ProxyRepository
  13. }
  14. func NewAntigravityOAuthService(proxyRepo ProxyRepository) *AntigravityOAuthService {
  15. return &AntigravityOAuthService{
  16. sessionStore: antigravity.NewSessionStore(),
  17. proxyRepo: proxyRepo,
  18. }
  19. }
  20. // AntigravityAuthURLResult is the result of generating an authorization URL
  21. type AntigravityAuthURLResult struct {
  22. AuthURL string `json:"auth_url"`
  23. SessionID string `json:"session_id"`
  24. State string `json:"state"`
  25. }
  26. // GenerateAuthURL 生成 Google OAuth 授权链接
  27. func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*AntigravityAuthURLResult, error) {
  28. state, err := antigravity.GenerateState()
  29. if err != nil {
  30. return nil, fmt.Errorf("生成 state 失败: %w", err)
  31. }
  32. codeVerifier, err := antigravity.GenerateCodeVerifier()
  33. if err != nil {
  34. return nil, fmt.Errorf("生成 code_verifier 失败: %w", err)
  35. }
  36. sessionID, err := antigravity.GenerateSessionID()
  37. if err != nil {
  38. return nil, fmt.Errorf("生成 session_id 失败: %w", err)
  39. }
  40. var proxyURL string
  41. if proxyID != nil {
  42. proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
  43. if err == nil && proxy != nil {
  44. proxyURL = proxy.URL()
  45. }
  46. }
  47. session := &antigravity.OAuthSession{
  48. State: state,
  49. CodeVerifier: codeVerifier,
  50. ProxyURL: proxyURL,
  51. CreatedAt: time.Now(),
  52. }
  53. s.sessionStore.Set(sessionID, session)
  54. codeChallenge := antigravity.GenerateCodeChallenge(codeVerifier)
  55. authURL := antigravity.BuildAuthorizationURL(state, codeChallenge)
  56. return &AntigravityAuthURLResult{
  57. AuthURL: authURL,
  58. SessionID: sessionID,
  59. State: state,
  60. }, nil
  61. }
  62. // AntigravityExchangeCodeInput 交换 code 的输入
  63. type AntigravityExchangeCodeInput struct {
  64. SessionID string
  65. State string
  66. Code string
  67. ProxyID *int64
  68. }
  69. // AntigravityTokenInfo token 信息
  70. type AntigravityTokenInfo struct {
  71. AccessToken string `json:"access_token"`
  72. RefreshToken string `json:"refresh_token"`
  73. ExpiresIn int64 `json:"expires_in"`
  74. ExpiresAt int64 `json:"expires_at"`
  75. TokenType string `json:"token_type"`
  76. Email string `json:"email,omitempty"`
  77. ProjectID string `json:"project_id,omitempty"`
  78. ProjectIDMissing bool `json:"-"`
  79. PlanType string `json:"plan_type,omitempty"`
  80. PrivacyMode string `json:"-"`
  81. }
  82. // ExchangeCode 用 authorization code 交换 token
  83. func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *AntigravityExchangeCodeInput) (*AntigravityTokenInfo, error) {
  84. session, ok := s.sessionStore.Get(input.SessionID)
  85. if !ok {
  86. return nil, fmt.Errorf("session 不存在或已过期")
  87. }
  88. if strings.TrimSpace(input.State) == "" || input.State != session.State {
  89. return nil, fmt.Errorf("state 无效")
  90. }
  91. // 确定代理 URL
  92. proxyURL := session.ProxyURL
  93. if input.ProxyID != nil {
  94. proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
  95. if err == nil && proxy != nil {
  96. proxyURL = proxy.URL()
  97. }
  98. }
  99. client, err := antigravity.NewClient(proxyURL)
  100. if err != nil {
  101. return nil, fmt.Errorf("create antigravity client failed: %w", err)
  102. }
  103. // 交换 token
  104. tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier)
  105. if err != nil {
  106. return nil, fmt.Errorf("token 交换失败: %w", err)
  107. }
  108. // 删除 session
  109. s.sessionStore.Delete(input.SessionID)
  110. // 计算过期时间(减去 5 分钟安全窗口)
  111. expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
  112. result := &AntigravityTokenInfo{
  113. AccessToken: tokenResp.AccessToken,
  114. RefreshToken: tokenResp.RefreshToken,
  115. ExpiresIn: tokenResp.ExpiresIn,
  116. ExpiresAt: expiresAt,
  117. TokenType: tokenResp.TokenType,
  118. }
  119. // 获取用户信息
  120. userInfo, err := client.GetUserInfo(ctx, tokenResp.AccessToken)
  121. if err != nil {
  122. fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
  123. } else {
  124. result.Email = userInfo.Email
  125. }
  126. // 获取 project_id + plan_type(部分账户类型可能没有),失败时重试
  127. loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
  128. if loadErr != nil {
  129. fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
  130. result.ProjectIDMissing = true
  131. }
  132. if loadResult != nil {
  133. result.ProjectID = loadResult.ProjectID
  134. if loadResult.Subscription != nil {
  135. result.PlanType = loadResult.Subscription.PlanType
  136. }
  137. }
  138. // 令牌刚获取,立即设置隐私(不依赖后续账号创建流程)
  139. result.PrivacyMode = setAntigravityPrivacy(ctx, result.AccessToken, result.ProjectID, proxyURL)
  140. return result, nil
  141. }
  142. // RefreshToken 刷新 token
  143. func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*AntigravityTokenInfo, error) {
  144. var lastErr error
  145. for attempt := 0; attempt <= 3; attempt++ {
  146. if attempt > 0 {
  147. backoff := time.Duration(1<<uint(attempt-1)) * time.Second
  148. if backoff > 30*time.Second {
  149. backoff = 30 * time.Second
  150. }
  151. time.Sleep(backoff)
  152. }
  153. client, err := antigravity.NewClient(proxyURL)
  154. if err != nil {
  155. return nil, fmt.Errorf("create antigravity client failed: %w", err)
  156. }
  157. tokenResp, err := client.RefreshToken(ctx, refreshToken)
  158. if err == nil {
  159. now := time.Now()
  160. expiresAt := now.Unix() + tokenResp.ExpiresIn - 300
  161. fmt.Printf("[AntigravityOAuth] Token refreshed: expires_in=%d, expires_at=%d (%s)\n",
  162. tokenResp.ExpiresIn, expiresAt, time.Unix(expiresAt, 0).Format("2006-01-02 15:04:05"))
  163. return &AntigravityTokenInfo{
  164. AccessToken: tokenResp.AccessToken,
  165. RefreshToken: tokenResp.RefreshToken,
  166. ExpiresIn: tokenResp.ExpiresIn,
  167. ExpiresAt: expiresAt,
  168. TokenType: tokenResp.TokenType,
  169. }, nil
  170. }
  171. if isNonRetryableAntigravityOAuthError(err) {
  172. return nil, err
  173. }
  174. // 代理连接错误(TCP 超时、连接拒绝、DNS 失败)不重试,立即返回
  175. if antigravity.IsConnectionError(err) {
  176. return nil, fmt.Errorf("proxy unavailable: %w", err)
  177. }
  178. lastErr = err
  179. }
  180. return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
  181. }
  182. // ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id)
  183. func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*AntigravityTokenInfo, error) {
  184. var proxyURL string
  185. if proxyID != nil {
  186. proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
  187. if err == nil && proxy != nil {
  188. proxyURL = proxy.URL()
  189. }
  190. }
  191. // 刷新 token
  192. tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
  193. if err != nil {
  194. return nil, err
  195. }
  196. // 获取用户信息(email)
  197. client, err := antigravity.NewClient(proxyURL)
  198. if err != nil {
  199. return nil, fmt.Errorf("create antigravity client failed: %w", err)
  200. }
  201. userInfo, err := client.GetUserInfo(ctx, tokenInfo.AccessToken)
  202. if err != nil {
  203. fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
  204. } else {
  205. tokenInfo.Email = userInfo.Email
  206. }
  207. // 获取 project_id + plan_type(容错,失败不阻塞)
  208. loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
  209. if loadErr != nil {
  210. fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
  211. tokenInfo.ProjectIDMissing = true
  212. }
  213. if loadResult != nil {
  214. tokenInfo.ProjectID = loadResult.ProjectID
  215. if loadResult.Subscription != nil {
  216. tokenInfo.PlanType = loadResult.Subscription.PlanType
  217. }
  218. }
  219. // 令牌刚获取,立即设置隐私
  220. tokenInfo.PrivacyMode = setAntigravityPrivacy(ctx, tokenInfo.AccessToken, tokenInfo.ProjectID, proxyURL)
  221. return tokenInfo, nil
  222. }
  223. func isNonRetryableAntigravityOAuthError(err error) bool {
  224. msg := err.Error()
  225. nonRetryable := []string{
  226. "invalid_grant",
  227. "invalid_client",
  228. "unauthorized_client",
  229. "access_denied",
  230. }
  231. for _, needle := range nonRetryable {
  232. if strings.Contains(msg, needle) {
  233. return true
  234. }
  235. }
  236. return false
  237. }
  238. // RefreshAccountToken 刷新账户的 token
  239. func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*AntigravityTokenInfo, error) {
  240. if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
  241. return nil, fmt.Errorf("非 Antigravity OAuth 账户")
  242. }
  243. refreshToken := account.GetCredential("refresh_token")
  244. if strings.TrimSpace(refreshToken) == "" {
  245. return nil, fmt.Errorf("无可用的 refresh_token")
  246. }
  247. var proxyURL string
  248. if account.ProxyID != nil {
  249. proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
  250. if err == nil && proxy != nil {
  251. proxyURL = proxy.URL()
  252. }
  253. }
  254. tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
  255. if err != nil {
  256. return nil, err
  257. }
  258. // 保留原有的 email
  259. existingEmail := strings.TrimSpace(account.GetCredential("email"))
  260. if existingEmail != "" {
  261. tokenInfo.Email = existingEmail
  262. }
  263. // 每次刷新都调用 LoadCodeAssist 获取 project_id + plan_type,失败时重试
  264. existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
  265. loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
  266. if loadErr != nil {
  267. tokenInfo.ProjectID = existingProjectID
  268. if existingProjectID == "" {
  269. tokenInfo.ProjectIDMissing = true
  270. }
  271. }
  272. if loadResult != nil {
  273. if loadResult.ProjectID != "" {
  274. tokenInfo.ProjectID = loadResult.ProjectID
  275. }
  276. if loadResult.Subscription != nil {
  277. tokenInfo.PlanType = loadResult.Subscription.PlanType
  278. }
  279. }
  280. return tokenInfo, nil
  281. }
  282. // loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
  283. // 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
  284. type loadCodeAssistResult struct {
  285. ProjectID string
  286. Subscription *AntigravitySubscriptionResult
  287. }
  288. // loadProjectIDWithRetry 带重试机制获取 project_id,同时从响应中提取 plan_type。
  289. func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (*loadCodeAssistResult, error) {
  290. var lastErr error
  291. var lastSubscription *AntigravitySubscriptionResult
  292. for attempt := 0; attempt <= maxRetries; attempt++ {
  293. if attempt > 0 {
  294. backoff := time.Duration(1<<uint(attempt-1)) * time.Second
  295. if backoff > 8*time.Second {
  296. backoff = 8 * time.Second
  297. }
  298. time.Sleep(backoff)
  299. }
  300. client, err := antigravity.NewClient(proxyURL)
  301. if err != nil {
  302. return nil, fmt.Errorf("create antigravity client failed: %w", err)
  303. }
  304. loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
  305. if loadResp != nil {
  306. sub := NormalizeAntigravitySubscription(loadResp)
  307. lastSubscription = &sub
  308. }
  309. if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
  310. return &loadCodeAssistResult{
  311. ProjectID: loadResp.CloudAICompanionProject,
  312. Subscription: lastSubscription,
  313. }, nil
  314. }
  315. if err == nil {
  316. if projectID, onboardErr := tryOnboardProjectID(ctx, client, accessToken, loadRaw); onboardErr == nil && projectID != "" {
  317. return &loadCodeAssistResult{
  318. ProjectID: projectID,
  319. Subscription: lastSubscription,
  320. }, nil
  321. } else if onboardErr != nil {
  322. lastErr = onboardErr
  323. continue
  324. }
  325. }
  326. if err != nil {
  327. lastErr = err
  328. } else if loadResp == nil {
  329. lastErr = fmt.Errorf("LoadCodeAssist 返回空响应")
  330. } else {
  331. lastErr = fmt.Errorf("LoadCodeAssist 返回空 project_id")
  332. }
  333. }
  334. if lastSubscription != nil {
  335. return &loadCodeAssistResult{Subscription: lastSubscription}, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
  336. }
  337. return nil, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
  338. }
  339. func tryOnboardProjectID(ctx context.Context, client *antigravity.Client, accessToken string, loadRaw map[string]any) (string, error) {
  340. tierID := resolveDefaultTierID(loadRaw)
  341. if tierID == "" {
  342. return "", fmt.Errorf("loadCodeAssist 未返回可用的默认 tier")
  343. }
  344. projectID, err := client.OnboardUser(ctx, accessToken, tierID)
  345. if err != nil {
  346. return "", fmt.Errorf("onboardUser 失败 (tier=%s): %w", tierID, err)
  347. }
  348. return projectID, nil
  349. }
  350. func resolveDefaultTierID(loadRaw map[string]any) string {
  351. if len(loadRaw) == 0 {
  352. return ""
  353. }
  354. rawTiers, ok := loadRaw["allowedTiers"]
  355. if !ok {
  356. return ""
  357. }
  358. tiers, ok := rawTiers.([]any)
  359. if !ok {
  360. return ""
  361. }
  362. for _, rawTier := range tiers {
  363. tier, ok := rawTier.(map[string]any)
  364. if !ok {
  365. continue
  366. }
  367. if isDefault, _ := tier["isDefault"].(bool); !isDefault {
  368. continue
  369. }
  370. if id, ok := tier["id"].(string); ok {
  371. id = strings.TrimSpace(id)
  372. if id != "" {
  373. return id
  374. }
  375. }
  376. }
  377. return ""
  378. }
  379. // FillProjectID 仅获取 project_id,不刷新 OAuth token
  380. func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Account, accessToken string) (string, error) {
  381. var proxyURL string
  382. if account.ProxyID != nil {
  383. proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
  384. if err == nil && proxy != nil {
  385. proxyURL = proxy.URL()
  386. }
  387. }
  388. result, err := s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
  389. if result != nil {
  390. return result.ProjectID, err
  391. }
  392. return "", err
  393. }
  394. // BuildAccountCredentials 构建账户凭证
  395. func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *AntigravityTokenInfo) map[string]any {
  396. creds := map[string]any{
  397. "access_token": tokenInfo.AccessToken,
  398. "expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
  399. }
  400. if tokenInfo.RefreshToken != "" {
  401. creds["refresh_token"] = tokenInfo.RefreshToken
  402. }
  403. if tokenInfo.TokenType != "" {
  404. creds["token_type"] = tokenInfo.TokenType
  405. }
  406. if tokenInfo.Email != "" {
  407. creds["email"] = tokenInfo.Email
  408. }
  409. if tokenInfo.ProjectID != "" {
  410. creds["project_id"] = tokenInfo.ProjectID
  411. }
  412. if tokenInfo.PlanType != "" {
  413. creds["plan_type"] = tokenInfo.PlanType
  414. }
  415. return creds
  416. }
  417. // Stop 停止服务
  418. func (s *AntigravityOAuthService) Stop() {
  419. s.sessionStore.Stop()
  420. }