@@ -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 导入进度事件
20532120type 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 混合导入)
20772144func (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