| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785 |
- package admin
- import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "time"
- "log/slog"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- const (
- dataType = "sub2api-data"
- legacyDataType = "sub2api-bundle"
- dataVersion = 1
- dataPageCap = 1000
- )
- type DataPayload struct {
- Type string `json:"type,omitempty"`
- Version int `json:"version,omitempty"`
- ExportedAt string `json:"exported_at"`
- Proxies []DataProxy `json:"proxies"`
- Accounts []DataAccount `json:"accounts"`
- // SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
- // 导入侧忽略该字段;omitempty 保持向后兼容。
- SkippedShadows int `json:"skipped_shadows,omitempty"`
- }
- type DataProxy struct {
- ProxyKey string `json:"proxy_key"`
- Name string `json:"name"`
- Protocol string `json:"protocol"`
- Host string `json:"host"`
- Port int `json:"port"`
- Username string `json:"username,omitempty"`
- Password string `json:"password,omitempty"`
- Status string `json:"status"`
- ExpiresAt *int64 `json:"expires_at,omitempty"` // unix 秒,与 DataAccount.ExpiresAt 风格一致
- FallbackMode string `json:"fallback_mode,omitempty"` // none/direct/proxy
- BackupProxyName string `json:"backup_proxy_name,omitempty"` // 备用代理 name(跨实例按 name 反查)
- ExpiryWarnDays int `json:"expiry_warn_days,omitempty"`
- }
- // DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
- // Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
- // 应新增独立结构而非修改这里。
- // 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
- // 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
- // 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
- // (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
- type DataAccount struct {
- Name string `json:"name"`
- Notes *string `json:"notes,omitempty"`
- Platform string `json:"platform"`
- Type string `json:"type"`
- Credentials map[string]any `json:"credentials"`
- Extra map[string]any `json:"extra,omitempty"`
- ProxyKey *string `json:"proxy_key,omitempty"`
- Concurrency int `json:"concurrency"`
- Priority int `json:"priority"`
- RateMultiplier *float64 `json:"rate_multiplier,omitempty"`
- ExpiresAt *int64 `json:"expires_at,omitempty"`
- AutoPauseOnExpired *bool `json:"auto_pause_on_expired,omitempty"`
- }
- type DataImportRequest struct {
- Data DataPayload `json:"data"`
- SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
- }
- type DataImportResult struct {
- ProxyCreated int `json:"proxy_created"`
- ProxyReused int `json:"proxy_reused"`
- ProxyFailed int `json:"proxy_failed"`
- AccountCreated int `json:"account_created"`
- AccountFailed int `json:"account_failed"`
- Errors []DataImportError `json:"errors,omitempty"`
- }
- type DataImportError struct {
- Kind string `json:"kind"`
- Name string `json:"name,omitempty"`
- ProxyKey string `json:"proxy_key,omitempty"`
- Message string `json:"message"`
- }
- func buildProxyKey(protocol, host string, port int, username, password string) string {
- return fmt.Sprintf("%s|%s|%d|%s|%s", strings.TrimSpace(protocol), strings.TrimSpace(host), port, strings.TrimSpace(username), strings.TrimSpace(password))
- }
- func (h *AccountHandler) ExportData(c *gin.Context) {
- ctx := c.Request.Context()
- selectedIDs, err := parseAccountIDs(c)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- accounts, err := h.resolveExportAccounts(ctx, selectedIDs, c)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
- // 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
- // status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
- // 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
- skippedShadows := 0
- exportable := make([]service.Account, 0, len(accounts))
- for i := range accounts {
- if accounts[i].IsCredentialShadow() {
- skippedShadows++
- continue
- }
- exportable = append(exportable, accounts[i])
- }
- accounts = exportable
- if skippedShadows > 0 {
- slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
- }
- includeProxies, err := parseIncludeProxies(c)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- var proxies []service.Proxy
- if includeProxies {
- proxies, err = h.resolveExportProxies(ctx, accounts)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- } else {
- proxies = []service.Proxy{}
- }
- // 构建 id→name 映射,用于导出备用代理 name
- proxyNameByID := make(map[int64]string, len(proxies))
- for i := range proxies {
- proxyNameByID[proxies[i].ID] = proxies[i].Name
- }
- proxyKeyByID := make(map[int64]string, len(proxies))
- dataProxies := make([]DataProxy, 0, len(proxies))
- for i := range proxies {
- p := proxies[i]
- key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
- proxyKeyByID[p.ID] = key
- var expiresAt *int64
- if p.ExpiresAt != nil {
- v := p.ExpiresAt.Unix()
- expiresAt = &v
- }
- var backupProxyName string
- if p.BackupProxyID != nil {
- backupProxyName = proxyNameByID[*p.BackupProxyID]
- }
- dataProxies = append(dataProxies, DataProxy{
- ProxyKey: key,
- Name: p.Name,
- Protocol: p.Protocol,
- Host: p.Host,
- Port: p.Port,
- Username: p.Username,
- Password: p.Password,
- Status: p.Status,
- ExpiresAt: expiresAt,
- FallbackMode: p.FallbackMode,
- BackupProxyName: backupProxyName,
- ExpiryWarnDays: p.ExpiryWarnDays,
- })
- }
- dataAccounts := make([]DataAccount, 0, len(accounts))
- for i := range accounts {
- acc := accounts[i]
- var proxyKey *string
- if acc.ProxyID != nil {
- if key, ok := proxyKeyByID[*acc.ProxyID]; ok {
- proxyKey = &key
- }
- }
- var expiresAt *int64
- if acc.ExpiresAt != nil {
- v := acc.ExpiresAt.Unix()
- expiresAt = &v
- }
- dataAccounts = append(dataAccounts, DataAccount{
- Name: acc.Name,
- Notes: acc.Notes,
- Platform: acc.Platform,
- Type: acc.Type,
- Credentials: acc.Credentials,
- Extra: acc.Extra,
- ProxyKey: proxyKey,
- Concurrency: acc.Concurrency,
- Priority: acc.Priority,
- RateMultiplier: acc.RateMultiplier,
- ExpiresAt: expiresAt,
- AutoPauseOnExpired: &acc.AutoPauseOnExpired,
- })
- }
- payload := DataPayload{
- ExportedAt: time.Now().UTC().Format(time.RFC3339),
- Proxies: dataProxies,
- Accounts: dataAccounts,
- SkippedShadows: skippedShadows,
- }
- response.Success(c, payload)
- }
- func (h *AccountHandler) ImportData(c *gin.Context) {
- var req DataImportRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if err := validateDataHeader(req.Data); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- executeAdminIdempotentJSON(c, "admin.accounts.import_data", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
- return h.importData(ctx, req)
- })
- }
- func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) (DataImportResult, error) {
- skipDefaultGroupBind := true
- if req.SkipDefaultGroupBind != nil {
- skipDefaultGroupBind = *req.SkipDefaultGroupBind
- }
- dataPayload := req.Data
- result := DataImportResult{}
- existingProxies, err := h.listAllProxies(ctx)
- if err != nil {
- return result, err
- }
- proxyKeyToID := make(map[string]int64, len(existingProxies))
- // proxyNameToID 用于 backup_proxy_name 反查:DB 已有 + 本批次新建均会写入
- proxyNameToID := make(map[string]int64, len(existingProxies))
- for i := range existingProxies {
- p := existingProxies[i]
- key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
- proxyKeyToID[key] = p.ID
- if p.Name != "" {
- proxyNameToID[p.Name] = p.ID
- }
- }
- for i := range dataPayload.Proxies {
- item := dataPayload.Proxies[i]
- key := item.ProxyKey
- if key == "" {
- key = buildProxyKey(item.Protocol, item.Host, item.Port, item.Username, item.Password)
- }
- if err := validateDataProxy(item); err != nil {
- result.ProxyFailed++
- result.Errors = append(result.Errors, DataImportError{
- Kind: "proxy",
- Name: item.Name,
- ProxyKey: key,
- Message: err.Error(),
- })
- continue
- }
- normalizedStatus := normalizeProxyStatus(item.Status)
- if existingID, ok := proxyKeyToID[key]; ok {
- proxyKeyToID[key] = existingID
- result.ProxyReused++
- if normalizedStatus != "" {
- if proxy, getErr := h.adminService.GetProxy(ctx, existingID); getErr == nil && proxy != nil && proxy.Status != normalizedStatus {
- // 同步 status 时传入完整字段,避免零值覆盖已存在代理的有效期/fallback 配置。
- var existingExpiresAt *time.Time
- if item.ExpiresAt != nil {
- t := time.Unix(*item.ExpiresAt, 0).UTC()
- existingExpiresAt = &t
- }
- existingFallbackMode := item.FallbackMode
- if existingFallbackMode == "" {
- existingFallbackMode = service.FallbackModeNone
- }
- var existingBackupProxyID *int64
- if item.BackupProxyName != "" {
- if bid, ok := proxyNameToID[item.BackupProxyName]; ok {
- existingBackupProxyID = &bid
- }
- }
- _, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
- Status: normalizedStatus,
- ExpiresAt: existingExpiresAt,
- ClearExpiresAt: existingExpiresAt == nil,
- FallbackMode: existingFallbackMode,
- BackupProxyID: existingBackupProxyID,
- ClearBackupID: existingBackupProxyID == nil,
- ExpiryWarnDays: &item.ExpiryWarnDays,
- Name: proxy.Name,
- Protocol: proxy.Protocol,
- Host: proxy.Host,
- Port: proxy.Port,
- Username: proxy.Username,
- Password: proxy.Password,
- })
- }
- }
- continue
- }
- // 解析 expires_at(unix 秒 → *time.Time)
- var expiresAt *time.Time
- if item.ExpiresAt != nil {
- t := time.Unix(*item.ExpiresAt, 0).UTC()
- expiresAt = &t
- }
- // 解析 backup_proxy_name → backup_proxy_id
- fallbackMode := item.FallbackMode
- var backupProxyID *int64
- if item.BackupProxyName != "" {
- if bid, ok := proxyNameToID[item.BackupProxyName]; ok {
- backupProxyID = &bid
- } else {
- // 查不到备用代理:降级 fallback_mode=none,记录 warning
- fallbackMode = service.FallbackModeNone
- result.Errors = append(result.Errors, DataImportError{
- Kind: "proxy",
- Name: item.Name,
- ProxyKey: key,
- Message: fmt.Sprintf("backup_proxy_name %q not found, fallback_mode downgraded to none", item.BackupProxyName),
- })
- }
- }
- created, createErr := h.adminService.CreateProxy(ctx, &service.CreateProxyInput{
- Name: defaultProxyName(item.Name),
- Protocol: item.Protocol,
- Host: item.Host,
- Port: item.Port,
- Username: item.Username,
- Password: item.Password,
- ExpiresAt: expiresAt,
- FallbackMode: fallbackMode,
- BackupProxyID: backupProxyID,
- ExpiryWarnDays: item.ExpiryWarnDays,
- })
- if createErr != nil {
- result.ProxyFailed++
- result.Errors = append(result.Errors, DataImportError{
- Kind: "proxy",
- Name: item.Name,
- ProxyKey: key,
- Message: createErr.Error(),
- })
- continue
- }
- proxyKeyToID[key] = created.ID
- // 把新建代理的 name 也加入反查表,供后续批内代理引用
- if created.Name != "" {
- proxyNameToID[created.Name] = created.ID
- }
- result.ProxyCreated++
- if normalizedStatus != "" && normalizedStatus != created.Status {
- // 新建后同步 status 时,传入完整字段,避免零值覆盖刚创建的有效期/fallback 配置。
- _, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
- Status: normalizedStatus,
- ExpiresAt: expiresAt,
- ClearExpiresAt: expiresAt == nil,
- FallbackMode: fallbackMode,
- BackupProxyID: backupProxyID,
- ClearBackupID: backupProxyID == nil,
- ExpiryWarnDays: &item.ExpiryWarnDays,
- Name: created.Name,
- Protocol: created.Protocol,
- Host: created.Host,
- Port: created.Port,
- Username: created.Username,
- Password: created.Password,
- })
- }
- }
- // 收集需要异步设置隐私的 Antigravity OAuth 账号
- var privacyAccounts []*service.Account
- for i := range dataPayload.Accounts {
- item := dataPayload.Accounts[i]
- if err := validateDataAccount(item); err != nil {
- result.AccountFailed++
- result.Errors = append(result.Errors, DataImportError{
- Kind: "account",
- Name: item.Name,
- Message: err.Error(),
- })
- continue
- }
- var proxyID *int64
- if item.ProxyKey != nil && *item.ProxyKey != "" {
- if id, ok := proxyKeyToID[*item.ProxyKey]; ok {
- proxyID = &id
- } else {
- result.AccountFailed++
- result.Errors = append(result.Errors, DataImportError{
- Kind: "account",
- Name: item.Name,
- ProxyKey: *item.ProxyKey,
- Message: "proxy_key not found",
- })
- continue
- }
- }
- enrichCredentialsFromIDToken(&item)
- accountInput := &service.CreateAccountInput{
- Name: item.Name,
- Notes: item.Notes,
- Platform: item.Platform,
- Type: item.Type,
- Credentials: item.Credentials,
- Extra: item.Extra,
- ProxyID: proxyID,
- Concurrency: item.Concurrency,
- Priority: item.Priority,
- RateMultiplier: item.RateMultiplier,
- GroupIDs: nil,
- ExpiresAt: item.ExpiresAt,
- AutoPauseOnExpired: item.AutoPauseOnExpired,
- SkipDefaultGroupBind: skipDefaultGroupBind,
- }
- created, err := h.adminService.CreateAccount(ctx, accountInput)
- if err != nil {
- result.AccountFailed++
- result.Errors = append(result.Errors, DataImportError{
- Kind: "account",
- Name: item.Name,
- Message: err.Error(),
- })
- continue
- }
- // 收集 Antigravity OAuth 账号,稍后异步设置隐私
- if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
- privacyAccounts = append(privacyAccounts, created)
- }
- h.scheduleGrokImportProbe(created)
- result.AccountCreated++
- }
- // 异步设置 Antigravity 隐私,避免大量导入时阻塞请求
- if len(privacyAccounts) > 0 {
- adminSvc := h.adminService
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("import_antigravity_privacy_panic", "recover", r)
- }
- }()
- bgCtx := context.Background()
- for _, acc := range privacyAccounts {
- adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
- }
- slog.Info("import_antigravity_privacy_done", "count", len(privacyAccounts))
- }()
- }
- return result, nil
- }
- func (h *AccountHandler) listAllProxies(ctx context.Context) ([]service.Proxy, error) {
- page := 1
- pageSize := dataPageCap
- var out []service.Proxy
- for {
- items, total, err := h.adminService.ListProxies(ctx, page, pageSize, "", "", "", "created_at", "desc")
- if err != nil {
- return nil, err
- }
- out = append(out, items...)
- if len(out) >= int(total) || len(items) == 0 {
- break
- }
- page++
- }
- return out, nil
- }
- func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode, sortBy, sortOrder string) ([]service.Account, error) {
- page := 1
- pageSize := dataPageCap
- var out []service.Account
- for {
- items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
- if err != nil {
- return nil, err
- }
- out = append(out, items...)
- if len(out) >= int(total) || len(items) == 0 {
- break
- }
- page++
- }
- return out, nil
- }
- func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) {
- if len(ids) > 0 {
- accounts, err := h.adminService.GetAccountsByIDs(ctx, ids)
- if err != nil {
- return nil, err
- }
- out := make([]service.Account, 0, len(accounts))
- for _, acc := range accounts {
- if acc == nil {
- continue
- }
- out = append(out, *acc)
- }
- return out, nil
- }
- platform := c.Query("platform")
- accountType := c.Query("type")
- status := c.Query("status")
- privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
- search := strings.TrimSpace(c.Query("search"))
- sortBy := c.DefaultQuery("sort_by", "name")
- sortOrder := c.DefaultQuery("sort_order", "asc")
- if len(search) > 100 {
- search = search[:100]
- }
- groupID := int64(0)
- if groupIDStr := c.Query("group"); groupIDStr != "" {
- if groupIDStr == accountListGroupUngroupedQueryValue {
- groupID = service.AccountListGroupUngrouped
- } else {
- parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64)
- if parseErr != nil || parsedGroupID <= 0 {
- return nil, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")
- }
- groupID = parsedGroupID
- }
- }
- return h.listAccountsFiltered(ctx, platform, accountType, status, search, groupID, privacyMode, sortBy, sortOrder)
- }
- func (h *AccountHandler) resolveExportProxies(ctx context.Context, accounts []service.Account) ([]service.Proxy, error) {
- if len(accounts) == 0 {
- return []service.Proxy{}, nil
- }
- seen := make(map[int64]struct{})
- ids := make([]int64, 0)
- for i := range accounts {
- if accounts[i].ProxyID == nil {
- continue
- }
- id := *accounts[i].ProxyID
- if id <= 0 {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- ids = append(ids, id)
- }
- if len(ids) == 0 {
- return []service.Proxy{}, nil
- }
- return h.adminService.GetProxiesByIDs(ctx, ids)
- }
- func parseAccountIDs(c *gin.Context) ([]int64, error) {
- values := c.QueryArray("ids")
- if len(values) == 0 {
- raw := strings.TrimSpace(c.Query("ids"))
- if raw != "" {
- values = []string{raw}
- }
- }
- if len(values) == 0 {
- return nil, nil
- }
- ids := make([]int64, 0, len(values))
- for _, item := range values {
- for _, part := range strings.Split(item, ",") {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- id, err := strconv.ParseInt(part, 10, 64)
- if err != nil || id <= 0 {
- return nil, fmt.Errorf("invalid account id: %s", part)
- }
- ids = append(ids, id)
- }
- }
- return ids, nil
- }
- func parseIncludeProxies(c *gin.Context) (bool, error) {
- raw := strings.TrimSpace(strings.ToLower(c.Query("include_proxies")))
- if raw == "" {
- return true, nil
- }
- switch raw {
- case "1", "true", "yes", "on":
- return true, nil
- case "0", "false", "no", "off":
- return false, nil
- default:
- return true, fmt.Errorf("invalid include_proxies value: %s", raw)
- }
- }
- func validateDataHeader(payload DataPayload) error {
- if payload.Type != "" && payload.Type != dataType && payload.Type != legacyDataType {
- return fmt.Errorf("unsupported data type: %s", payload.Type)
- }
- if payload.Version != 0 && payload.Version != dataVersion {
- return fmt.Errorf("unsupported data version: %d", payload.Version)
- }
- if payload.Proxies == nil {
- return errors.New("proxies is required")
- }
- if payload.Accounts == nil {
- return errors.New("accounts is required")
- }
- return nil
- }
- func validateDataProxy(item DataProxy) error {
- if strings.TrimSpace(item.Protocol) == "" {
- return errors.New("proxy protocol is required")
- }
- if strings.TrimSpace(item.Host) == "" {
- return errors.New("proxy host is required")
- }
- if item.Port <= 0 || item.Port > 65535 {
- return errors.New("proxy port is invalid")
- }
- switch item.Protocol {
- case "http", "https", "socks5", "socks5h":
- default:
- return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol)
- }
- if item.Status != "" {
- normalizedStatus := normalizeProxyStatus(item.Status)
- if normalizedStatus != service.StatusActive && normalizedStatus != "inactive" {
- return fmt.Errorf("proxy status is invalid: %s", item.Status)
- }
- }
- return nil
- }
- func validateDataAccount(item DataAccount) error {
- if strings.TrimSpace(item.Name) == "" {
- return errors.New("account name is required")
- }
- if strings.TrimSpace(item.Platform) == "" {
- return errors.New("account platform is required")
- }
- if strings.TrimSpace(item.Type) == "" {
- return errors.New("account type is required")
- }
- if len(item.Credentials) == 0 {
- return errors.New("account credentials is required")
- }
- switch item.Type {
- case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream:
- default:
- return fmt.Errorf("account type is invalid: %s", item.Type)
- }
- if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
- return errors.New("rate_multiplier must be >= 0")
- }
- if item.Concurrency < 0 {
- return errors.New("concurrency must be >= 0")
- }
- if item.Priority < 0 {
- return errors.New("priority must be >= 0")
- }
- return nil
- }
- func defaultProxyName(name string) string {
- if strings.TrimSpace(name) == "" {
- return "imported-proxy"
- }
- return name
- }
- // enrichCredentialsFromIDToken performs best-effort extraction of user info fields
- // (email, plan_type, chatgpt_account_id, etc.) from id_token in credentials.
- // Only applies to OpenAI OAuth accounts. Skips expired token errors silently.
- // Existing credential values are never overwritten — only missing fields are filled.
- func enrichCredentialsFromIDToken(item *DataAccount) {
- if item.Credentials == nil {
- return
- }
- // Only enrich OpenAI OAuth accounts
- platform := strings.ToLower(strings.TrimSpace(item.Platform))
- if platform != service.PlatformOpenAI {
- return
- }
- if strings.ToLower(strings.TrimSpace(item.Type)) != service.AccountTypeOAuth {
- return
- }
- idToken, _ := item.Credentials["id_token"].(string)
- if strings.TrimSpace(idToken) == "" {
- return
- }
- // DecodeIDToken skips expiry validation — safe for imported data
- claims, err := openai.DecodeIDToken(idToken)
- if err != nil {
- slog.Debug("import_enrich_id_token_decode_failed", "account", item.Name, "error", err)
- return
- }
- userInfo := claims.GetUserInfo()
- if userInfo == nil {
- return
- }
- // Fill missing fields only (never overwrite existing values)
- setIfMissing := func(key, value string) {
- if value == "" {
- return
- }
- if existing, _ := item.Credentials[key].(string); existing == "" {
- item.Credentials[key] = value
- }
- }
- setIfMissing("email", userInfo.Email)
- setIfMissing("plan_type", userInfo.PlanType)
- setIfMissing("chatgpt_account_id", userInfo.ChatGPTAccountID)
- setIfMissing("chatgpt_user_id", userInfo.ChatGPTUserID)
- setIfMissing("organization_id", userInfo.OrganizationID)
- }
- func normalizeProxyStatus(status string) string {
- normalized := strings.TrimSpace(strings.ToLower(status))
- switch normalized {
- case "":
- return ""
- case service.StatusActive:
- return service.StatusActive
- case "inactive", service.StatusDisabled:
- return "inactive"
- case "expired":
- // 导入 expired 代理按 inactive 处理,避免导入即触发到期改投逻辑
- return "inactive"
- default:
- return normalized
- }
- }
|