account_data.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. package admin
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "log/slog"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. )
  16. const (
  17. dataType = "sub2api-data"
  18. legacyDataType = "sub2api-bundle"
  19. dataVersion = 1
  20. dataPageCap = 1000
  21. )
  22. type DataPayload struct {
  23. Type string `json:"type,omitempty"`
  24. Version int `json:"version,omitempty"`
  25. ExportedAt string `json:"exported_at"`
  26. Proxies []DataProxy `json:"proxies"`
  27. Accounts []DataAccount `json:"accounts"`
  28. // SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
  29. // 导入侧忽略该字段;omitempty 保持向后兼容。
  30. SkippedShadows int `json:"skipped_shadows,omitempty"`
  31. }
  32. type DataProxy struct {
  33. ProxyKey string `json:"proxy_key"`
  34. Name string `json:"name"`
  35. Protocol string `json:"protocol"`
  36. Host string `json:"host"`
  37. Port int `json:"port"`
  38. Username string `json:"username,omitempty"`
  39. Password string `json:"password,omitempty"`
  40. Status string `json:"status"`
  41. ExpiresAt *int64 `json:"expires_at,omitempty"` // unix 秒,与 DataAccount.ExpiresAt 风格一致
  42. FallbackMode string `json:"fallback_mode,omitempty"` // none/direct/proxy
  43. BackupProxyName string `json:"backup_proxy_name,omitempty"` // 备用代理 name(跨实例按 name 反查)
  44. ExpiryWarnDays int `json:"expiry_warn_days,omitempty"`
  45. }
  46. // DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
  47. // Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
  48. // 应新增独立结构而非修改这里。
  49. // 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
  50. // 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
  51. // 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
  52. // (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
  53. type DataAccount struct {
  54. Name string `json:"name"`
  55. Notes *string `json:"notes,omitempty"`
  56. Platform string `json:"platform"`
  57. Type string `json:"type"`
  58. Credentials map[string]any `json:"credentials"`
  59. Extra map[string]any `json:"extra,omitempty"`
  60. ProxyKey *string `json:"proxy_key,omitempty"`
  61. Concurrency int `json:"concurrency"`
  62. Priority int `json:"priority"`
  63. RateMultiplier *float64 `json:"rate_multiplier,omitempty"`
  64. ExpiresAt *int64 `json:"expires_at,omitempty"`
  65. AutoPauseOnExpired *bool `json:"auto_pause_on_expired,omitempty"`
  66. }
  67. type DataImportRequest struct {
  68. Data DataPayload `json:"data"`
  69. SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
  70. }
  71. type DataImportResult struct {
  72. ProxyCreated int `json:"proxy_created"`
  73. ProxyReused int `json:"proxy_reused"`
  74. ProxyFailed int `json:"proxy_failed"`
  75. AccountCreated int `json:"account_created"`
  76. AccountFailed int `json:"account_failed"`
  77. Errors []DataImportError `json:"errors,omitempty"`
  78. }
  79. type DataImportError struct {
  80. Kind string `json:"kind"`
  81. Name string `json:"name,omitempty"`
  82. ProxyKey string `json:"proxy_key,omitempty"`
  83. Message string `json:"message"`
  84. }
  85. func buildProxyKey(protocol, host string, port int, username, password string) string {
  86. return fmt.Sprintf("%s|%s|%d|%s|%s", strings.TrimSpace(protocol), strings.TrimSpace(host), port, strings.TrimSpace(username), strings.TrimSpace(password))
  87. }
  88. func (h *AccountHandler) ExportData(c *gin.Context) {
  89. ctx := c.Request.Context()
  90. selectedIDs, err := parseAccountIDs(c)
  91. if err != nil {
  92. response.BadRequest(c, err.Error())
  93. return
  94. }
  95. accounts, err := h.resolveExportAccounts(ctx, selectedIDs, c)
  96. if err != nil {
  97. response.ErrorFrom(c, err)
  98. return
  99. }
  100. // 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
  101. // 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
  102. // status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
  103. // 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
  104. skippedShadows := 0
  105. exportable := make([]service.Account, 0, len(accounts))
  106. for i := range accounts {
  107. if accounts[i].IsCredentialShadow() {
  108. skippedShadows++
  109. continue
  110. }
  111. exportable = append(exportable, accounts[i])
  112. }
  113. accounts = exportable
  114. if skippedShadows > 0 {
  115. slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
  116. }
  117. includeProxies, err := parseIncludeProxies(c)
  118. if err != nil {
  119. response.BadRequest(c, err.Error())
  120. return
  121. }
  122. var proxies []service.Proxy
  123. if includeProxies {
  124. proxies, err = h.resolveExportProxies(ctx, accounts)
  125. if err != nil {
  126. response.ErrorFrom(c, err)
  127. return
  128. }
  129. } else {
  130. proxies = []service.Proxy{}
  131. }
  132. // 构建 id→name 映射,用于导出备用代理 name
  133. proxyNameByID := make(map[int64]string, len(proxies))
  134. for i := range proxies {
  135. proxyNameByID[proxies[i].ID] = proxies[i].Name
  136. }
  137. proxyKeyByID := make(map[int64]string, len(proxies))
  138. dataProxies := make([]DataProxy, 0, len(proxies))
  139. for i := range proxies {
  140. p := proxies[i]
  141. key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
  142. proxyKeyByID[p.ID] = key
  143. var expiresAt *int64
  144. if p.ExpiresAt != nil {
  145. v := p.ExpiresAt.Unix()
  146. expiresAt = &v
  147. }
  148. var backupProxyName string
  149. if p.BackupProxyID != nil {
  150. backupProxyName = proxyNameByID[*p.BackupProxyID]
  151. }
  152. dataProxies = append(dataProxies, DataProxy{
  153. ProxyKey: key,
  154. Name: p.Name,
  155. Protocol: p.Protocol,
  156. Host: p.Host,
  157. Port: p.Port,
  158. Username: p.Username,
  159. Password: p.Password,
  160. Status: p.Status,
  161. ExpiresAt: expiresAt,
  162. FallbackMode: p.FallbackMode,
  163. BackupProxyName: backupProxyName,
  164. ExpiryWarnDays: p.ExpiryWarnDays,
  165. })
  166. }
  167. dataAccounts := make([]DataAccount, 0, len(accounts))
  168. for i := range accounts {
  169. acc := accounts[i]
  170. var proxyKey *string
  171. if acc.ProxyID != nil {
  172. if key, ok := proxyKeyByID[*acc.ProxyID]; ok {
  173. proxyKey = &key
  174. }
  175. }
  176. var expiresAt *int64
  177. if acc.ExpiresAt != nil {
  178. v := acc.ExpiresAt.Unix()
  179. expiresAt = &v
  180. }
  181. dataAccounts = append(dataAccounts, DataAccount{
  182. Name: acc.Name,
  183. Notes: acc.Notes,
  184. Platform: acc.Platform,
  185. Type: acc.Type,
  186. Credentials: acc.Credentials,
  187. Extra: acc.Extra,
  188. ProxyKey: proxyKey,
  189. Concurrency: acc.Concurrency,
  190. Priority: acc.Priority,
  191. RateMultiplier: acc.RateMultiplier,
  192. ExpiresAt: expiresAt,
  193. AutoPauseOnExpired: &acc.AutoPauseOnExpired,
  194. })
  195. }
  196. payload := DataPayload{
  197. ExportedAt: time.Now().UTC().Format(time.RFC3339),
  198. Proxies: dataProxies,
  199. Accounts: dataAccounts,
  200. SkippedShadows: skippedShadows,
  201. }
  202. response.Success(c, payload)
  203. }
  204. func (h *AccountHandler) ImportData(c *gin.Context) {
  205. var req DataImportRequest
  206. if err := c.ShouldBindJSON(&req); err != nil {
  207. response.BadRequest(c, "Invalid request: "+err.Error())
  208. return
  209. }
  210. if err := validateDataHeader(req.Data); err != nil {
  211. response.BadRequest(c, err.Error())
  212. return
  213. }
  214. executeAdminIdempotentJSON(c, "admin.accounts.import_data", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
  215. return h.importData(ctx, req)
  216. })
  217. }
  218. func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) (DataImportResult, error) {
  219. skipDefaultGroupBind := true
  220. if req.SkipDefaultGroupBind != nil {
  221. skipDefaultGroupBind = *req.SkipDefaultGroupBind
  222. }
  223. dataPayload := req.Data
  224. result := DataImportResult{}
  225. existingProxies, err := h.listAllProxies(ctx)
  226. if err != nil {
  227. return result, err
  228. }
  229. proxyKeyToID := make(map[string]int64, len(existingProxies))
  230. // proxyNameToID 用于 backup_proxy_name 反查:DB 已有 + 本批次新建均会写入
  231. proxyNameToID := make(map[string]int64, len(existingProxies))
  232. for i := range existingProxies {
  233. p := existingProxies[i]
  234. key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
  235. proxyKeyToID[key] = p.ID
  236. if p.Name != "" {
  237. proxyNameToID[p.Name] = p.ID
  238. }
  239. }
  240. for i := range dataPayload.Proxies {
  241. item := dataPayload.Proxies[i]
  242. key := item.ProxyKey
  243. if key == "" {
  244. key = buildProxyKey(item.Protocol, item.Host, item.Port, item.Username, item.Password)
  245. }
  246. if err := validateDataProxy(item); err != nil {
  247. result.ProxyFailed++
  248. result.Errors = append(result.Errors, DataImportError{
  249. Kind: "proxy",
  250. Name: item.Name,
  251. ProxyKey: key,
  252. Message: err.Error(),
  253. })
  254. continue
  255. }
  256. normalizedStatus := normalizeProxyStatus(item.Status)
  257. if existingID, ok := proxyKeyToID[key]; ok {
  258. proxyKeyToID[key] = existingID
  259. result.ProxyReused++
  260. if normalizedStatus != "" {
  261. if proxy, getErr := h.adminService.GetProxy(ctx, existingID); getErr == nil && proxy != nil && proxy.Status != normalizedStatus {
  262. // 同步 status 时传入完整字段,避免零值覆盖已存在代理的有效期/fallback 配置。
  263. var existingExpiresAt *time.Time
  264. if item.ExpiresAt != nil {
  265. t := time.Unix(*item.ExpiresAt, 0).UTC()
  266. existingExpiresAt = &t
  267. }
  268. existingFallbackMode := item.FallbackMode
  269. if existingFallbackMode == "" {
  270. existingFallbackMode = service.FallbackModeNone
  271. }
  272. var existingBackupProxyID *int64
  273. if item.BackupProxyName != "" {
  274. if bid, ok := proxyNameToID[item.BackupProxyName]; ok {
  275. existingBackupProxyID = &bid
  276. }
  277. }
  278. _, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
  279. Status: normalizedStatus,
  280. ExpiresAt: existingExpiresAt,
  281. ClearExpiresAt: existingExpiresAt == nil,
  282. FallbackMode: existingFallbackMode,
  283. BackupProxyID: existingBackupProxyID,
  284. ClearBackupID: existingBackupProxyID == nil,
  285. ExpiryWarnDays: &item.ExpiryWarnDays,
  286. Name: proxy.Name,
  287. Protocol: proxy.Protocol,
  288. Host: proxy.Host,
  289. Port: proxy.Port,
  290. Username: proxy.Username,
  291. Password: proxy.Password,
  292. })
  293. }
  294. }
  295. continue
  296. }
  297. // 解析 expires_at(unix 秒 → *time.Time)
  298. var expiresAt *time.Time
  299. if item.ExpiresAt != nil {
  300. t := time.Unix(*item.ExpiresAt, 0).UTC()
  301. expiresAt = &t
  302. }
  303. // 解析 backup_proxy_name → backup_proxy_id
  304. fallbackMode := item.FallbackMode
  305. var backupProxyID *int64
  306. if item.BackupProxyName != "" {
  307. if bid, ok := proxyNameToID[item.BackupProxyName]; ok {
  308. backupProxyID = &bid
  309. } else {
  310. // 查不到备用代理:降级 fallback_mode=none,记录 warning
  311. fallbackMode = service.FallbackModeNone
  312. result.Errors = append(result.Errors, DataImportError{
  313. Kind: "proxy",
  314. Name: item.Name,
  315. ProxyKey: key,
  316. Message: fmt.Sprintf("backup_proxy_name %q not found, fallback_mode downgraded to none", item.BackupProxyName),
  317. })
  318. }
  319. }
  320. created, createErr := h.adminService.CreateProxy(ctx, &service.CreateProxyInput{
  321. Name: defaultProxyName(item.Name),
  322. Protocol: item.Protocol,
  323. Host: item.Host,
  324. Port: item.Port,
  325. Username: item.Username,
  326. Password: item.Password,
  327. ExpiresAt: expiresAt,
  328. FallbackMode: fallbackMode,
  329. BackupProxyID: backupProxyID,
  330. ExpiryWarnDays: item.ExpiryWarnDays,
  331. })
  332. if createErr != nil {
  333. result.ProxyFailed++
  334. result.Errors = append(result.Errors, DataImportError{
  335. Kind: "proxy",
  336. Name: item.Name,
  337. ProxyKey: key,
  338. Message: createErr.Error(),
  339. })
  340. continue
  341. }
  342. proxyKeyToID[key] = created.ID
  343. // 把新建代理的 name 也加入反查表,供后续批内代理引用
  344. if created.Name != "" {
  345. proxyNameToID[created.Name] = created.ID
  346. }
  347. result.ProxyCreated++
  348. if normalizedStatus != "" && normalizedStatus != created.Status {
  349. // 新建后同步 status 时,传入完整字段,避免零值覆盖刚创建的有效期/fallback 配置。
  350. _, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
  351. Status: normalizedStatus,
  352. ExpiresAt: expiresAt,
  353. ClearExpiresAt: expiresAt == nil,
  354. FallbackMode: fallbackMode,
  355. BackupProxyID: backupProxyID,
  356. ClearBackupID: backupProxyID == nil,
  357. ExpiryWarnDays: &item.ExpiryWarnDays,
  358. Name: created.Name,
  359. Protocol: created.Protocol,
  360. Host: created.Host,
  361. Port: created.Port,
  362. Username: created.Username,
  363. Password: created.Password,
  364. })
  365. }
  366. }
  367. // 收集需要异步设置隐私的 Antigravity OAuth 账号
  368. var privacyAccounts []*service.Account
  369. for i := range dataPayload.Accounts {
  370. item := dataPayload.Accounts[i]
  371. if err := validateDataAccount(item); err != nil {
  372. result.AccountFailed++
  373. result.Errors = append(result.Errors, DataImportError{
  374. Kind: "account",
  375. Name: item.Name,
  376. Message: err.Error(),
  377. })
  378. continue
  379. }
  380. var proxyID *int64
  381. if item.ProxyKey != nil && *item.ProxyKey != "" {
  382. if id, ok := proxyKeyToID[*item.ProxyKey]; ok {
  383. proxyID = &id
  384. } else {
  385. result.AccountFailed++
  386. result.Errors = append(result.Errors, DataImportError{
  387. Kind: "account",
  388. Name: item.Name,
  389. ProxyKey: *item.ProxyKey,
  390. Message: "proxy_key not found",
  391. })
  392. continue
  393. }
  394. }
  395. enrichCredentialsFromIDToken(&item)
  396. accountInput := &service.CreateAccountInput{
  397. Name: item.Name,
  398. Notes: item.Notes,
  399. Platform: item.Platform,
  400. Type: item.Type,
  401. Credentials: item.Credentials,
  402. Extra: item.Extra,
  403. ProxyID: proxyID,
  404. Concurrency: item.Concurrency,
  405. Priority: item.Priority,
  406. RateMultiplier: item.RateMultiplier,
  407. GroupIDs: nil,
  408. ExpiresAt: item.ExpiresAt,
  409. AutoPauseOnExpired: item.AutoPauseOnExpired,
  410. SkipDefaultGroupBind: skipDefaultGroupBind,
  411. }
  412. created, err := h.adminService.CreateAccount(ctx, accountInput)
  413. if err != nil {
  414. result.AccountFailed++
  415. result.Errors = append(result.Errors, DataImportError{
  416. Kind: "account",
  417. Name: item.Name,
  418. Message: err.Error(),
  419. })
  420. continue
  421. }
  422. // 收集 Antigravity OAuth 账号,稍后异步设置隐私
  423. if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
  424. privacyAccounts = append(privacyAccounts, created)
  425. }
  426. h.scheduleGrokImportProbe(created)
  427. result.AccountCreated++
  428. }
  429. // 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
  430. if len(privacyAccounts) > 0 {
  431. adminSvc := h.adminService
  432. go func() {
  433. defer func() {
  434. if r := recover(); r != nil {
  435. slog.Error("import_antigravity_privacy_panic", "recover", r)
  436. }
  437. }()
  438. bgCtx := context.Background()
  439. for _, acc := range privacyAccounts {
  440. adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
  441. }
  442. slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
  443. }()
  444. }
  445. return result, nil
  446. }
  447. func (h *AccountHandler) listAllProxies(ctx context.Context) ([]service.Proxy, error) {
  448. page := 1
  449. pageSize := dataPageCap
  450. var out []service.Proxy
  451. for {
  452. items, total, err := h.adminService.ListProxies(ctx, page, pageSize, "", "", "", "created_at", "desc")
  453. if err != nil {
  454. return nil, err
  455. }
  456. out = append(out, items...)
  457. if len(out) >= int(total) || len(items) == 0 {
  458. break
  459. }
  460. page++
  461. }
  462. return out, nil
  463. }
  464. func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode, sortBy, sortOrder string) ([]service.Account, error) {
  465. page := 1
  466. pageSize := dataPageCap
  467. var out []service.Account
  468. for {
  469. items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
  470. if err != nil {
  471. return nil, err
  472. }
  473. out = append(out, items...)
  474. if len(out) >= int(total) || len(items) == 0 {
  475. break
  476. }
  477. page++
  478. }
  479. return out, nil
  480. }
  481. func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) {
  482. if len(ids) > 0 {
  483. accounts, err := h.adminService.GetAccountsByIDs(ctx, ids)
  484. if err != nil {
  485. return nil, err
  486. }
  487. out := make([]service.Account, 0, len(accounts))
  488. for _, acc := range accounts {
  489. if acc == nil {
  490. continue
  491. }
  492. out = append(out, *acc)
  493. }
  494. return out, nil
  495. }
  496. platform := c.Query("platform")
  497. accountType := c.Query("type")
  498. status := c.Query("status")
  499. privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
  500. search := strings.TrimSpace(c.Query("search"))
  501. sortBy := c.DefaultQuery("sort_by", "name")
  502. sortOrder := c.DefaultQuery("sort_order", "asc")
  503. if len(search) > 100 {
  504. search = search[:100]
  505. }
  506. groupID := int64(0)
  507. if groupIDStr := c.Query("group"); groupIDStr != "" {
  508. if groupIDStr == accountListGroupUngroupedQueryValue {
  509. groupID = service.AccountListGroupUngrouped
  510. } else {
  511. parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64)
  512. if parseErr != nil || parsedGroupID <= 0 {
  513. return nil, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")
  514. }
  515. groupID = parsedGroupID
  516. }
  517. }
  518. return h.listAccountsFiltered(ctx, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
  519. }
  520. func (h *AccountHandler) resolveExportProxies(ctx context.Context, accounts []service.Account) ([]service.Proxy, error) {
  521. if len(accounts) == 0 {
  522. return []service.Proxy{}, nil
  523. }
  524. seen := make(map[int64]struct{})
  525. ids := make([]int64, 0)
  526. for i := range accounts {
  527. if accounts[i].ProxyID == nil {
  528. continue
  529. }
  530. id := *accounts[i].ProxyID
  531. if id <= 0 {
  532. continue
  533. }
  534. if _, ok := seen[id]; ok {
  535. continue
  536. }
  537. seen[id] = struct{}{}
  538. ids = append(ids, id)
  539. }
  540. if len(ids) == 0 {
  541. return []service.Proxy{}, nil
  542. }
  543. return h.adminService.GetProxiesByIDs(ctx, ids)
  544. }
  545. func parseAccountIDs(c *gin.Context) ([]int64, error) {
  546. values := c.QueryArray("ids")
  547. if len(values) == 0 {
  548. raw := strings.TrimSpace(c.Query("ids"))
  549. if raw != "" {
  550. values = []string{raw}
  551. }
  552. }
  553. if len(values) == 0 {
  554. return nil, nil
  555. }
  556. ids := make([]int64, 0, len(values))
  557. for _, item := range values {
  558. for _, part := range strings.Split(item, ",") {
  559. part = strings.TrimSpace(part)
  560. if part == "" {
  561. continue
  562. }
  563. id, err := strconv.ParseInt(part, 10, 64)
  564. if err != nil || id <= 0 {
  565. return nil, fmt.Errorf("invalid account id: %s", part)
  566. }
  567. ids = append(ids, id)
  568. }
  569. }
  570. return ids, nil
  571. }
  572. func parseIncludeProxies(c *gin.Context) (bool, error) {
  573. raw := strings.TrimSpace(strings.ToLower(c.Query("include_proxies")))
  574. if raw == "" {
  575. return true, nil
  576. }
  577. switch raw {
  578. case "1", "true", "yes", "on":
  579. return true, nil
  580. case "0", "false", "no", "off":
  581. return false, nil
  582. default:
  583. return true, fmt.Errorf("invalid include_proxies value: %s", raw)
  584. }
  585. }
  586. func validateDataHeader(payload DataPayload) error {
  587. if payload.Type != "" && payload.Type != dataType && payload.Type != legacyDataType {
  588. return fmt.Errorf("unsupported data type: %s", payload.Type)
  589. }
  590. if payload.Version != 0 && payload.Version != dataVersion {
  591. return fmt.Errorf("unsupported data version: %d", payload.Version)
  592. }
  593. if payload.Proxies == nil {
  594. return errors.New("proxies is required")
  595. }
  596. if payload.Accounts == nil {
  597. return errors.New("accounts is required")
  598. }
  599. return nil
  600. }
  601. func validateDataProxy(item DataProxy) error {
  602. if strings.TrimSpace(item.Protocol) == "" {
  603. return errors.New("proxy protocol is required")
  604. }
  605. if strings.TrimSpace(item.Host) == "" {
  606. return errors.New("proxy host is required")
  607. }
  608. if item.Port <= 0 || item.Port > 65535 {
  609. return errors.New("proxy port is invalid")
  610. }
  611. switch item.Protocol {
  612. case "http", "https", "socks5", "socks5h":
  613. default:
  614. return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol)
  615. }
  616. if item.Status != "" {
  617. normalizedStatus := normalizeProxyStatus(item.Status)
  618. if normalizedStatus != service.StatusActive && normalizedStatus != "inactive" {
  619. return fmt.Errorf("proxy status is invalid: %s", item.Status)
  620. }
  621. }
  622. return nil
  623. }
  624. func validateDataAccount(item DataAccount) error {
  625. if strings.TrimSpace(item.Name) == "" {
  626. return errors.New("account name is required")
  627. }
  628. if strings.TrimSpace(item.Platform) == "" {
  629. return errors.New("account platform is required")
  630. }
  631. if strings.TrimSpace(item.Type) == "" {
  632. return errors.New("account type is required")
  633. }
  634. if len(item.Credentials) == 0 {
  635. return errors.New("account credentials is required")
  636. }
  637. switch item.Type {
  638. case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream:
  639. default:
  640. return fmt.Errorf("account type is invalid: %s", item.Type)
  641. }
  642. if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
  643. return errors.New("rate_multiplier must be >= 0")
  644. }
  645. if item.Concurrency < 0 {
  646. return errors.New("concurrency must be >= 0")
  647. }
  648. if item.Priority < 0 {
  649. return errors.New("priority must be >= 0")
  650. }
  651. return nil
  652. }
  653. func defaultProxyName(name string) string {
  654. if strings.TrimSpace(name) == "" {
  655. return "imported-proxy"
  656. }
  657. return name
  658. }
  659. // enrichCredentialsFromIDToken performs best-effort extraction of user info fields
  660. // (email, plan_type, chatgpt_account_id, etc.) from id_token in credentials.
  661. // Only applies to OpenAI OAuth accounts. Skips expired token errors silently.
  662. // Existing credential values are never overwritten — only missing fields are filled.
  663. func enrichCredentialsFromIDToken(item *DataAccount) {
  664. if item.Credentials == nil {
  665. return
  666. }
  667. // Only enrich OpenAI OAuth accounts
  668. platform := strings.ToLower(strings.TrimSpace(item.Platform))
  669. if platform != service.PlatformOpenAI {
  670. return
  671. }
  672. if strings.ToLower(strings.TrimSpace(item.Type)) != service.AccountTypeOAuth {
  673. return
  674. }
  675. idToken, _ := item.Credentials["id_token"].(string)
  676. if strings.TrimSpace(idToken) == "" {
  677. return
  678. }
  679. // DecodeIDToken skips expiry validation — safe for imported data
  680. claims, err := openai.DecodeIDToken(idToken)
  681. if err != nil {
  682. slog.Debug("import_enrich_id_token_decode_failed", "account", item.Name, "error", err)
  683. return
  684. }
  685. userInfo := claims.GetUserInfo()
  686. if userInfo == nil {
  687. return
  688. }
  689. // Fill missing fields only (never overwrite existing values)
  690. setIfMissing := func(key, value string) {
  691. if value == "" {
  692. return
  693. }
  694. if existing, _ := item.Credentials[key].(string); existing == "" {
  695. item.Credentials[key] = value
  696. }
  697. }
  698. setIfMissing("email", userInfo.Email)
  699. setIfMissing("plan_type", userInfo.PlanType)
  700. setIfMissing("chatgpt_account_id", userInfo.ChatGPTAccountID)
  701. setIfMissing("chatgpt_user_id", userInfo.ChatGPTUserID)
  702. setIfMissing("organization_id", userInfo.OrganizationID)
  703. }
  704. func normalizeProxyStatus(status string) string {
  705. normalized := strings.TrimSpace(strings.ToLower(status))
  706. switch normalized {
  707. case "":
  708. return ""
  709. case service.StatusActive:
  710. return service.StatusActive
  711. case "inactive", service.StatusDisabled:
  712. return "inactive"
  713. case "expired":
  714. // 导入 expired 代理按 inactive 处理,避免导入即触发到期改投逻辑
  715. return "inactive"
  716. default:
  717. return normalized
  718. }
  719. }