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
0507852a
Unverified
Commit
0507852a
authored
Apr 03, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 03, 2026
Browse files
Merge pull request #1437 from DaydreamCoding/fix/openai-oauth-improvements
feat(openai): OpenAI OAuth 账号管理增强:订阅状态、隐私设置、Token 刷新修复
parents
7b6ff135
e8efaa4c
Changes
10
Show whitespace changes
Inline
Side-by-side
backend/internal/service/account_test_service.go
View file @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -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
}
...
...
backend/internal/service/openai_oauth_service.go
View file @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -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_refresher.go
View file @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -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 @
0507852a
...
...
@@ -2026,6 +2026,7 @@ export default {
privacyAntigravityFailed
:
'
隐私设置失败
'
,
setPrivacy
:
'
设置隐私
'
,
subscriptionAbnormal
:
'
异常
'
,
subscriptionExpires
:
'
到期
'
,
// 容量状态提示
capacity
:
{
windowCost
:
{
...
...
frontend/src/views/admin/AccountsView.vue
View file @
0507852a
...
...
@@ -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