From 2bccb06359bb7d88f0873ac8dc05f30d4dc760c3 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Thu, 5 Feb 2026 03:13:30 +0800 Subject: [PATCH] feat: Implement the ChatGPT Owner Demotion Console with new frontend and backend components. --- backend/cmd/main.go | 28 ++- backend/internal/api/demote.go | 36 ++++ backend/internal/api/team_process.go | 23 ++- backend/internal/config/config.go | 8 + backend/internal/demote/demote.go | 292 +++++++++++++++++++++++++++ frontend/src/pages/Config.tsx | 80 +++++++- 6 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 backend/internal/api/demote.go create mode 100644 backend/internal/demote/demote.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 4f2ff50..7e612e6 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -166,6 +166,9 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies)) mux.HandleFunc("/api/codex-proxy/test", api.CORS(api.HandleCodexProxies)) + // Owner 降级 API + mux.HandleFunc("/api/demote/owner", api.CORS(api.HandleDemoteOwner)) + // 嵌入的前端静态文件 if web.IsEmbedded() { webFS := web.GetFileSystem() @@ -237,6 +240,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { "team_reg_proxy_test_ip": getTeamRegProxyTestIP(), "site_name": config.Global.SiteName, "auth_method": config.Global.AuthMethod, + "demote_after_use": config.Global.DemoteAfterUse, "mail_services_count": len(config.Global.MailServices), "mail_services": config.Global.MailServices, }) @@ -244,16 +248,17 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { case http.MethodPut: // 更新配置 var req struct { - S2AApiBase *string `json:"s2a_api_base"` - S2AAdminKey *string `json:"s2a_admin_key"` - Concurrency *int `json:"concurrency"` - Priority *int `json:"priority"` - GroupIDs []int `json:"group_ids"` - ProxyEnabled *bool `json:"proxy_enabled"` - DefaultProxy *string `json:"default_proxy"` - TeamRegProxy *string `json:"team_reg_proxy"` - SiteName *string `json:"site_name"` - AuthMethod *string `json:"auth_method"` + S2AApiBase *string `json:"s2a_api_base"` + S2AAdminKey *string `json:"s2a_admin_key"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + GroupIDs []int `json:"group_ids"` + ProxyEnabled *bool `json:"proxy_enabled"` + DefaultProxy *string `json:"default_proxy"` + TeamRegProxy *string `json:"team_reg_proxy"` + SiteName *string `json:"site_name"` + AuthMethod *string `json:"auth_method"` + DemoteAfterUse *bool `json:"demote_after_use"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.Error(w, http.StatusBadRequest, "请求格式错误") @@ -296,6 +301,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { if req.AuthMethod != nil { config.Global.AuthMethod = *req.AuthMethod } + if req.DemoteAfterUse != nil { + config.Global.DemoteAfterUse = *req.DemoteAfterUse + } // 保存到数据库 (实时生效) if err := config.Update(config.Global); err != nil { diff --git a/backend/internal/api/demote.go b/backend/internal/api/demote.go new file mode 100644 index 0000000..ff46497 --- /dev/null +++ b/backend/internal/api/demote.go @@ -0,0 +1,36 @@ +package api + +import ( + "encoding/json" + "net/http" + + "codex-pool/internal/demote" +) + +// HandleDemoteOwner POST /api/demote/owner - Owner 降级 +func HandleDemoteOwner(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "仅支持 POST") + return + } + + var req demote.DemoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "无效的请求: "+err.Error()) + return + } + + if req.AccessToken == "" { + Error(w, http.StatusBadRequest, "access_token 不能为空") + return + } + + // 默认角色 + if req.Role == "" { + req.Role = "standard-user" + } + + result := demote.DemoteOwner(req) + + Success(w, result) +} diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index 4487096..30cd63a 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -12,6 +12,7 @@ import ( "codex-pool/internal/auth" "codex-pool/internal/config" "codex-pool/internal/database" + "codex-pool/internal/demote" "codex-pool/internal/invite" "codex-pool/internal/logger" "codex-pool/internal/mail" @@ -460,6 +461,24 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul database.Instance.MarkOwnerAsUsed(owner.Email) database.Instance.DeleteTeamOwnerByEmail(owner.Email) logger.Info(fmt.Sprintf("%s 母号已使用并删除: %s", logPrefix, owner.Email), owner.Email, "team") + + // 如果开启了母号降级,尝试降级 + if config.Global != nil && config.Global.DemoteAfterUse { + go func() { + logger.Info(fmt.Sprintf("%s 尝试降级母号...", logPrefix), owner.Email, "team") + result := demote.DemoteOwner(demote.DemoteRequest{ + AccessToken: owner.Token, + AccountID: owner.AccountID, + Role: "standard-user", + Proxy: req.Proxy, + }) + if result.Success { + logger.Success(fmt.Sprintf("%s 母号已降级为普通成员", logPrefix), owner.Email, "team") + } else { + logger.Warning(fmt.Sprintf("%s 母号降级失败: %s", logPrefix, result.Error), owner.Email, "team") + } + }() + } } else { // 失败时恢复为 valid,允许重试 database.Instance.MarkOwnerAsFailed(owner.Email) @@ -658,7 +677,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul config.Global.S2AAdminKey, s2aResp.Data.SessionID, code, - memberEmail, + "team-"+memberEmail, config.Global.Concurrency, config.Global.Priority, config.Global.GroupIDs, @@ -907,7 +926,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul config.Global.S2AAdminKey, s2aResp.Data.SessionID, code, - owner.Email, + "team-"+owner.Email, config.Global.Concurrency, config.Global.Priority, config.Global.GroupIDs, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f1d597b..6889d43 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -49,6 +49,9 @@ type Config struct { // 站点配置 SiteName string `json:"site_name"` + // 母号降级配置 + DemoteAfterUse bool `json:"demote_after_use"` // 母号使用后自动降级 + // 邮箱服务 MailServices []MailServiceConfig `json:"mail_services"` } @@ -189,6 +192,9 @@ func InitFromDB() *Config { } else { cfg.AuthMethod = "browser" // 默认使用浏览器模式 } + if v, _ := configDB.GetConfig("demote_after_use"); v == "true" { + cfg.DemoteAfterUse = true + } Global = cfg return cfg @@ -234,6 +240,8 @@ func SaveToDB() error { configDB.SetConfig("auth_method", cfg.AuthMethod) } + configDB.SetConfig("demote_after_use", strconv.FormatBool(cfg.DemoteAfterUse)) + return nil } diff --git a/backend/internal/demote/demote.go b/backend/internal/demote/demote.go new file mode 100644 index 0000000..1bff79e --- /dev/null +++ b/backend/internal/demote/demote.go @@ -0,0 +1,292 @@ +package demote + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "codex-pool/internal/client" +) + +// DemoteRequest 降级请求 +type DemoteRequest struct { + AccessToken string `json:"access_token"` // JWT Token 或完整的 session JSON + AccountID string `json:"account_id"` // 可选,Team ID + Role string `json:"role"` // 目标角色: standard-user 或 account-admin + Proxy string `json:"proxy"` // 可选,代理 +} + +// DemoteResult 降级结果 +type DemoteResult struct { + Success bool `json:"success"` + Email string `json:"email,omitempty"` + OriginalRole string `json:"original_role,omitempty"` + NewRole string `json:"new_role,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +// SessionData ChatGPT session 数据结构 +type SessionData struct { + AccessToken string `json:"accessToken"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"user"` + Account struct { + ID string `json:"id"` + } `json:"account"` +} + +// JWTPayload JWT 解析结构 +type JWTPayload struct { + Auth struct { + AccountUserID string `json:"chatgpt_account_user_id"` + } `json:"https://api.openai.com/auth"` + Profile struct { + Email string `json:"email"` + } `json:"https://api.openai.com/profile"` +} + +// decodeJWTPayload 解码 JWT 的 payload 部分 +func decodeJWTPayload(token string) (*JWTPayload, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + + payload := parts[1] + // 补齐 base64 padding + if m := len(payload) % 4; m != 0 { + payload += strings.Repeat("=", 4-m) + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + // 尝试标准 base64 + decoded, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT payload: %v", err) + } + } + + var result JWTPayload + if err := json.Unmarshal(decoded, &result); err != nil { + return nil, fmt.Errorf("failed to parse JWT payload: %v", err) + } + + return &result, nil +} + +// extractUserInfo 从 Token 和 Session 中提取用户信息 +func extractUserInfo(token string, session *SessionData) (userID, accountID, email string) { + // 优先从 session 获取(更准确) + if session != nil { + if session.User.ID != "" { + userID = session.User.ID + } + if session.Account.ID != "" { + accountID = session.Account.ID + } + if session.User.Email != "" { + email = session.User.Email + } + + // 如果已经获取到必要信息,直接返回 + if userID != "" && accountID != "" { + return + } + } + + // 备选:从 JWT 解析 + payload, err := decodeJWTPayload(token) + if err != nil { + return + } + + // 解析 chatgpt_account_user_id,格式可能是 user-xxx__account-id + accountUserID := payload.Auth.AccountUserID + if accountUserID != "" { + if strings.Contains(accountUserID, "__") { + parts := strings.Split(accountUserID, "__") + if userID == "" { + userID = parts[0] + } + if accountID == "" && len(parts) > 1 { + accountID = parts[1] + } + } else if userID == "" { + userID = accountUserID + } + } + + if email == "" { + email = payload.Profile.Email + } + + return +} + +// DemoteOwner 执行 Owner 降级 +func DemoteOwner(req DemoteRequest) *DemoteResult { + // 验证角色 + validRoles := map[string]bool{ + "standard-user": true, + "account-admin": true, + } + if !validRoles[req.Role] { + return &DemoteResult{ + Success: false, + Error: "无效的角色,必须是 standard-user 或 account-admin", + } + } + + accessToken := strings.TrimSpace(req.AccessToken) + var session *SessionData + + // 检测是否是完整的 session JSON + if strings.HasPrefix(accessToken, "{") { + if err := json.Unmarshal([]byte(accessToken), &session); err != nil { + return &DemoteResult{ + Success: false, + Error: "无法解析 session JSON: " + err.Error(), + } + } + accessToken = session.AccessToken + if accessToken == "" { + return &DemoteResult{ + Success: false, + Error: "session JSON 中没有 accessToken", + } + } + } + + // 提取用户信息 + userID, accountID, email := extractUserInfo(accessToken, session) + + // 如果请求中指定了 account_id,优先使用 + if req.AccountID != "" { + accountID = req.AccountID + } + + if userID == "" { + return &DemoteResult{ + Success: false, + Error: "无法获取 user_id,请提供完整的 session JSON", + } + } + if accountID == "" { + return &DemoteResult{ + Success: false, + Error: "无法获取 account_id,请提供完整的 session JSON 或指定 account_id", + } + } + + // 创建 TLS 客户端 + var tlsClient *client.TLSClient + var lastErr error + + // 403 重试机制 - 最多 3 次 + for retry := 0; retry < 3; retry++ { + var err error + tlsClient, err = client.New(req.Proxy) + if err != nil { + lastErr = err + continue + } + + // 初始化会话 - 先访问 chatgpt.com 通过 CF 验证 + resp, err := tlsClient.Get("https://chatgpt.com") + if err != nil { + lastErr = err + tlsClient.Close() + continue + } + resp.Body.Close() + + if resp.StatusCode == 403 { + lastErr = fmt.Errorf("Cloudflare 403") + tlsClient.Close() + continue + } + + if resp.StatusCode == 200 { + lastErr = nil + break + } + + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + tlsClient.Close() + } + + if lastErr != nil { + return &DemoteResult{ + Success: false, + Email: email, + Error: fmt.Sprintf("初始化会话失败(已重试3次): %v", lastErr), + } + } + defer tlsClient.Close() + + // 构建降级 API 请求 + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", accountID, userID) + + payload := map[string]string{"role": req.Role} + jsonBody, _ := json.Marshal(payload) + + patchReq, err := http.NewRequest("PATCH", apiURL, bytes.NewReader(jsonBody)) + if err != nil { + return &DemoteResult{ + Success: false, + Email: email, + Error: "创建请求失败: " + err.Error(), + } + } + + patchReq.Header.Set("Authorization", "Bearer "+accessToken) + patchReq.Header.Set("Content-Type", "application/json") + patchReq.Header.Set("Accept", "*/*") + patchReq.Header.Set("Origin", "https://chatgpt.com") + patchReq.Header.Set("Referer", "https://chatgpt.com/") + + resp, err := tlsClient.Do(patchReq) + if err != nil { + return &DemoteResult{ + Success: false, + Email: email, + Error: "请求失败: " + err.Error(), + } + } + defer resp.Body.Close() + + body, _ := client.ReadBodyString(resp) + + if resp.StatusCode == 200 { + roleDisplay := "普通成员" + if req.Role == "account-admin" { + roleDisplay = "管理员" + } + return &DemoteResult{ + Success: true, + Email: email, + NewRole: req.Role, + Message: fmt.Sprintf("成功降级为%s", roleDisplay), + } + } + + return &DemoteResult{ + Success: false, + Email: email, + Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateStr(body, 200)), + } +} + +func truncateStr(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index dd76f21..f1a9589 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -13,7 +13,8 @@ import { Globe, Zap, Monitor, - Network + Network, + UserMinus } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { useConfig } from '../hooks/useConfig' @@ -25,6 +26,8 @@ export default function Config() { const [saving, setSaving] = useState(false) const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser') const [savingAuthMethod, setSavingAuthMethod] = useState(false) + const [demoteAfterUse, setDemoteAfterUse] = useState(false) + const [savingDemote, setSavingDemote] = useState(false) const [proxyPoolCount, setProxyPoolCount] = useState(0) const { toasts, toast, removeToast } = useToast() @@ -40,6 +43,8 @@ export default function Config() { if (data.data.auth_method) { setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser') } + // 加载母号降级开关 + setDemoteAfterUse(data.data.demote_after_use === true) } } catch (error) { console.error('Failed to fetch config:', error) @@ -113,6 +118,33 @@ export default function Config() { } } + // 保存母号降级开关 + const handleToggleDemote = async () => { + setSavingDemote(true) + const newValue = !demoteAfterUse + setDemoteAfterUse(newValue) // 立即更新 UI + try { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ demote_after_use: newValue }), + }) + const data = await res.json() + if (data.code === 0) { + toast.success(newValue ? '母号降级已开启' : '母号降级已关闭') + refreshConfig() + } else { + setDemoteAfterUse(!newValue) // 回滚 + toast.error(data.message || '保存失败') + } + } catch { + setDemoteAfterUse(!newValue) // 回滚 + toast.error('网络错误') + } finally { + setSavingDemote(false) + } + } + const configItems = [ { to: '/config/s2a', @@ -287,6 +319,52 @@ export default function Config() { )} + + {/* 母号降级开关 */} +
+
+
+
+ +
+
+ +

+ 母号入库完成后自动降级为普通成员 +

+
+
+ +
+ {demoteAfterUse && ( +
+
+ + 母号降级已开启 +
+

+ 母号入库完成后会自动调用 API 将其降级为普通成员,可以防止母号被滥用 +

+
+ )} +