Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
f8b8b539
Unverified
Commit
f8b8b539
authored
Mar 26, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 26, 2026
Browse files
Merge pull request #1299 from DaydreamCoding/feat/antigravity-privacy-and-subscription
feat(antigravity): 自动隐私设置 + 订阅状态检测
parents
b20e1422
975e6b15
Changes
18
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_data.go
View file @
f8b8b539
...
...
@@ -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
}
...
...
backend/internal/handler/admin/account_handler.go
View file @
f8b8b539
...
...
@@ -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
)
{
...
...
backend/internal/handler/admin/admin_service_stub_test.go
View file @
f8b8b539
...
...
@@ -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
}
...
...
backend/internal/pkg/antigravity/client.go
View file @
f8b8b539
...
...
@@ -79,6 +79,8 @@ type UserInfo struct {
type
LoadCodeAssistRequest
struct
{
Metadata
struct
{
IDEType
string
`json:"ideType"`
IDEVersion
string
`json:"ideVersion"`
IDEName
string
`json:"ideName"`
}
`json:"metadata"`
}
...
...
@@ -223,6 +225,23 @@ func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
return
r
.
PaidTier
.
AvailableCredits
}
// TierIDToPlanType 将 tier ID 映射为用户可见的套餐名。
func
TierIDToPlanType
(
tierID
string
)
string
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
tierID
))
{
case
"free-tier"
:
return
"Free"
case
"g1-pro-tier"
:
return
"Pro"
case
"g1-ultra-tier"
:
return
"Ultra"
default
:
if
tierID
==
""
{
return
"Free"
}
return
tierID
}
}
// Client Antigravity API 客户端
type
Client
struct
{
httpClient
*
http
.
Client
...
...
@@ -421,6 +440,8 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
func
(
c
*
Client
)
LoadCodeAssist
(
ctx
context
.
Context
,
accessToken
string
)
(
*
LoadCodeAssistResponse
,
map
[
string
]
any
,
error
)
{
reqBody
:=
LoadCodeAssistRequest
{}
reqBody
.
Metadata
.
IDEType
=
"ANTIGRAVITY"
reqBody
.
Metadata
.
IDEVersion
=
"1.20.6"
reqBody
.
Metadata
.
IDEName
=
"antigravity"
bodyBytes
,
err
:=
json
.
Marshal
(
reqBody
)
if
err
!=
nil
{
...
...
@@ -704,3 +725,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
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
}
backend/internal/pkg/antigravity/client_test.go
View file @
f8b8b539
...
...
@@ -250,6 +250,27 @@ func TestGetTier_两者都为nil(t *testing.T) {
}
}
func
TestTierIDToPlanType
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
tierID
string
want
string
}{
{
"free-tier"
,
"Free"
},
{
"g1-pro-tier"
,
"Pro"
},
{
"g1-ultra-tier"
,
"Ultra"
},
{
"FREE-TIER"
,
"Free"
},
{
""
,
"Free"
},
{
"unknown-tier"
,
"unknown-tier"
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
tierID
,
func
(
t
*
testing
.
T
)
{
if
got
:=
TierIDToPlanType
(
tt
.
tierID
);
got
!=
tt
.
want
{
t
.
Errorf
(
"TierIDToPlanType(%q) = %q, want %q"
,
tt
.
tierID
,
got
,
tt
.
want
)
}
})
}
}
// ---------------------------------------------------------------------------
// NewClient
// ---------------------------------------------------------------------------
...
...
@@ -800,6 +821,12 @@ type redirectRoundTripper struct {
transport
http
.
RoundTripper
}
type
roundTripperFunc
func
(
*
http
.
Request
)
(
*
http
.
Response
,
error
)
func
(
f
roundTripperFunc
)
RoundTrip
(
req
*
http
.
Request
)
(
*
http
.
Response
,
error
)
{
return
f
(
req
)
}
func
(
rt
*
redirectRoundTripper
)
RoundTrip
(
req
*
http
.
Request
)
(
*
http
.
Response
,
error
)
{
originalURL
:=
req
.
URL
.
String
()
for
prefix
,
target
:=
range
rt
.
redirects
{
...
...
@@ -1271,6 +1298,12 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
if
reqBody
.
Metadata
.
IDEType
!=
"ANTIGRAVITY"
{
t
.
Errorf
(
"IDEType 不匹配: got %s, want ANTIGRAVITY"
,
reqBody
.
Metadata
.
IDEType
)
}
if
strings
.
TrimSpace
(
reqBody
.
Metadata
.
IDEVersion
)
==
""
{
t
.
Errorf
(
"IDEVersion 不应为空"
)
}
if
reqBody
.
Metadata
.
IDEName
!=
"antigravity"
{
t
.
Errorf
(
"IDEName 不匹配: got %s, want antigravity"
,
reqBody
.
Metadata
.
IDEName
)
}
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
w
.
WriteHeader
(
http
.
StatusOK
)
...
...
backend/internal/server/routes/admin.go
View file @
f8b8b539
...
...
@@ -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
)
...
...
backend/internal/service/admin_service.go
View file @
f8b8b539
...
...
@@ -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,78 @@ 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 流程。
// 用户可通过前端 ForceAntigravityPrivacy(SetPrivacy 按钮)强制重新设置。
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
}
backend/internal/service/antigravity_oauth_service.go
View file @
f8b8b539
...
...
@@ -89,7 +89,8 @@ type AntigravityTokenInfo struct {
TokenType
string
`json:"token_type"`
Email
string
`json:"email,omitempty"`
ProjectID
string
`json:"project_id,omitempty"`
ProjectIDMissing
bool
`json:"-"`
// LoadCodeAssist 未返回 project_id
ProjectIDMissing
bool
`json:"-"`
PlanType
string
`json:"-"`
}
// ExchangeCode 用 authorization code 交换 token
...
...
@@ -145,13 +146,17 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
result
.
Email
=
userInfo
.
Email
}
// 获取 project_id(部分账户类型可能没有),失败时重试
projectID
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenResp
.
AccessToken
,
proxyURL
,
3
)
// 获取 project_id
+ plan_type
(部分账户类型可能没有),失败时重试
loadResult
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenResp
.
AccessToken
,
proxyURL
,
3
)
if
loadErr
!=
nil
{
fmt
.
Printf
(
"[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v
\n
"
,
loadErr
)
result
.
ProjectIDMissing
=
true
}
else
{
result
.
ProjectID
=
projectID
}
if
loadResult
!=
nil
{
result
.
ProjectID
=
loadResult
.
ProjectID
if
loadResult
.
Subscription
!=
nil
{
result
.
PlanType
=
loadResult
.
Subscription
.
PlanType
}
}
return
result
,
nil
...
...
@@ -230,13 +235,17 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
tokenInfo
.
Email
=
userInfo
.
Email
}
// 获取 project_id(容错,失败不阻塞)
projectID
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenInfo
.
AccessToken
,
proxyURL
,
3
)
// 获取 project_id
+ plan_type
(容错,失败不阻塞)
loadResult
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenInfo
.
AccessToken
,
proxyURL
,
3
)
if
loadErr
!=
nil
{
fmt
.
Printf
(
"[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v
\n
"
,
loadErr
)
tokenInfo
.
ProjectIDMissing
=
true
}
else
{
tokenInfo
.
ProjectID
=
projectID
}
if
loadResult
!=
nil
{
tokenInfo
.
ProjectID
=
loadResult
.
ProjectID
if
loadResult
.
Subscription
!=
nil
{
tokenInfo
.
PlanType
=
loadResult
.
Subscription
.
PlanType
}
}
return
tokenInfo
,
nil
...
...
@@ -288,33 +297,42 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
tokenInfo
.
Email
=
existingEmail
}
// 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试
// 每次刷新都调用 LoadCodeAssist 获取 project_id
+ plan_type
,失败时重试
existingProjectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
projectID
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenInfo
.
AccessToken
,
proxyURL
,
3
)
loadResult
,
loadErr
:=
s
.
loadProjectIDWithRetry
(
ctx
,
tokenInfo
.
AccessToken
,
proxyURL
,
3
)
if
loadErr
!=
nil
{
// LoadCodeAssist 失败,保留原有 project_id
tokenInfo
.
ProjectID
=
existingProjectID
// 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失
// 如果之前有 project_id,本次只是临时故障,不应标记为错误
if
existingProjectID
==
""
{
tokenInfo
.
ProjectIDMissing
=
true
}
}
else
{
tokenInfo
.
ProjectID
=
projectID
}
if
loadResult
!=
nil
{
if
loadResult
.
ProjectID
!=
""
{
tokenInfo
.
ProjectID
=
loadResult
.
ProjectID
}
if
loadResult
.
Subscription
!=
nil
{
tokenInfo
.
PlanType
=
loadResult
.
Subscription
.
PlanType
}
}
return
tokenInfo
,
nil
}
// loadProjectIDWithRetry 带重试机制获取 project_id
// 返回 project_id 和错误,失败时会重试指定次数
func
(
s
*
AntigravityOAuthService
)
loadProjectIDWithRetry
(
ctx
context
.
Context
,
accessToken
,
proxyURL
string
,
maxRetries
int
)
(
string
,
error
)
{
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
type
loadCodeAssistResult
struct
{
ProjectID
string
Subscription
*
AntigravitySubscriptionResult
}
// loadProjectIDWithRetry 带重试机制获取 project_id,同时从响应中提取 plan_type。
func
(
s
*
AntigravityOAuthService
)
loadProjectIDWithRetry
(
ctx
context
.
Context
,
accessToken
,
proxyURL
string
,
maxRetries
int
)
(
*
loadCodeAssistResult
,
error
)
{
var
lastErr
error
var
lastSubscription
*
AntigravitySubscriptionResult
for
attempt
:=
0
;
attempt
<=
maxRetries
;
attempt
++
{
if
attempt
>
0
{
// 指数退避:1s, 2s, 4s
backoff
:=
time
.
Duration
(
1
<<
uint
(
attempt
-
1
))
*
time
.
Second
if
backoff
>
8
*
time
.
Second
{
backoff
=
8
*
time
.
Second
...
...
@@ -324,24 +342,34 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
client
,
err
:=
antigravity
.
NewClient
(
proxyURL
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"create antigravity client failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"create antigravity client failed: %w"
,
err
)
}
loadResp
,
loadRaw
,
err
:=
client
.
LoadCodeAssist
(
ctx
,
accessToken
)
if
loadResp
!=
nil
{
sub
:=
NormalizeAntigravitySubscription
(
loadResp
)
lastSubscription
=
&
sub
}
if
err
==
nil
&&
loadResp
!=
nil
&&
loadResp
.
CloudAICompanionProject
!=
""
{
return
loadResp
.
CloudAICompanionProject
,
nil
return
&
loadCodeAssistResult
{
ProjectID
:
loadResp
.
CloudAICompanionProject
,
Subscription
:
lastSubscription
,
},
nil
}
if
err
==
nil
{
if
projectID
,
onboardErr
:=
tryOnboardProjectID
(
ctx
,
client
,
accessToken
,
loadRaw
);
onboardErr
==
nil
&&
projectID
!=
""
{
return
projectID
,
nil
return
&
loadCodeAssistResult
{
ProjectID
:
projectID
,
Subscription
:
lastSubscription
,
},
nil
}
else
if
onboardErr
!=
nil
{
lastErr
=
onboardErr
continue
}
}
// 记录错误
if
err
!=
nil
{
lastErr
=
err
}
else
if
loadResp
==
nil
{
...
...
@@ -351,7 +379,10 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
}
}
return
""
,
fmt
.
Errorf
(
"获取 project_id 失败 (重试 %d 次后): %w"
,
maxRetries
,
lastErr
)
if
lastSubscription
!=
nil
{
return
&
loadCodeAssistResult
{
Subscription
:
lastSubscription
},
fmt
.
Errorf
(
"获取 project_id 失败 (重试 %d 次后): %w"
,
maxRetries
,
lastErr
)
}
return
nil
,
fmt
.
Errorf
(
"获取 project_id 失败 (重试 %d 次后): %w"
,
maxRetries
,
lastErr
)
}
func
tryOnboardProjectID
(
ctx
context
.
Context
,
client
*
antigravity
.
Client
,
accessToken
string
,
loadRaw
map
[
string
]
any
)
(
string
,
error
)
{
...
...
@@ -410,7 +441,11 @@ func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Ac
proxyURL
=
proxy
.
URL
()
}
}
return
s
.
loadProjectIDWithRetry
(
ctx
,
accessToken
,
proxyURL
,
3
)
result
,
err
:=
s
.
loadProjectIDWithRetry
(
ctx
,
accessToken
,
proxyURL
,
3
)
if
result
!=
nil
{
return
result
.
ProjectID
,
err
}
return
""
,
err
}
// BuildAccountCredentials 构建账户凭证
...
...
@@ -431,6 +466,9 @@ func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *Antigravity
if
tokenInfo
.
ProjectID
!=
""
{
creds
[
"project_id"
]
=
tokenInfo
.
ProjectID
}
if
tokenInfo
.
PlanType
!=
""
{
creds
[
"plan_type"
]
=
tokenInfo
.
PlanType
}
return
creds
}
...
...
backend/internal/service/antigravity_privacy_service.go
0 → 100644
View file @
f8b8b539
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
}
backend/internal/service/antigravity_privacy_service_test.go
0 → 100644
View file @
f8b8b539
//go:build unit
package
service
import
(
"testing"
)
func
applyAntigravitySubscriptionResult
(
account
*
Account
,
result
AntigravitySubscriptionResult
)
(
map
[
string
]
any
,
map
[
string
]
any
)
{
credentials
:=
make
(
map
[
string
]
any
)
for
k
,
v
:=
range
account
.
Credentials
{
credentials
[
k
]
=
v
}
credentials
[
"plan_type"
]
=
result
.
PlanType
extra
:=
make
(
map
[
string
]
any
)
for
k
,
v
:=
range
account
.
Extra
{
extra
[
k
]
=
v
}
if
result
.
SubscriptionStatus
!=
""
{
extra
[
"subscription_status"
]
=
result
.
SubscriptionStatus
}
else
{
delete
(
extra
,
"subscription_status"
)
}
if
result
.
SubscriptionError
!=
""
{
extra
[
"subscription_error"
]
=
result
.
SubscriptionError
}
else
{
delete
(
extra
,
"subscription_error"
)
}
return
credentials
,
extra
}
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
)
}
}
func
TestApplyAntigravityPrivacyMode_PreservedBySubscriptionResult
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Credentials
:
map
[
string
]
any
{
"access_token"
:
"token"
,
},
Extra
:
map
[
string
]
any
{
"existing"
:
"value"
,
},
}
applyAntigravityPrivacyMode
(
account
,
AntigravityPrivacySet
)
_
,
extra
:=
applyAntigravitySubscriptionResult
(
account
,
AntigravitySubscriptionResult
{
PlanType
:
"Pro"
,
})
if
got
:=
extra
[
"privacy_mode"
];
got
!=
AntigravityPrivacySet
{
t
.
Fatalf
(
"expected subscription writeback to keep privacy_mode %q, got %v"
,
AntigravityPrivacySet
,
got
)
}
if
got
:=
extra
[
"existing"
];
got
!=
"value"
{
t
.
Fatalf
(
"expected existing extra fields to be preserved, got %v"
,
got
)
}
}
backend/internal/service/antigravity_subscription_service.go
0 → 100644
View file @
f8b8b539
package
service
import
(
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const
antigravitySubscriptionAbnormal
=
"abnormal"
// AntigravitySubscriptionResult 表示订阅检测后的规范化结果。
type
AntigravitySubscriptionResult
struct
{
PlanType
string
SubscriptionStatus
string
SubscriptionError
string
}
// NormalizeAntigravitySubscription 从 LoadCodeAssistResponse 提取 plan_type + 异常状态。
// 使用 GetTier()(返回 tier ID)+ TierIDToPlanType 映射。
func
NormalizeAntigravitySubscription
(
resp
*
antigravity
.
LoadCodeAssistResponse
)
AntigravitySubscriptionResult
{
if
resp
==
nil
{
return
AntigravitySubscriptionResult
{
PlanType
:
"Free"
}
}
if
len
(
resp
.
IneligibleTiers
)
>
0
{
result
:=
AntigravitySubscriptionResult
{
PlanType
:
"Abnormal"
,
SubscriptionStatus
:
antigravitySubscriptionAbnormal
,
}
if
resp
.
IneligibleTiers
[
0
]
!=
nil
{
result
.
SubscriptionError
=
strings
.
TrimSpace
(
resp
.
IneligibleTiers
[
0
]
.
ReasonMessage
)
}
return
result
}
tierID
:=
resp
.
GetTier
()
return
AntigravitySubscriptionResult
{
PlanType
:
antigravity
.
TierIDToPlanType
(
tierID
),
}
}
backend/internal/service/token_refresh_service.go
View file @
f8b8b539
...
...
@@ -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,50 @@ 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
,
)
}
}
frontend/src/api/admin/accounts.ts
View file @
f8b8b539
...
...
@@ -627,6 +627,16 @@ export async function batchRefresh(accountIds: number[]): Promise<BatchOperation
return
data
}
/**
* Set privacy for an Antigravity OAuth account
* @param id - Account ID
* @returns Updated account
*/
export
async
function
setPrivacy
(
id
:
number
):
Promise
<
Account
>
{
const
{
data
}
=
await
apiClient
.
post
<
Account
>
(
`/admin/accounts/
${
id
}
/set-privacy`
)
return
data
}
export
const
accountsAPI
=
{
list
,
listWithEtag
,
...
...
@@ -663,7 +673,8 @@ export const accountsAPI = {
importData
,
getAntigravityDefaultModelMapping
,
batchClearError
,
batchRefresh
batchRefresh
,
setPrivacy
}
export
default
accountsAPI
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
f8b8b539
...
...
@@ -32,6 +32,10 @@
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
</button>
</
template
>
<button
v-if=
"isAntigravityOAuth"
@
click=
"$emit('set-privacy', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"shield"
size=
"sm"
/>
{{ t('admin.accounts.setPrivacy') }}
</button>
<div
v-if=
"hasRecoverableState"
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<button
v-if=
"hasRecoverableState"
@
click=
"$emit('recover-state', account); $emit('close')"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-emerald-600 hover:bg-gray-100 dark:hover:bg-dark-700"
>
<Icon
name=
"sync"
size=
"sm"
/>
...
...
@@ -55,7 +59,7 @@ import { Icon } from '@/components/icons'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
show
:
boolean
;
account
:
Account
|
null
;
position
:
{
top
:
number
;
left
:
number
}
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
schedule
'
,
'
reauth
'
,
'
refresh-token
'
,
'
recover-state
'
,
'
reset-quota
'
])
const
emit
=
defineEmits
([
'
close
'
,
'
test
'
,
'
stats
'
,
'
schedule
'
,
'
reauth
'
,
'
refresh-token
'
,
'
recover-state
'
,
'
reset-quota
'
,
'
set-privacy
'
])
const
{
t
}
=
useI18n
()
const
isRateLimited
=
computed
(()
=>
{
if
(
props
.
account
?.
rate_limit_reset_at
&&
new
Date
(
props
.
account
.
rate_limit_reset_at
)
>
new
Date
())
{
...
...
@@ -75,6 +79,7 @@ const isTempUnschedulable = computed(() => props.account?.temp_unschedulable_unt
const
hasRecoverableState
=
computed
(()
=>
{
return
props
.
account
?.
status
===
'
error
'
||
Boolean
(
isRateLimited
.
value
)
||
Boolean
(
isOverloaded
.
value
)
||
Boolean
(
isTempUnschedulable
.
value
)
})
const
isAntigravityOAuth
=
computed
(()
=>
props
.
account
?.
platform
===
'
antigravity
'
&&
props
.
account
?.
type
===
'
oauth
'
)
const
hasQuotaLimit
=
computed
(()
=>
{
return
(
props
.
account
?.
type
===
'
apikey
'
||
props
.
account
?.
type
===
'
bedrock
'
)
&&
(
(
props
.
account
?.
quota_limit
??
0
)
>
0
||
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
f8b8b539
...
...
@@ -31,7 +31,7 @@
</div>
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
<div
v-if=
"planLabel || privacyBadge"
class=
"inline-flex items-center overflow-hidden rounded-md"
>
<span
v-if=
"planLabel"
:class=
"['inline-flex items-center gap-1 px-1.5 py-1',
typ
eClass]"
>
<span
v-if=
"planLabel"
:class=
"['inline-flex items-center gap-1 px-1.5 py-1',
planBadg
eClass]"
>
<span>
{{
planLabel
}}
</span>
</span>
<span
...
...
@@ -102,6 +102,8 @@ const planLabel = computed(() => {
return
'
Pro
'
case
'
free
'
:
return
'
Free
'
case
'
abnormal
'
:
return
t
(
'
admin.accounts.subscriptionAbnormal
'
)
default
:
return
props
.
planType
}
...
...
@@ -139,18 +141,34 @@ const typeClass = computed(() => {
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400
'
})
// Privacy badge — shows different states for OpenAI OAuth training setting
const
planBadgeClass
=
computed
(()
=>
{
if
(
props
.
planType
&&
props
.
planType
.
toLowerCase
()
===
'
abnormal
'
)
{
return
'
bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400
'
}
return
typeClass
.
value
})
// Privacy badge — shows different states for OpenAI/Antigravity OAuth privacy setting
const
privacyBadge
=
computed
(()
=>
{
if
(
props
.
platform
!==
'
openai
'
||
props
.
type
!==
'
oauth
'
||
!
props
.
privacyMode
)
return
null
if
(
props
.
type
!==
'
oauth
'
||
!
props
.
privacyMode
)
return
null
// 支持 OpenAI 和 Antigravity 平台
if
(
props
.
platform
!==
'
openai
'
&&
props
.
platform
!==
'
antigravity
'
)
return
null
const
shieldCheck
=
'
M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z
'
const
shieldX
=
'
M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285zM12 18h.008v.008H12V18z
'
switch
(
props
.
privacyMode
)
{
// OpenAI states
case
'
training_off
'
:
return
{
label
:
'
Priva
cy
'
,
icon
:
shieldCheck
,
title
:
t
(
'
admin.accounts.privacyTrainingOff
'
),
class
:
'
bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400
'
}
return
{
label
:
'
Priva
te
'
,
icon
:
shieldCheck
,
title
:
t
(
'
admin.accounts.privacyTrainingOff
'
),
class
:
'
bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400
'
}
case
'
training_set_cf_blocked
'
:
return
{
label
:
'
CF
'
,
icon
:
shieldX
,
title
:
t
(
'
admin.accounts.privacyCfBlocked
'
),
class
:
'
bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
case
'
training_set_failed
'
:
return
{
label
:
'
Fail
'
,
icon
:
shieldX
,
title
:
t
(
'
admin.accounts.privacyFailed
'
),
class
:
'
bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400
'
}
// Antigravity states
case
'
privacy_set
'
:
return
{
label
:
'
Private
'
,
icon
:
shieldCheck
,
title
:
t
(
'
admin.accounts.privacyAntigravitySet
'
),
class
:
'
bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400
'
}
case
'
privacy_set_failed
'
:
return
{
label
:
'
Fail
'
,
icon
:
shieldX
,
title
:
t
(
'
admin.accounts.privacyAntigravityFailed
'
),
class
:
'
bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400
'
}
default
:
return
null
}
...
...
frontend/src/i18n/locales/en.ts
View file @
f8b8b539
...
...
@@ -1984,6 +1984,10 @@ export default {
privacyTrainingOff
:
'
Training data sharing disabled
'
,
privacyCfBlocked
:
'
Blocked by Cloudflare, training may still be on
'
,
privacyFailed
:
'
Failed to disable training
'
,
privacyAntigravitySet
:
'
Telemetry and marketing emails disabled
'
,
privacyAntigravityFailed
:
'
Privacy setting failed
'
,
setPrivacy
:
'
Set Privacy
'
,
subscriptionAbnormal
:
'
Abnormal
'
,
// Capacity status tooltips
capacity
:
{
windowCost
:
{
...
...
frontend/src/i18n/locales/zh.ts
View file @
f8b8b539
...
...
@@ -2022,6 +2022,10 @@ export default {
privacyTrainingOff
:
'
已关闭训练数据共享
'
,
privacyCfBlocked
:
'
被 Cloudflare 拦截,训练可能仍开启
'
,
privacyFailed
:
'
关闭训练数据共享失败
'
,
privacyAntigravitySet
:
'
已关闭遥测和营销邮件
'
,
privacyAntigravityFailed
:
'
隐私设置失败
'
,
setPrivacy
:
'
设置隐私
'
,
subscriptionAbnormal
:
'
异常
'
,
// 容量状态提示
capacity
:
{
windowCost
:
{
...
...
frontend/src/views/admin/AccountsView.vue
View file @
f8b8b539
...
...
@@ -276,7 +276,7 @@
<
AccountTestModal
:
show
=
"
showTest
"
:
account
=
"
testingAcc
"
@
close
=
"
closeTestModal
"
/>
<
AccountStatsModal
:
show
=
"
showStats
"
:
account
=
"
statsAcc
"
@
close
=
"
closeStatsModal
"
/>
<
ScheduledTestsPanel
:
show
=
"
showSchedulePanel
"
:
account
-
id
=
"
scheduleAcc?.id ?? null
"
:
model
-
options
=
"
scheduleModelOptions
"
@
close
=
"
closeSchedulePanel
"
/>
<
AccountActionMenu
:
show
=
"
menu.show
"
:
account
=
"
menu.acc
"
:
position
=
"
menu.pos
"
@
close
=
"
menu.show = false
"
@
test
=
"
handleTest
"
@
stats
=
"
handleViewStats
"
@
schedule
=
"
handleSchedule
"
@
reauth
=
"
handleReAuth
"
@
refresh
-
token
=
"
handleRefresh
"
@
recover
-
state
=
"
handleRecoverState
"
@
reset
-
quota
=
"
handleResetQuota
"
/>
<
AccountActionMenu
:
show
=
"
menu.show
"
:
account
=
"
menu.acc
"
:
position
=
"
menu.pos
"
@
close
=
"
menu.show = false
"
@
test
=
"
handleTest
"
@
stats
=
"
handleViewStats
"
@
schedule
=
"
handleSchedule
"
@
reauth
=
"
handleReAuth
"
@
refresh
-
token
=
"
handleRefresh
"
@
recover
-
state
=
"
handleRecoverState
"
@
reset
-
quota
=
"
handleResetQuota
"
@
set
-
privacy
=
"
handleSetPrivacy
"
/>
<
SyncFromCrsModal
:
show
=
"
showSync
"
@
close
=
"
showSync = false
"
@
synced
=
"
reload
"
/>
<
ImportDataModal
:
show
=
"
showImportData
"
@
close
=
"
showImportData = false
"
@
imported
=
"
handleDataImported
"
/>
<
BulkEditAccountModal
:
show
=
"
showBulkEdit
"
:
account
-
ids
=
"
selIds
"
:
selected
-
platforms
=
"
selPlatforms
"
:
selected
-
types
=
"
selTypes
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEdit = false
"
@
updated
=
"
handleBulkUpdated
"
/>
...
...
@@ -1241,6 +1241,17 @@ const handleResetQuota = async (a: Account) => {
console
.
error
(
'
Failed to reset quota:
'
,
error
)
}
}
const
handleSetPrivacy
=
async
(
a
:
Account
)
=>
{
try
{
const
updated
=
await
adminAPI
.
accounts
.
setPrivacy
(
a
.
id
)
patchAccountInList
(
updated
)
enterAutoRefreshSilentWindow
()
appStore
.
showSuccess
(
t
(
'
common.success
'
))
}
catch
(
error
:
any
)
{
console
.
error
(
'
Failed to set privacy:
'
,
error
)
appStore
.
showError
(
error
?.
response
?.
data
?.
message
||
t
(
'
admin.accounts.privacyAntigravityFailed
'
))
}
}
const
handleDelete
=
(
a
:
Account
)
=>
{
deletingAcc
.
value
=
a
;
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingAcc
.
value
)
return
;
try
{
await
adminAPI
.
accounts
.
delete
(
deletingAcc
.
value
.
id
);
showDeleteDialog
.
value
=
false
;
deletingAcc
.
value
=
null
;
reload
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to delete account:
'
,
error
)
}
}
const
handleToggleSchedulable
=
async
(
a
:
Account
)
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment