feat: Implement the ChatGPT Owner Demotion Console with new frontend and backend components.

This commit is contained in:
2026-02-05 03:13:30 +08:00
parent 61712cf4fb
commit 2bccb06359
6 changed files with 454 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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] + "..."
}