feat(antigravity): 自动设置隐私并支持后台手动重试

新增 Antigravity OAuth 隐私设置能力,在账号创建、刷新、导入和后台
Token 刷新路径自动调用 setUserSettings + fetchUserInfo 关闭遥测;
持久化后同步内存 Extra,错误处理改为日志记录。

Made-with: Cursor
This commit is contained in:
QTom
2026-03-25 13:05:47 +08:00
parent 0f03393010
commit c2965c0fb0
15 changed files with 512 additions and 8 deletions

View File

@@ -267,6 +267,9 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
}
}
// 收集需要异步设置隐私的 Antigravity OAuth 账号
var privacyAccounts []*service.Account
for i := range dataPayload.Accounts {
item := dataPayload.Accounts[i]
if err := validateDataAccount(item); err != nil {
@@ -314,7 +317,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
SkipDefaultGroupBind: skipDefaultGroupBind,
}
if _, err := h.adminService.CreateAccount(ctx, accountInput); err != nil {
created, err := h.adminService.CreateAccount(ctx, accountInput)
if err != nil {
result.AccountFailed++
result.Errors = append(result.Errors, DataImportError{
Kind: "account",
@@ -323,9 +327,30 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
})
continue
}
// 收集 Antigravity OAuth 账号,稍后异步设置隐私
if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
privacyAccounts = append(privacyAccounts, created)
}
result.AccountCreated++
}
// 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
if len(privacyAccounts) > 0 {
adminSvc := h.adminService
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("import_antigravity_privacy_panic", "recover", r)
}
}()
bgCtx := context.Background()
for _, acc := range privacyAccounts {
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
}
slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
}()
}
return result, nil
}

View File

@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -536,6 +537,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
if execErr != nil {
return nil, execErr
}
// Antigravity OAuth: 新账号直接设置隐私
h.adminService.ForceAntigravityPrivacy(ctx, account)
return h.buildAccountResponseWithRuntime(ctx, account), nil
})
if err != nil {
@@ -883,6 +886,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
// Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
return updatedAccount, "", nil
}
@@ -1154,6 +1159,8 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
success := 0
failed := 0
results := make([]gin.H, 0, len(req.Accounts))
// 收集需要异步设置隐私的 Antigravity OAuth 账号
var privacyAccounts []*service.Account
for _, item := range req.Accounts {
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
@@ -1196,6 +1203,10 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
continue
}
// 收集 Antigravity OAuth 账号,稍后异步设置隐私
if account.Platform == service.PlatformAntigravity && account.Type == service.AccountTypeOAuth {
privacyAccounts = append(privacyAccounts, account)
}
success++
results = append(results, gin.H{
"name": item.Name,
@@ -1204,6 +1215,22 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
}
// 异步设置 Antigravity 隐私,避免批量创建时阻塞请求
if len(privacyAccounts) > 0 {
adminSvc := h.adminService
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
}
}()
bgCtx := context.Background()
for _, acc := range privacyAccounts {
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
}
}()
}
return gin.H{
"success": success,
"failed": failed,
@@ -1869,6 +1896,42 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
response.Success(c, models)
}
// SetPrivacy handles setting privacy for a single Antigravity OAuth account
// POST /api/v1/admin/accounts/:id/set-privacy
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.NotFound(c, "Account not found")
return
}
if account.Platform != service.PlatformAntigravity || account.Type != service.AccountTypeOAuth {
response.BadRequest(c, "Only Antigravity OAuth accounts support privacy setting")
return
}
mode := h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
if mode == "" {
response.BadRequest(c, "Cannot set privacy: missing access_token")
return
}
// 从 DB 重新读取以确保返回最新状态
updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
// 隐私已设置成功但读取失败,回退到内存更新
if account.Extra == nil {
account.Extra = make(map[string]any)
}
account.Extra["privacy_mode"] = mode
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
return
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
}
// RefreshTier handles refreshing Google One tier for a single account
// POST /api/v1/admin/accounts/:id/refresh-tier
func (h *AccountHandler) RefreshTier(c *gin.Context) {

View File

@@ -445,6 +445,14 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
return ""
}
func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account *service.Account) string {
return ""
}
func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string {
return ""
}
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
}

View File

@@ -704,3 +704,139 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return nil, nil, lastErr
}
// ── Privacy API ──────────────────────────────────────────────────────
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
const privacyBaseURL = antigravityDailyBaseURL
// SetUserSettingsRequest setUserSettings 请求体
type SetUserSettingsRequest struct {
UserSettings map[string]any `json:"user_settings"`
}
// FetchUserInfoRequest fetchUserInfo 请求体
type FetchUserInfoRequest struct {
Project string `json:"project"`
}
// FetchUserInfoResponse fetchUserInfo 响应体
type FetchUserInfoResponse struct {
UserSettings map[string]any `json:"userSettings,omitempty"`
RegionCode string `json:"regionCode,omitempty"`
}
// IsPrivate 判断隐私是否已设置userSettings 为空或不含 telemetryEnabled 表示已设置
func (r *FetchUserInfoResponse) IsPrivate() bool {
if r == nil || r.UserSettings == nil {
return true
}
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
return !hasTelemetry
}
// SetUserSettingsResponse setUserSettings 响应体
type SetUserSettingsResponse struct {
UserSettings map[string]any `json:"userSettings,omitempty"`
}
// IsSuccess 判断 setUserSettings 是否成功:返回 {"userSettings":{}} 且无 telemetryEnabled
func (r *SetUserSettingsResponse) IsSuccess() bool {
if r == nil {
return false
}
// userSettings 为 nil 或空 map 均视为成功
if r.UserSettings == nil || len(r.UserSettings) == 0 {
return true
}
// 如果包含 telemetryEnabled 字段,说明未成功清除
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
return !hasTelemetry
}
// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
// 发送空 user_settings 以清除隐私设置
payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
apiURL := privacyBaseURL + "/v1internal:setUserSettings"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
req.Host = "daily-cloudcode-pa.googleapis.com"
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("setUserSettings 请求失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("setUserSettings 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result SetUserSettingsResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("响应解析失败: %w", err)
}
return &result, nil
}
// FetchUserInfo 调用 fetchUserInfo API 获取用户隐私设置状态
func (c *Client) FetchUserInfo(ctx context.Context, accessToken, projectID string) (*FetchUserInfoResponse, error) {
reqBody := FetchUserInfoRequest{Project: projectID}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
apiURL := privacyBaseURL + "/v1internal:fetchUserInfo"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", GetUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
req.Host = "daily-cloudcode-pa.googleapis.com"
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetchUserInfo 请求失败: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetchUserInfo 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result FetchUserInfoResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("响应解析失败: %w", err)
}
return &result, nil
}

