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
b4412353
Commit
b4412353
authored
Apr 03, 2026
by
陈曦
Browse files
resolve conficts
parents
d5cc72e4
0507852a
Changes
13
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_handler.go
View file @
b4412353
...
...
@@ -839,6 +839,7 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
if
updateErr
!=
nil
{
return
nil
,
""
,
fmt
.
Errorf
(
"failed to update credentials: %w"
,
updateErr
)
}
h
.
adminService
.
EnsureAntigravityPrivacy
(
ctx
,
updatedAccount
)
return
updatedAccount
,
"missing_project_id_temporary"
,
nil
}
...
...
backend/internal/service/account_test_service.go
View file @
b4412353
...
...
@@ -551,6 +551,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
account
.
RateLimitResetAt
=
resetAt
}
}
// 401 Unauthorized: 标记账号为永久错误
if
resp
.
StatusCode
==
http
.
StatusUnauthorized
&&
s
.
accountRepo
!=
nil
{
errMsg
:=
fmt
.
Sprintf
(
"Authentication failed (401): %s"
,
string
(
body
))
_
=
s
.
accountRepo
.
SetError
(
ctx
,
account
.
ID
,
errMsg
)
}
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"API returned %d: %s"
,
resp
.
StatusCode
,
string
(
body
)))
}
...
...
backend/internal/service/admin_service.go
View file @
b4412353
...
...
@@ -1642,8 +1642,20 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
}
// Antigravity OAuth 账号:创建后异步设置隐私
if
account
.
Platform
==
PlatformAntigravity
&&
account
.
Type
==
AccountTypeOAuth
{
// OAuth 账号:创建后异步设置隐私。
// 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。
if
account
.
Type
==
AccountTypeOAuth
{
switch
account
.
Platform
{
case
PlatformOpenAI
:
go
func
()
{
defer
func
()
{
if
r
:=
recover
();
r
!=
nil
{
slog
.
Error
(
"create_account_openai_privacy_panic"
,
"account_id"
,
account
.
ID
,
"recover"
,
r
)
}
}()
s
.
EnsureOpenAIPrivacy
(
context
.
Background
(),
account
)
}()
case
PlatformAntigravity
:
go
func
()
{
defer
func
()
{
if
r
:=
recover
();
r
!=
nil
{
...
...
@@ -1653,6 +1665,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
s
.
EnsureAntigravityPrivacy
(
context
.
Background
(),
account
)
}()
}
}
return
account
,
nil
}
...
...
@@ -2783,16 +2796,14 @@ func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Acco
}
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
// 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。
// 用户可通过前端 ForceAntigravityPrivacy(SetPrivacy 按钮)强制重新设置。
// 仅当 privacy_mode 已成功设置("privacy_set")时跳过;
// 未设置或之前失败("privacy_set_failed")均会重试。
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
!
=
""
{
if
existing
,
ok
:=
account
.
Extra
[
"privacy_mode"
]
.
(
string
);
ok
&&
existing
=
=
AntigravityPrivacySet
{
return
existing
}
}
...
...
backend/internal/service/antigravity_oauth_service.go
View file @
b4412353
...
...
@@ -91,6 +91,7 @@ type AntigravityTokenInfo struct {
ProjectID
string
`json:"project_id,omitempty"`
ProjectIDMissing
bool
`json:"-"`
PlanType
string
`json:"-"`
PrivacyMode
string
`json:"-"`
}
// ExchangeCode 用 authorization code 交换 token
...
...
@@ -159,6 +160,9 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
}
}
// 令牌刚获取,立即设置隐私(不依赖后续账号创建流程)
result
.
PrivacyMode
=
setAntigravityPrivacy
(
ctx
,
result
.
AccessToken
,
result
.
ProjectID
,
proxyURL
)
return
result
,
nil
}
...
...
@@ -248,6 +252,9 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
}
}
// 令牌刚获取,立即设置隐私
tokenInfo
.
PrivacyMode
=
setAntigravityPrivacy
(
ctx
,
tokenInfo
.
AccessToken
,
tokenInfo
.
ProjectID
,
proxyURL
)
return
tokenInfo
,
nil
}
...
...
backend/internal/service/openai_oauth_service.go
View file @
b4412353
...
...
@@ -138,6 +138,7 @@ type OpenAITokenInfo struct {
ChatGPTUserID
string
`json:"chatgpt_user_id,omitempty"`
OrganizationID
string
`json:"organization_id,omitempty"`
PlanType
string
`json:"plan_type,omitempty"`
SubscriptionExpiresAt
string
`json:"subscription_expires_at,omitempty"`
PrivacyMode
string
`json:"privacy_mode,omitempty"`
}
...
...
@@ -214,6 +215,8 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch
tokenInfo
.
PlanType
=
userInfo
.
PlanType
}
s
.
enrichTokenInfo
(
ctx
,
tokenInfo
,
proxyURL
)
return
tokenInfo
,
nil
}
...
...
@@ -259,8 +262,19 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre
tokenInfo
.
PlanType
=
userInfo
.
PlanType
}
// id_token 中缺少 plan_type 时(如 Mobile RT),尝试通过 ChatGPT backend-api 补全
if
tokenInfo
.
PlanType
==
""
&&
tokenInfo
.
AccessToken
!=
""
&&
s
.
privacyClientFactory
!=
nil
{
s
.
enrichTokenInfo
(
ctx
,
tokenInfo
,
proxyURL
)
return
tokenInfo
,
nil
}
// enrichTokenInfo 通过 ChatGPT backend-api 补全 tokenInfo 并设置隐私(best-effort)。
// 从 accounts/check 获取最新 plan_type、subscription_expires_at、email,
// 然后尝试关闭训练数据共享。适用于所有获取/刷新 token 的路径。
func
(
s
*
OpenAIOAuthService
)
enrichTokenInfo
(
ctx
context
.
Context
,
tokenInfo
*
OpenAITokenInfo
,
proxyURL
string
)
{
if
tokenInfo
.
AccessToken
==
""
||
s
.
privacyClientFactory
==
nil
{
return
}
// 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号
orgID
:=
tokenInfo
.
OrganizationID
if
orgID
==
""
{
...
...
@@ -269,21 +283,19 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre
}
}
if
info
:=
fetchChatGPTAccountInfo
(
ctx
,
s
.
privacyClientFactory
,
tokenInfo
.
AccessToken
,
proxyURL
,
orgID
);
info
!=
nil
{
if
tokenInfo
.
PlanType
==
""
&&
info
.
PlanType
!=
""
{
if
info
.
PlanType
!=
""
{
tokenInfo
.
PlanType
=
info
.
PlanType
}
if
info
.
SubscriptionExpiresAt
!=
""
{
tokenInfo
.
SubscriptionExpiresAt
=
info
.
SubscriptionExpiresAt
}
if
tokenInfo
.
Email
==
""
&&
info
.
Email
!=
""
{
tokenInfo
.
Email
=
info
.
Email
}
}
}
// 尝试设置隐私(关闭训练数据共享),best-effort
if
tokenInfo
.
AccessToken
!=
""
&&
s
.
privacyClientFactory
!=
nil
{
tokenInfo
.
PrivacyMode
=
disableOpenAITraining
(
ctx
,
s
.
privacyClientFactory
,
tokenInfo
.
AccessToken
,
proxyURL
)
}
return
tokenInfo
,
nil
}
// ExchangeSoraSessionToken exchanges Sora session_token to access_token.
...
...
@@ -567,6 +579,9 @@ func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo)
if
tokenInfo
.
PlanType
!=
""
{
creds
[
"plan_type"
]
=
tokenInfo
.
PlanType
}
if
tokenInfo
.
SubscriptionExpiresAt
!=
""
{
creds
[
"subscription_expires_at"
]
=
tokenInfo
.
SubscriptionExpiresAt
}
if
strings
.
TrimSpace
(
tokenInfo
.
ClientID
)
!=
""
{
creds
[
"client_id"
]
=
strings
.
TrimSpace
(
tokenInfo
.
ClientID
)
}
...
...
backend/internal/service/openai_privacy_service.go
View file @
b4412353
...
...
@@ -56,6 +56,10 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto
SetHeader
(
"Authorization"
,
"Bearer "
+
accessToken
)
.
SetHeader
(
"Origin"
,
"https://chatgpt.com"
)
.
SetHeader
(
"Referer"
,
"https://chatgpt.com/"
)
.
SetHeader
(
"Accept"
,
"application/json"
)
.
SetHeader
(
"sec-fetch-mode"
,
"cors"
)
.
SetHeader
(
"sec-fetch-site"
,
"same-origin"
)
.
SetHeader
(
"sec-fetch-dest"
,
"empty"
)
.
SetQueryParam
(
"feature"
,
"training_allowed"
)
.
SetQueryParam
(
"value"
,
"false"
)
.
Patch
(
openAISettingsURL
)
...
...
@@ -86,6 +90,7 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto
type
ChatGPTAccountInfo
struct
{
PlanType
string
Email
string
SubscriptionExpiresAt
string
// entitlement.expires_at (RFC3339)
}
const
chatGPTAccountsCheckURL
=
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
...
...
@@ -138,14 +143,20 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
// 优先匹配 orgID 对应的账号(access_token JWT 中的 poid)
if
orgID
!=
""
{
if
matched
:=
extractPlanFromAccount
(
accounts
,
orgID
);
matched
!=
""
{
info
.
PlanType
=
matched
if
acctRaw
,
ok
:=
accounts
[
orgID
];
ok
{
if
acct
,
ok
:=
acctRaw
.
(
map
[
string
]
any
);
ok
{
fillAccountInfo
(
info
,
acct
)
}
}
}
// 未匹配到时,遍历所有账号:优先 is_default,次选非 free
if
info
.
PlanType
==
""
{
var
defaultPlan
,
paidPlan
,
anyPlan
string
type
candidate
struct
{
planType
string
expiresAt
string
}
var
defaultC
,
paidC
,
anyC
candidate
for
_
,
acctRaw
:=
range
accounts
{
acct
,
ok
:=
acctRaw
.
(
map
[
string
]
any
)
if
!
ok
{
...
...
@@ -155,26 +166,27 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
if
planType
==
""
{
continue
}
if
anyPlan
==
""
{
anyPlan
=
planType
ea
:=
extractEntitlementExpiresAt
(
acct
)
if
anyC
.
planType
==
""
{
anyC
=
candidate
{
planType
,
ea
}
}
if
account
,
ok
:=
acct
[
"account"
]
.
(
map
[
string
]
any
);
ok
{
if
isDefault
,
_
:=
account
[
"is_default"
]
.
(
bool
);
isDefault
{
default
Plan
=
planType
default
C
=
candidate
{
planType
,
ea
}
}
}
if
!
strings
.
EqualFold
(
planType
,
"free"
)
&&
paid
Plan
==
""
{
paid
Plan
=
planType
if
!
strings
.
EqualFold
(
planType
,
"free"
)
&&
paid
C
.
planType
==
""
{
paid
C
=
candidate
{
planType
,
ea
}
}
}
// 优先级:default > 非 free > 任意
switch
{
case
default
Plan
!=
""
:
info
.
PlanType
=
defaultPlan
case
paid
Plan
!=
""
:
info
.
PlanType
=
paidPlan
case
default
C
.
planType
!=
""
:
info
.
PlanType
,
info
.
SubscriptionExpiresAt
=
defaultC
.
planType
,
defaultC
.
expiresAt
case
paid
C
.
planType
!=
""
:
info
.
PlanType
,
info
.
SubscriptionExpiresAt
=
paidC
.
planType
,
paidC
.
expiresAt
default
:
info
.
PlanType
=
anyPlan
info
.
PlanType
,
info
.
SubscriptionExpiresAt
=
anyC
.
planType
,
anyC
.
expiresAt
}
}
...
...
@@ -183,21 +195,14 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
return
nil
}
slog
.
Info
(
"chatgpt_account_check_success"
,
"plan_type"
,
info
.
PlanType
,
"org_id"
,
orgID
)
slog
.
Info
(
"chatgpt_account_check_success"
,
"plan_type"
,
info
.
PlanType
,
"subscription_expires_at"
,
info
.
SubscriptionExpiresAt
,
"org_id"
,
orgID
)
return
info
}
// extractPlanFromAccount 从 accounts map 中按 key(account_id)精确匹配并提取 plan_type
func
extractPlanFromAccount
(
accounts
map
[
string
]
any
,
accountKey
string
)
string
{
acctRaw
,
ok
:=
accounts
[
accountKey
]
if
!
ok
{
return
""
}
acct
,
ok
:=
acctRaw
.
(
map
[
string
]
any
)
if
!
ok
{
return
""
}
return
extractPlanType
(
acct
)
// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at
func
fillAccountInfo
(
info
*
ChatGPTAccountInfo
,
acct
map
[
string
]
any
)
{
info
.
PlanType
=
extractPlanType
(
acct
)
info
.
SubscriptionExpiresAt
=
extractEntitlementExpiresAt
(
acct
)
}
// extractPlanType 从单个 account 对象中提取 plan_type
...
...
@@ -215,6 +220,17 @@ func extractPlanType(acct map[string]any) string {
return
""
}
// extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。
// 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。
func
extractEntitlementExpiresAt
(
acct
map
[
string
]
any
)
string
{
entitlement
,
ok
:=
acct
[
"entitlement"
]
.
(
map
[
string
]
any
)
if
!
ok
{
return
""
}
ea
,
_
:=
entitlement
[
"expires_at"
]
.
(
string
)
return
ea
}
func
truncate
(
s
string
,
n
int
)
string
{
if
len
(
s
)
<=
n
{
return
s
...
...
backend/internal/service/ratelimit_service.go
View file @
b4412353
...
...
@@ -161,6 +161,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
shouldDisable
=
true
break
}
// OpenAI: {"detail":"Unauthorized"} 表示 token 完全无效(非标准 OpenAI 错误格式),直接标记 error
if
account
.
Platform
==
PlatformOpenAI
&&
gjson
.
GetBytes
(
responseBody
,
"detail"
)
.
String
()
==
"Unauthorized"
{
msg
:=
"Unauthorized (401): account authentication failed permanently"
if
upstreamMsg
!=
""
{
msg
=
"Unauthorized (401): "
+
upstreamMsg
}
s
.
handleAuthError
(
ctx
,
account
,
msg
)
shouldDisable
=
true
break
}
// OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。
// Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。
if
account
.
Type
==
AccountTypeOAuth
&&
account
.
Platform
!=
PlatformAntigravity
{
...
...
backend/internal/service/token_refresh_service.go
View file @
b4412353
...
...
@@ -489,15 +489,14 @@ func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *
}
// ensureAntigravityPrivacy 后台刷新中检查 Antigravity OAuth 账号隐私状态。
// 仅
做 Extra["
privacy_mode
"] 存在性检查,不发起 HTTP 请求,避免每轮循环产生额外网络开销。
//
用户可通过前端 SetPrivacy 按钮强制重新设置
。
// 仅
当
privacy_mode
已成功设置("privacy_set")时跳过;
//
未设置或之前失败("privacy_set_failed")均会重试
。
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
{
if
mode
,
ok
:=
account
.
Extra
[
"privacy_mode"
]
.
(
string
);
ok
&&
mode
==
AntigravityPrivacySet
{
return
}
}
...
...
backend/internal/service/token_refresher.go
View file @
b4412353
...
...
@@ -109,11 +109,11 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool {
}
// NeedsRefresh 检查token是否需要刷新
//
基于
expires_at
字段判断是否在刷新窗口内
// expires_at
缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期
func
(
r
*
OpenAITokenRefresher
)
NeedsRefresh
(
account
*
Account
,
refreshWindow
time
.
Duration
)
bool
{
expiresAt
:=
account
.
GetCredentialAsTime
(
"expires_at"
)
if
expiresAt
==
nil
{
return
false
return
account
.
IsRateLimited
()
}
return
time
.
Until
(
*
expiresAt
)
<
refreshWindow
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
b4412353
...
...
@@ -45,6 +45,10 @@
<span>
{{
privacyBadge
.
label
}}
</span>
</span>
</div>
<!-- Row 3: Subscription expiration (non-free paid accounts only) -->
<div
v-if=
"expiresLabel"
class=
"text-[10px] leading-tight text-gray-400 dark:text-gray-500 pl-0.5"
:title=
"subscriptionExpiresAt"
>
{{
expiresLabel
}}
</div>
</div>
</
template
>
...
...
@@ -62,6 +66,7 @@ interface Props {
type
:
AccountType
planType
?:
string
privacyMode
?:
string
subscriptionExpiresAt
?:
string
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -148,6 +153,22 @@ const planBadgeClass = computed(() => {
return
typeClass
.
value
})
// Subscription expiration label (non-free only)
const
expiresLabel
=
computed
(()
=>
{
if
(
!
props
.
subscriptionExpiresAt
||
!
props
.
planType
)
return
''
if
(
props
.
planType
.
toLowerCase
()
===
'
free
'
)
return
''
try
{
const
d
=
new
Date
(
props
.
subscriptionExpiresAt
)
if
(
isNaN
(
d
.
getTime
()))
return
''
const
yyyy
=
d
.
getFullYear
()
const
mm
=
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
dd
=
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)
return
`
${
t
(
'
admin.accounts.subscriptionExpires
'
)}
${
yyyy
}
-
${
mm
}
-
${
dd
}
`
}
catch
{
return
''
}
})
// Privacy badge — shows different states for OpenAI/Antigravity OAuth privacy setting
const
privacyBadge
=
computed
(()
=>
{
if
(
props
.
type
!==
'
oauth
'
||
!
props
.
privacyMode
)
return
null
...
...
frontend/src/i18n/locales/en.ts
View file @
b4412353
...
...
@@ -1988,6 +1988,7 @@ export default {
privacyAntigravityFailed
:
'
Privacy setting failed
'
,
setPrivacy
:
'
Set Privacy
'
,
subscriptionAbnormal
:
'
Abnormal
'
,
subscriptionExpires
:
'
Expires
'
,
// Capacity status tooltips
capacity
:
{
windowCost
:
{
...
...
frontend/src/i18n/locales/zh.ts
View file @
b4412353
...
...
@@ -2026,6 +2026,7 @@ export default {
privacyAntigravityFailed
:
'
隐私设置失败
'
,
setPrivacy
:
'
设置隐私
'
,
subscriptionAbnormal
:
'
异常
'
,
subscriptionExpires
:
'
到期
'
,
// 容量状态提示
capacity
:
{
windowCost
:
{
...
...
frontend/src/views/admin/AccountsView.vue
View file @
b4412353
...
...
@@ -182,7 +182,7 @@
<
/template
>
<
template
#
cell
-
platform_type
=
"
{ row
}
"
>
<
div
class
=
"
flex flex-wrap items-center gap-1
"
>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
:
privacy
-
mode
=
"
row.extra?.privacy_mode
"
/>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
:
privacy
-
mode
=
"
row.extra?.privacy_mode
"
:
subscription
-
expires
-
at
=
"
row.credentials?.subscription_expires_at
"
/>
<
span
v
-
if
=
"
getAntigravityTierLabel(row)
"
:
class
=
"
['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]
"
...
...
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