Skip to content

Commit d2a6b72

Browse files
committed
feat(accounts): improve import dedup, usage display, and Sub2Api integration
- JSON import: dedup by chatgpt_account_id (file-level + DB-level) so multiple accounts sharing the same refresh_token (sub2api exports etc.) no longer collapse into one. Add format=json_at to ignore RT/ST and import via access_token only. - UsageCell: drive 5h/7d visibility from actual window data (incl. reset_5h_at) instead of plan_type alone, so accounts whose plan_type lags behind their real subscription still surface 5h usage. - New Sub2Api import flow: backend proxy endpoints /accounts/sub2api/{preview,import} call the remote instance with its admin x-api-key, return aggregate stats, and reuse importAccountsCommon (SSE progress + chatgpt_account_id dedup). UI entry sits next to file/migrate import with one-click buttons for available / healthy (incl. rate-limited) / all OpenAI accounts.
1 parent 2cb2f6a commit d2a6b72

7 files changed

Lines changed: 1122 additions & 37 deletions

File tree

admin/handler.go

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
204204
api.POST("/accounts/openai-responses/models", h.FetchOpenAIResponsesModels)
205205
api.PATCH("/accounts/:id/openai-responses", h.UpdateOpenAIResponsesAccount)
206206
api.POST("/accounts/import", h.ImportAccounts)
207+
api.POST("/accounts/sub2api/preview", h.PreviewSub2APIAccounts)
208+
api.POST("/accounts/sub2api/import", h.ImportFromSub2API)
207209
api.PATCH("/accounts/:id/scheduler", h.UpdateAccountScheduler)
208210
api.DELETE("/accounts/:id", h.DeleteAccount)
209211
api.POST("/accounts/:id/refresh", h.RefreshAccount)
@@ -1715,6 +1717,7 @@ type importToken struct {
17151717
email string
17161718
idToken string
17171719
accountID string
1720+
chatgptAccountID string // sub2api 等导出格式中的 ChatGPT 账号唯一标识,用于精确去重
17181721
planType string
17191722
expiresAt string
17201723
codex7DUsedPercent string
@@ -1732,6 +1735,7 @@ type jsonAccountEntry struct {
17321735
AccessToken string `json:"access_token"`
17331736
IDToken string `json:"id_token"`
17341737
AccountID string `json:"account_id"`
1738+
ChatGPTAccountID string `json:"chatgpt_account_id"`
17351739
Email string `json:"email"`
17361740
Name string `json:"name"`
17371741
PlanType string `json:"plan_type"`
@@ -1760,6 +1764,7 @@ type sub2apiAccountCredentials struct {
17601764
AccessToken string `json:"access_token"`
17611765
IDToken string `json:"id_token"`
17621766
AccountID string `json:"account_id"`
1767+
ChatGPTAccountID string `json:"chatgpt_account_id"`
17631768
Email string `json:"email"`
17641769
PlanType string `json:"plan_type"`
17651770
Codex7DUsedPercent importJSONScalarString `json:"codex_7d_used_percent"`
@@ -1856,6 +1861,7 @@ func jsonAccountEntriesToTokens(entries []jsonAccountEntry) []importToken {
18561861
email: email,
18571862
idToken: strings.TrimSpace(entry.IDToken),
18581863
accountID: strings.TrimSpace(entry.AccountID),
1864+
chatgptAccountID: strings.TrimSpace(entry.ChatGPTAccountID),
18591865
planType: strings.TrimSpace(entry.PlanType),
18601866
expiresAt: firstNonEmpty(entry.ExpiresAt.String(), entry.Expired.String()),
18611867
codex7DUsedPercent: strings.TrimSpace(entry.Codex7DUsedPercent.String()),
@@ -1896,6 +1902,7 @@ func parseSub2APIJSONImportTokens(data []byte) []importToken {
18961902
email: email,
18971903
idToken: strings.TrimSpace(account.Credentials.IDToken),
18981904
accountID: strings.TrimSpace(account.Credentials.AccountID),
1905+
chatgptAccountID: strings.TrimSpace(account.Credentials.ChatGPTAccountID),
18991906
planType: strings.TrimSpace(account.Credentials.PlanType),
19001907
expiresAt: firstNonEmpty(account.Credentials.ExpiresAt.String(), account.Credentials.Expired.String()),
19011908
codex7DUsedPercent: strings.TrimSpace(account.Credentials.Codex7DUsedPercent.String()),
@@ -1918,6 +1925,8 @@ func (h *Handler) ImportAccounts(c *gin.Context) {
19181925
switch format {
19191926
case "json":
19201927
h.importAccountsJSON(c, proxyURL)
1928+
case "json_at":
1929+
h.importAccountsJSONPreferAT(c, proxyURL)
19211930
case "at_txt":
19221931
h.importAccountsATTXT(c, proxyURL)
19231932
default:
@@ -2049,6 +2058,64 @@ func (h *Handler) importAccountsJSON(c *gin.Context, proxyURL string) {
20492058
h.importAccountsCommon(c, allTokens, proxyURL)
20502059
}
20512060

2061+
// importAccountsJSONPreferAT 通过 JSON 文件导入,但只信任 access_token,
2062+
// 用于一些导出工具中 refresh_token / session_token 是占位/重复值的场景。
2063+
func (h *Handler) importAccountsJSONPreferAT(c *gin.Context, proxyURL string) {
2064+
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
2065+
writeError(c, http.StatusBadRequest, "解析表单失败")
2066+
return
2067+
}
2068+
2069+
files := c.Request.MultipartForm.File["file"]
2070+
if len(files) == 0 {
2071+
writeError(c, http.StatusBadRequest, "请上传至少一个 JSON 文件")
2072+
return
2073+
}
2074+
2075+
var allTokens []importToken
2076+
2077+
for _, fh := range files {
2078+
if err := validateImportFileSize(fh); err != nil {
2079+
writeError(c, http.StatusBadRequest, err.Error())
2080+
return
2081+
}
2082+
2083+
f, err := fh.Open()
2084+
if err != nil {
2085+
writeError(c, http.StatusBadRequest, fmt.Sprintf("打开文件 %s 失败", fh.Filename))
2086+
return
2087+
}
2088+
data, err := io.ReadAll(f)
2089+
f.Close()
2090+
if err != nil {
2091+
writeError(c, http.StatusBadRequest, fmt.Sprintf("读取文件 %s 失败", fh.Filename))
2092+
return
2093+
}
2094+
2095+
tokens, err := parseImportJSONTokens(data)
2096+
if err != nil {
2097+
writeError(c, http.StatusBadRequest, fmt.Sprintf("文件 %s 不是有效的 JSON 格式", fh.Filename))
2098+
return
2099+
}
2100+
2101+
for _, t := range tokens {
2102+
if strings.TrimSpace(t.accessToken) == "" {
2103+
continue
2104+
}
2105+
t.refreshToken = ""
2106+
t.sessionToken = ""
2107+
allTokens = append(allTokens, t)
2108+
}
2109+
}
2110+
2111+
if len(allTokens) == 0 {
2112+
writeError(c, http.StatusBadRequest, "JSON 文件中未找到有效的 access_token")
2113+
return
2114+
}
2115+
2116+
h.importAccountsCommon(c, allTokens, proxyURL)
2117+
}
2118+
20522119
// importEvent SSE 导入进度事件
20532120
type importEvent struct {
20542121
Type string `json:"type"` // progress | complete
@@ -2075,12 +2142,35 @@ func setupSSE(c *gin.Context) {
20752142

20762143
// importAccountsCommon 公共的去重、并发插入、SSE 进度推送逻辑(支持 RT 和 AT-only 混合导入)
20772144
func (h *Handler) importAccountsCommon(c *gin.Context, tokens []importToken, proxyURL string) {
2078-
// 文件内去重(RT、ST 和 AT 分别去重)
2145+
// 文件内去重:
2146+
// 1) 当条目带有 chatgpt_account_id 时,以它作为唯一键 —— 这是 ChatGPT 端真正的账号标识,
2147+
// 可以避免因导出工具误把同一 RT 复制给多个不同账号而被错误合并。
2148+
// 2) 没有 chatgpt_account_id 时,退回到 RT / ST / AT 顺序去重(兼容旧导出格式)。
2149+
// 3) 同一份文件内若出现"同一个 RT 对应多个不同 chatgpt_account_id",
2150+
// 会被全部保留为独立账号;数据库层面 refresh_token 没有 UNIQUE 约束,因此安全。
2151+
seenChatGPTID := make(map[string]bool)
20792152
seenRT := make(map[string]bool)
20802153
seenST := make(map[string]bool)
20812154
seenAT := make(map[string]bool)
20822155
var unique []importToken
20832156
for _, t := range tokens {
2157+
if t.chatgptAccountID != "" {
2158+
if seenChatGPTID[t.chatgptAccountID] {
2159+
continue
2160+
}
2161+
seenChatGPTID[t.chatgptAccountID] = true
2162+
if t.refreshToken != "" {
2163+
seenRT[t.refreshToken] = true
2164+
}
2165+
if t.sessionToken != "" {
2166+
seenST[t.sessionToken] = true
2167+
}
2168+
if t.accessToken != "" {
2169+
seenAT[t.accessToken] = true
2170+
}
2171+
unique = append(unique, t)
2172+
continue
2173+
}
20842174
if t.refreshToken != "" {
20852175
if !seenRT[t.refreshToken] {
20862176
seenRT[t.refreshToken] = true
@@ -2140,16 +2230,41 @@ func (h *Handler) importAccountsCommon(c *gin.Context, tokens []importToken, pro
21402230
}
21412231
}
21422232

2233+
// 当导入条目带 chatgpt_account_id 时,按它查数据库已有账号 —— 这是 ChatGPT 端真实的账号唯一标识。
2234+
hasChatGPTID := false
2235+
for _, t := range unique {
2236+
if t.chatgptAccountID != "" {
2237+
hasChatGPTID = true
2238+
break
2239+
}
2240+
}
2241+
var existingChatGPTIDs map[string]bool
2242+
if hasChatGPTID {
2243+
existingChatGPTIDs, err = h.db.GetAllChatGPTAccountIDs(dedupeCtx)
2244+
if err != nil {
2245+
log.Printf("查询已有 chatgpt_account_id 失败: %v", err)
2246+
existingChatGPTIDs = make(map[string]bool)
2247+
}
2248+
}
2249+
21432250
var newTokens []importToken
21442251
duplicateCount := 0
21452252
for _, t := range unique {
2253+
// 优先按 chatgpt_account_id 判定数据库内是否已存在该账号;
2254+
// 命中则跳过,避免同一账号被重复导入。
2255+
if t.chatgptAccountID != "" && existingChatGPTIDs[t.chatgptAccountID] {
2256+
duplicateCount++
2257+
continue
2258+
}
21462259
switch {
21472260
case t.refreshToken != "":
2148-
if existingRTs[t.refreshToken] {
2261+
// 已经按 chatgpt_account_id 排除过重复账号;此处仅当条目没有 chatgpt_account_id 时才回退到 RT 去重,
2262+
// 否则当多个不同账号共享同一 RT(部分导出工具的常见格式)时会被错误判定为重复。
2263+
if t.chatgptAccountID == "" && existingRTs[t.refreshToken] {
21492264
duplicateCount++
2150-
} else if t.sessionToken != "" && existingSTs[t.sessionToken] {
2265+
} else if t.chatgptAccountID == "" && t.sessionToken != "" && existingSTs[t.sessionToken] {
21512266
duplicateCount++
2152-
} else if t.accessToken != "" && existingATs[t.accessToken] {
2267+
} else if t.chatgptAccountID == "" && t.accessToken != "" && existingATs[t.accessToken] {
21532268
duplicateCount++
21542269
} else {
21552270
newTokens = append(newTokens, t)
@@ -2250,7 +2365,7 @@ func (h *Handler) importAccountsCommon(c *gin.Context, tokens []importToken, pro
22502365
sessionToken: tok.sessionToken,
22512366
accessToken: tok.accessToken,
22522367
idToken: tok.idToken,
2253-
accountID: tok.accountID,
2368+
accountID: firstNonEmpty(tok.accountID, tok.chatgptAccountID),
22542369
email: tok.email,
22552370
planType: tok.planType,
22562371
expiresAtRaw: tok.expiresAt,

0 commit comments

Comments
 (0)