View File

@@ -257,6 +257,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy)
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)

View File

@@ -65,6 +65,10 @@ type AdminService interface {
SetAccountError(ctx context.Context, id int64, errorMsg string) error
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode未设置则尝试关闭训练数据共享并持久化。
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode未设置则调用 setUserSettings 并持久化。
EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
ForceAntigravityPrivacy(ctx context.Context, account *Account) string
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
@@ -2661,3 +2665,79 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
return mode
}
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
// 用户可通过前端 ForceAntigravityPrivacySetPrivacy 按钮)强制重新设置。
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
// 已设置过则跳过(无论成功或失败),用户可通过 Force 手动重试
if account.Extra != nil {
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
return existing
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}

View File

@@ -0,0 +1,81 @@
package service
import (
"context"
"log/slog"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const (
AntigravityPrivacySet = "privacy_set"
AntigravityPrivacyFailed = "privacy_set_failed"
)
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
// 流程:
// 1. setUserSettings 清空设置 → 检查返回值 {"userSettings":{}}
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id
//
// 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
if accessToken == "" {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := antigravity.NewClient(proxyURL)
if err != nil {
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
return AntigravityPrivacyFailed
}
// 第 1 步:调用 setUserSettings检查返回值
setResp, err := client.SetUserSettings(ctx, accessToken)
if err != nil {
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !setResp.IsSuccess() {
slog.Warn("antigravity_privacy_set_response_not_empty",
"user_settings", setResp.UserSettings,
)
return AntigravityPrivacyFailed
}
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
if strings.TrimSpace(projectID) == "" {
slog.Warn("antigravity_privacy_missing_project_id")
return AntigravityPrivacyFailed
}
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
if err != nil {
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !userInfo.IsPrivate() {
slog.Warn("antigravity_privacy_verify_not_private",
"user_settings", userInfo.UserSettings,
)
return AntigravityPrivacyFailed
}
slog.Info("antigravity_privacy_set_success")
return AntigravityPrivacySet
}
func applyAntigravityPrivacyMode(account *Account, mode string) {
if account == nil || strings.TrimSpace(mode) == "" {
return
}
extra := make(map[string]any, len(account.Extra)+1)
for k, v := range account.Extra {
extra[k] = v
}
extra["privacy_mode"] = mode
account.Extra = extra
}

View File

@@ -0,0 +1,18 @@
//go:build unit
package service
import "testing"
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
account := &Account{}
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
if account.Extra == nil {
t.Fatal("expected account.Extra to be initialized")
}
if got := account.Extra["privacy_mode"]; got != AntigravityPrivacySet {
t.Fatalf("expected privacy_mode %q, got %v", AntigravityPrivacySet, got)
}
}

View File

@@ -128,7 +128,7 @@ func (s *TokenRefreshService) Start() {
)
}
// Stop 停止刷新服务
// Stop 停止刷新服务(可安全多次调用)
func (s *TokenRefreshService) Stop() {
close(s.stopCh)
s.wg.Wait()
@@ -404,6 +404,8 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A
}
// OpenAI OAuth: 刷新成功后,检查是否已设置 privacy_mode未设置则尝试关闭训练数据共享
s.ensureOpenAIPrivacy(ctx, account)
// Antigravity OAuth: 刷新成功后,检查是否已设置 privacy_mode未设置则调用 setUserSettings
s.ensureAntigravityPrivacy(ctx, account)
}
// errRefreshSkipped 表示刷新被跳过(锁竞争或已被其他路径刷新),不计入 failed 或 refreshed
@@ -477,3 +479,51 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
)
}
}
// ensureAntigravityPrivacy 后台刷新中检查 Antigravity OAuth 账号隐私状态。
// 仅做 Extra["privacy_mode"] 存在性检查,不发起 HTTP 请求,避免每轮循环产生额外网络开销。
// 用户可通过前端 SetPrivacy 按钮强制重新设置。
func (s *TokenRefreshService) ensureAntigravityPrivacy(ctx context.Context, account *Account) {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return
}
// 已设置过(无论成功或失败)则跳过,不发 HTTP
if account.Extra != nil {
if _, ok := account.Extra["privacy_mode"]; ok {
return
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil && s.proxyRepo != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
slog.Warn("token_refresh.update_antigravity_privacy_mode_failed",
"account_id", account.ID,
"error", err,
)
} else {
applyAntigravityPrivacyMode(account, mode)
slog.Info("token_refresh.antigravity_privacy_mode_set",
"account_id", account.ID,
"privacy_mode", mode,
)
}
}