feat: Introduce a new monitoring and configuration dashboard with backend API for autopool management and S2A integration.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -101,3 +101,4 @@ check_ban.py
|
||||
backend/codex-pool.exe
|
||||
backend/codex-pool.exe
|
||||
.claude/settings.local.json
|
||||
CodexAuth
|
||||
@@ -232,6 +232,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
"team_reg_proxy_test_status": getTeamRegProxyTestStatus(),
|
||||
"team_reg_proxy_test_ip": getTeamRegProxyTestIP(),
|
||||
"site_name": config.Global.SiteName,
|
||||
"auth_method": config.Global.AuthMethod,
|
||||
"mail_services_count": len(config.Global.MailServices),
|
||||
"mail_services": config.Global.MailServices,
|
||||
})
|
||||
@@ -248,6 +249,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
DefaultProxy *string `json:"default_proxy"`
|
||||
TeamRegProxy *string `json:"team_reg_proxy"`
|
||||
SiteName *string `json:"site_name"`
|
||||
AuthMethod *string `json:"auth_method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||
@@ -287,6 +289,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if req.SiteName != nil {
|
||||
config.Global.SiteName = *req.SiteName
|
||||
}
|
||||
if req.AuthMethod != nil {
|
||||
config.Global.AuthMethod = *req.AuthMethod
|
||||
}
|
||||
|
||||
// 保存到数据库 (实时生效)
|
||||
if err := config.Update(config.Global); err != nil {
|
||||
|
||||
@@ -32,14 +32,14 @@ type TeamProcessRequest struct {
|
||||
// Owner 账号列表
|
||||
Owners []TeamOwner `json:"owners"`
|
||||
// 配置
|
||||
MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数
|
||||
ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量
|
||||
ConcurrentS2A int `json:"concurrent_s2a"` // 入库并发数(默认2)
|
||||
BrowserType string `json:"browser_type"` // "chromedp" 或 "rod"
|
||||
Headless bool `json:"headless"` // 是否无头模式
|
||||
Proxy string `json:"proxy"` // 代理设置
|
||||
IncludeOwner bool `json:"include_owner"` // 母号也入库到 S2A
|
||||
ProcessCount int `json:"process_count"` // 处理数量,0表示全部
|
||||
MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数
|
||||
ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量
|
||||
ConcurrentS2A int `json:"concurrent_s2a"` // 入库并发数(默认2)
|
||||
BrowserType string `json:"browser_type"` // "chromedp" 或 "rod"
|
||||
Headless bool `json:"headless"` // 是否无头模式
|
||||
Proxy string `json:"proxy"` // 代理设置
|
||||
IncludeOwner bool `json:"include_owner"` // 母号也入库到 S2A
|
||||
ProcessCount int `json:"process_count"` // 处理数量,0表示全部
|
||||
}
|
||||
|
||||
// TeamProcessResult 团队处理结果
|
||||
@@ -755,7 +755,11 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
|
||||
|
||||
// 根据配置选择浏览器自动化
|
||||
var code string
|
||||
if req.BrowserType == "rod" {
|
||||
// 根据全局配置决定授权方式
|
||||
if config.Global.AuthMethod == "api" {
|
||||
// 使用纯 API 模式(CodexAuth)
|
||||
code, err = auth.CompleteWithCodexAPI(memberChild.Email, memberChild.Password, teamID, req.Proxy, authLogger)
|
||||
} else if req.BrowserType == "rod" {
|
||||
code, err = auth.CompleteWithRodLogged(s2aResp.Data.AuthURL, memberChild.Email, memberChild.Password, teamID, req.Headless, req.Proxy, authLogger)
|
||||
} else {
|
||||
code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, memberChild.Email, memberChild.Password, teamID, req.Headless, req.Proxy, authLogger)
|
||||
@@ -851,7 +855,11 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
|
||||
}
|
||||
|
||||
var code string
|
||||
if req.BrowserType == "rod" {
|
||||
// 根据全局配置决定授权方式
|
||||
if config.Global.AuthMethod == "api" {
|
||||
// 使用纯 API 模式(CodexAuth)
|
||||
code, err = auth.CompleteWithCodexAPI(owner.Email, owner.Password, teamID, req.Proxy, authLogger)
|
||||
} else if req.BrowserType == "rod" {
|
||||
code, err = auth.CompleteWithRodLogged(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy, authLogger)
|
||||
} else {
|
||||
code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy, authLogger)
|
||||
|
||||
483
backend/internal/auth/codex_api.go
Normal file
483
backend/internal/auth/codex_api.go
Normal file
@@ -0,0 +1,483 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codex-pool/internal/client"
|
||||
)
|
||||
|
||||
// 常量 CodexClientID, CodexRedirectURI, CodexScope 已在 s2a.go 中定义
|
||||
|
||||
// CodexAPIAuth 纯 API 授权 (无浏览器)
|
||||
type CodexAPIAuth struct {
|
||||
client *client.TLSClient
|
||||
email string
|
||||
password string
|
||||
workspaceID string
|
||||
deviceID string
|
||||
sid string
|
||||
sentinelToken string
|
||||
solvedPow string
|
||||
userAgent string
|
||||
logger *AuthLogger
|
||||
}
|
||||
|
||||
// NewCodexAPIAuth 创建 CodexAuth 实例
|
||||
func NewCodexAPIAuth(email, password, workspaceID, proxy string, logger *AuthLogger) (*CodexAPIAuth, error) {
|
||||
tlsClient, err := client.New(proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 TLS 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
return &CodexAPIAuth{
|
||||
client: tlsClient,
|
||||
email: email,
|
||||
password: password,
|
||||
workspaceID: workspaceID,
|
||||
deviceID: generateUUID(),
|
||||
sid: generateUUID(),
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateUUID 生成 UUID v4
|
||||
func generateUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
|
||||
// generateCodeVerifier 生成 PKCE code_verifier
|
||||
func generateCodeVerifier() string {
|
||||
b := make([]byte, 64)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateCodeChallenge 生成 PKCE code_challenge (S256)
|
||||
func generateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generateState 生成 state
|
||||
func generateState() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// fnv1a32 FNV-1a 32-bit hash
|
||||
func fnv1a32(data []byte) uint32 {
|
||||
h := uint32(2166136261)
|
||||
for _, b := range data {
|
||||
h ^= uint32(b)
|
||||
h *= 16777619
|
||||
}
|
||||
h ^= (h >> 16)
|
||||
h *= 2246822507
|
||||
h ^= (h >> 13)
|
||||
h *= 3266489909
|
||||
h ^= (h >> 16)
|
||||
return h
|
||||
}
|
||||
|
||||
// getParseTime 生成 JS Date().toString() 格式的时间字符串
|
||||
func getParseTime() string {
|
||||
now := time.Now()
|
||||
return now.Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
|
||||
}
|
||||
|
||||
// getConfig 生成 PoW 配置数组
|
||||
func (c *CodexAPIAuth) getConfig() []interface{} {
|
||||
return []interface{}{
|
||||
2500 + rand.Intn(1000),
|
||||
getParseTime(),
|
||||
4294967296,
|
||||
0,
|
||||
c.userAgent,
|
||||
"chrome-extension://pgojnojmmhpofjgdmaebadhbocahppod/assets/aW5qZWN0X2hhc2g/aW5qZ",
|
||||
nil,
|
||||
"zh-CN",
|
||||
"zh-CN",
|
||||
0,
|
||||
"canShare−function canShare() { [native code] }",
|
||||
fmt.Sprintf("_reactListening%d", 1000000+rand.Intn(9000000)),
|
||||
"onhashchange",
|
||||
float64(time.Now().UnixNano()/1e6) / 1000.0,
|
||||
c.sid,
|
||||
"",
|
||||
24,
|
||||
time.Now().UnixMilli() - int64(10000+rand.Intn(40000)),
|
||||
}
|
||||
}
|
||||
|
||||
// solvePow 解决 PoW 挑战
|
||||
func (c *CodexAPIAuth) solvePow(seed, difficulty string, cfg []interface{}, maxIterations int) string {
|
||||
seedBytes := []byte(seed)
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
cfg[3] = i
|
||||
cfg[9] = 0
|
||||
|
||||
jsonStr, _ := json.Marshal(cfg)
|
||||
encoded := base64.StdEncoding.EncodeToString(jsonStr)
|
||||
|
||||
h := fnv1a32(append(seedBytes, []byte(encoded)...))
|
||||
hexHash := fmt.Sprintf("%08x", h)
|
||||
|
||||
if hexHash[:len(difficulty)] <= difficulty {
|
||||
return encoded + "~S"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getRequirementsToken 生成初始 token
|
||||
func (c *CodexAPIAuth) getRequirementsToken() string {
|
||||
cfg := c.getConfig()
|
||||
cfg[3] = 0
|
||||
cfg[9] = 0
|
||||
jsonStr, _ := json.Marshal(cfg)
|
||||
encoded := base64.StdEncoding.EncodeToString(jsonStr)
|
||||
return "gAAAAAC" + encoded + "~S"
|
||||
}
|
||||
|
||||
// callSentinelReq 调用 Sentinel 获取 token
|
||||
func (c *CodexAPIAuth) callSentinelReq(flow string) error {
|
||||
initToken := c.getRequirementsToken()
|
||||
payload := map[string]interface{}{
|
||||
"p": initToken,
|
||||
"id": c.deviceID,
|
||||
"flow": flow,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "https://sentinel.openai.com/backend-api/sentinel/req", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sentinel 请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("sentinel 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
respBody, _ := client.ReadBody(resp)
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
ProofOfWork struct {
|
||||
Required bool `json:"required"`
|
||||
Seed string `json:"seed"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
} `json:"proofofwork"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("解析 sentinel 响应失败: %v", err)
|
||||
}
|
||||
|
||||
c.sentinelToken = result.Token
|
||||
|
||||
if result.ProofOfWork.Required {
|
||||
cfg := c.getConfig()
|
||||
solved := c.solvePow(result.ProofOfWork.Seed, result.ProofOfWork.Difficulty, cfg, 5000000)
|
||||
if solved == "" {
|
||||
return fmt.Errorf("PoW 解决失败")
|
||||
}
|
||||
c.solvedPow = "gAAAAAB" + solved
|
||||
} else {
|
||||
c.solvedPow = initToken
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSentinelHeader 构建 sentinel header
|
||||
func (c *CodexAPIAuth) getSentinelHeader(flow string) string {
|
||||
obj := map[string]interface{}{
|
||||
"p": c.solvedPow,
|
||||
"id": c.deviceID,
|
||||
"flow": flow,
|
||||
}
|
||||
if c.sentinelToken != "" {
|
||||
obj["c"] = c.sentinelToken
|
||||
}
|
||||
header, _ := json.Marshal(obj)
|
||||
return string(header)
|
||||
}
|
||||
|
||||
// logStep 记录日志
|
||||
func (c *CodexAPIAuth) logStep(step AuthStep, format string, args ...interface{}) {
|
||||
if c.logger != nil {
|
||||
c.logger.LogStep(step, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// logError 记录错误
|
||||
func (c *CodexAPIAuth) logError(step AuthStep, format string, args ...interface{}) {
|
||||
if c.logger != nil {
|
||||
c.logger.LogError(step, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ObtainAuthorizationCode 获取授权码
|
||||
func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) {
|
||||
c.logStep(StepNavigate, "开始 Codex API 授权流程...")
|
||||
|
||||
// 1. 生成 PKCE 参数
|
||||
codeVerifier := generateCodeVerifier()
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
state := generateState()
|
||||
|
||||
// 2. 构建授权 URL
|
||||
params := url.Values{
|
||||
"client_id": {CodexClientID},
|
||||
"scope": {CodexScope},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {CodexRedirectURI},
|
||||
"code_challenge": {codeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
"originator": {"codex_cli_rs"},
|
||||
}
|
||||
|
||||
authURL := "https://auth.openai.com/oauth/authorize?" + params.Encode()
|
||||
|
||||
// 3. 访问授权页面
|
||||
c.logStep(StepNavigate, "访问授权页面...")
|
||||
req, _ := http.NewRequest("GET", authURL, nil)
|
||||
req.Header.Set("Referer", "https://auth.openai.com/log-in")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logError(StepNavigate, "访问授权页失败: %v", err)
|
||||
return "", fmt.Errorf("访问授权页失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
client.ReadBody(resp) // 消耗响应体
|
||||
referer := resp.Request.URL.String()
|
||||
|
||||
// 4. 提交邮箱
|
||||
c.logStep(StepInputEmail, "提交邮箱: %s", c.email)
|
||||
if err := c.callSentinelReq("login_web_init"); err != nil {
|
||||
c.logError(StepInputEmail, "Sentinel 请求失败: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
emailPayload := map[string]interface{}{
|
||||
"username": map[string]string{
|
||||
"kind": "email",
|
||||
"value": c.email,
|
||||
},
|
||||
}
|
||||
emailBody, _ := json.Marshal(emailPayload)
|
||||
|
||||
req, _ = http.NewRequest("POST", "https://auth.openai.com/api/accounts/authorize/continue", bytes.NewReader(emailBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
req.Header.Set("Referer", referer)
|
||||
req.Header.Set("OpenAI-Sentinel-Token", c.getSentinelHeader("authorize_continue"))
|
||||
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logError(StepInputEmail, "提交邮箱失败: %v", err)
|
||||
return "", fmt.Errorf("提交邮箱失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := client.ReadBody(resp)
|
||||
c.logError(StepInputEmail, "提交邮箱失败: %d - %s", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("提交邮箱失败: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应,检查是否需要密码
|
||||
emailResp, _ := client.ReadBody(resp)
|
||||
var emailResult map[string]interface{}
|
||||
json.Unmarshal(emailResp, &emailResult)
|
||||
|
||||
// 5. 验证密码
|
||||
c.logStep(StepInputPassword, "验证密码...")
|
||||
if err := c.callSentinelReq("authorize_continue__auto"); err != nil {
|
||||
c.logError(StepInputPassword, "Sentinel 请求失败: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
pwdPayload := map[string]string{
|
||||
"username": c.email,
|
||||
"password": c.password,
|
||||
}
|
||||
pwdBody, _ := json.Marshal(pwdPayload)
|
||||
|
||||
req, _ = http.NewRequest("POST", "https://auth.openai.com/api/accounts/password/verify", bytes.NewReader(pwdBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
req.Header.Set("Referer", referer)
|
||||
req.Header.Set("OpenAI-Sentinel-Token", c.getSentinelHeader("password_verify"))
|
||||
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logError(StepInputPassword, "验证密码失败: %v", err)
|
||||
return "", fmt.Errorf("验证密码失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := client.ReadBody(resp)
|
||||
c.logError(StepInputPassword, "密码验证失败: %d - %s", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("密码验证失败: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 6. 选择工作区
|
||||
c.logStep(StepSelectWorkspace, "选择工作区: %s", c.workspaceID)
|
||||
if err := c.callSentinelReq("password_verify__auto"); err != nil {
|
||||
c.logError(StepSelectWorkspace, "Sentinel 请求失败: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
wsPayload := map[string]string{
|
||||
"workspace_id": c.workspaceID,
|
||||
}
|
||||
wsBody, _ := json.Marshal(wsPayload)
|
||||
|
||||
req, _ = http.NewRequest("POST", "https://auth.openai.com/api/accounts/workspace/select", bytes.NewReader(wsBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
req.Header.Set("Referer", referer)
|
||||
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logError(StepSelectWorkspace, "选择工作区失败: %v", err)
|
||||
return "", fmt.Errorf("选择工作区失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := client.ReadBody(resp)
|
||||
c.logError(StepSelectWorkspace, "选择工作区失败: %d - %s", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("选择工作区失败: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析 continue_url
|
||||
wsResp, _ := client.ReadBody(resp)
|
||||
var wsResult struct {
|
||||
ContinueURL string `json:"continue_url"`
|
||||
}
|
||||
if err := json.Unmarshal(wsResp, &wsResult); err != nil || wsResult.ContinueURL == "" {
|
||||
c.logError(StepSelectWorkspace, "未获取到 continue_url")
|
||||
return "", fmt.Errorf("未获取到 continue_url")
|
||||
}
|
||||
|
||||
// 7. 跟随重定向获取授权码
|
||||
c.logStep(StepWaitCallback, "跟随重定向...")
|
||||
continueURL := wsResult.ContinueURL
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
req, _ = http.NewRequest("GET", continueURL, nil)
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 301 && resp.StatusCode <= 308 {
|
||||
if strings.Contains(location, "localhost:1455") {
|
||||
// 提取授权码
|
||||
code := ExtractCodeFromCallbackURL(location)
|
||||
if code != "" {
|
||||
c.logStep(StepComplete, "授权成功,获取到授权码")
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
continueURL = location
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.logError(StepWaitCallback, "未能获取授权码")
|
||||
return "", fmt.Errorf("未能获取授权码")
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens 用授权码换取 tokens
|
||||
func (c *CodexAPIAuth) ExchangeCodeForTokens(code, codeVerifier string) (*CodexTokens, error) {
|
||||
payload := map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CodexClientID,
|
||||
"code_verifier": codeVerifier,
|
||||
"code": code,
|
||||
"redirect_uri": CodexRedirectURI,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "https://auth.openai.com/oauth/token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token 交换失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := client.ReadBody(resp)
|
||||
return nil, fmt.Errorf("token 交换失败: %d - %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
respBody, _ := client.ReadBody(resp)
|
||||
var tokens CodexTokens
|
||||
if err := json.Unmarshal(respBody, &tokens); err != nil {
|
||||
return nil, fmt.Errorf("解析 token 失败: %v", err)
|
||||
}
|
||||
|
||||
if tokens.ExpiresIn > 0 {
|
||||
expiredAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||||
tokens.ExpiredAt = expiredAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return &tokens, nil
|
||||
}
|
||||
|
||||
// CodexTokens 结构体已在 s2a.go 中定义
|
||||
// CompleteWithCodexAPI 使用纯 API 方式完成授权
|
||||
func CompleteWithCodexAPI(email, password, workspaceID, proxy string, logger *AuthLogger) (string, error) {
|
||||
if logger != nil {
|
||||
logger.LogStep(StepBrowserStart, "使用 CodexAuth API 模式...")
|
||||
}
|
||||
|
||||
auth, err := NewCodexAPIAuth(email, password, workspaceID, proxy, logger)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.LogError(StepBrowserStart, "创建 CodexAuth 失败: %v", err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
code, err := auth.ObtainAuthorizationCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
@@ -39,6 +39,9 @@ type Config struct {
|
||||
DefaultProxy string `json:"default_proxy"`
|
||||
TeamRegProxy string `json:"team_reg_proxy"` // Team 注册使用的代理
|
||||
|
||||
// 授权方式配置
|
||||
AuthMethod string `json:"auth_method"` // "api" 或 "browser"
|
||||
|
||||
// 自动化配置
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
AccountsPath string `json:"accounts_path"`
|
||||
@@ -181,6 +184,11 @@ func InitFromDB() *Config {
|
||||
if v, _ := configDB.GetConfig("site_name"); v != "" {
|
||||
cfg.SiteName = v
|
||||
}
|
||||
if v, _ := configDB.GetConfig("auth_method"); v != "" {
|
||||
cfg.AuthMethod = v
|
||||
} else {
|
||||
cfg.AuthMethod = "browser" // 默认使用浏览器模式
|
||||
}
|
||||
|
||||
Global = cfg
|
||||
return cfg
|
||||
@@ -222,6 +230,10 @@ func SaveToDB() error {
|
||||
configDB.SetConfig("site_name", cfg.SiteName)
|
||||
}
|
||||
|
||||
if cfg.AuthMethod != "" {
|
||||
configDB.SetConfig("auth_method", cfg.AuthMethod)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Globe,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
Zap,
|
||||
Monitor
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
@@ -33,6 +35,8 @@ export default function Config() {
|
||||
const [teamRegProxyStatus, setTeamRegProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown')
|
||||
const [proxyOriginIP, setProxyOriginIP] = useState('')
|
||||
const [teamRegProxyIP, setTeamRegProxyIP] = useState('')
|
||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser')
|
||||
const [savingAuthMethod, setSavingAuthMethod] = useState(false)
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
|
||||
// 加载站点名称和代理配置
|
||||
@@ -61,6 +65,10 @@ export default function Config() {
|
||||
if (data.data.team_reg_proxy_test_ip) {
|
||||
setTeamRegProxyIP(data.data.team_reg_proxy_test_ip)
|
||||
}
|
||||
// 加载授权方式
|
||||
if (data.data.auth_method) {
|
||||
setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
@@ -204,6 +212,33 @@ export default function Config() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存授权方式
|
||||
const handleSaveAuthMethod = async (method: 'api' | 'browser') => {
|
||||
setSavingAuthMethod(true)
|
||||
const previousMethod = authMethod
|
||||
setAuthMethod(method) // 立即更新 UI
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auth_method: method }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
toast.success(`授权方式已切换为 ${method === 'api' ? 'CodexAuth API' : '浏览器模拟'}`)
|
||||
refreshConfig()
|
||||
} else {
|
||||
setAuthMethod(previousMethod) // 回滚
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
setAuthMethod(previousMethod) // 回滚
|
||||
toast.error('网络错误')
|
||||
} finally {
|
||||
setSavingAuthMethod(false)
|
||||
}
|
||||
}
|
||||
|
||||
const configItems = [
|
||||
{
|
||||
to: '/config/s2a',
|
||||
@@ -311,7 +346,91 @@ export default function Config() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 代理地址配置 */}
|
||||
{/* 授权方式配置 */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||
授权方式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* API 模式 - CodexAuth */}
|
||||
<button
|
||||
onClick={() => handleSaveAuthMethod('api')}
|
||||
disabled={savingAuthMethod}
|
||||
className={`relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 ${authMethod === 'api'
|
||||
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/30 dark:to-indigo-900/30 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-800 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{authMethod === 'api' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`p-3 rounded-lg ${authMethod === 'api'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
||||
}`}>
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-semibold ${authMethod === 'api'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}>
|
||||
CodexAuth API
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
纯 API 模式,快速稳定
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 浏览器模式 */}
|
||||
<button
|
||||
onClick={() => handleSaveAuthMethod('browser')}
|
||||
disabled={savingAuthMethod}
|
||||
className={`relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 ${authMethod === 'browser'
|
||||
? 'border-emerald-500 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-900/30 dark:to-green-900/30 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-emerald-300 dark:hover:border-emerald-800 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{authMethod === 'browser' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`p-3 rounded-lg ${authMethod === 'browser'
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
||||
}`}>
|
||||
<Monitor className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-semibold ${authMethod === 'browser'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}>
|
||||
浏览器模拟
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Chromedp/Rod,兼容性好
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{authMethod === 'api' && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 text-white shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span className="font-medium">CodexAuth API 模式已启用</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-100 mt-1">
|
||||
使用纯 API 调用进行授权,无需启动浏览器,速度更快、更稳定
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Save,
|
||||
Monitor as MonitorIcon,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Switch } from '../components/common'
|
||||
import LiveLogViewer from '../components/LiveLogViewer'
|
||||
@@ -70,6 +71,7 @@ export default function Monitor() {
|
||||
const [replenishUseProxy, setReplenishUseProxy] = useState(false) // 补号时使用代理
|
||||
const [globalProxy, setGlobalProxy] = useState('') // 全局代理地址(只读显示)
|
||||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp') // 授权浏览器引擎
|
||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser') // 授权方式
|
||||
|
||||
// 倒计时状态
|
||||
const [countdown, setCountdown] = useState(60)
|
||||
@@ -264,6 +266,10 @@ export default function Monitor() {
|
||||
const configJson = await configRes.json()
|
||||
if (configJson.code === 0 && configJson.data) {
|
||||
setGlobalProxy(configJson.data.default_proxy || '')
|
||||
// 加载授权方式配置
|
||||
if (configJson.data.auth_method) {
|
||||
setAuthMethod(configJson.data.auth_method === 'api' ? 'api' : 'browser')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,37 +549,36 @@ export default function Monitor() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 浏览器选择器 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
授权浏览器引擎
|
||||
</label>
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrowserType('chromedp')}
|
||||
disabled={!autoAdd}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-all duration-200 ${browserType === 'chromedp'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
|
||||
} ${!autoAdd ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Chromedp
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrowserType('rod')}
|
||||
disabled={!autoAdd}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-all duration-200 border-l border-slate-200 dark:border-slate-700 ${browserType === 'rod'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
|
||||
} ${!autoAdd ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Rod
|
||||
</button>
|
||||
{/* 授权方式状态显示 */}
|
||||
<div className={`p-4 rounded-lg border ${authMethod === 'api'
|
||||
? 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 border-emerald-200 dark:border-emerald-800'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{authMethod === 'api' ? (
|
||||
<>
|
||||
<div className="p-2 rounded-lg bg-blue-500 text-white">
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-blue-700 dark:text-blue-300">CodexAuth API 模式</div>
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">纯 API 调用,无需浏览器,速度更快、更稳定</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-2 rounded-lg bg-emerald-500 text-white">
|
||||
<MonitorIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-emerald-700 dark:text-emerald-300">浏览器模拟模式 ({browserType})</div>
|
||||
<div className="text-xs text-emerald-600/70 dark:text-emerald-400/70">使用浏览器自动化进行授权,兼容性更好</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
选择用于自动授权的浏览器引擎,Chromedp 为默认高效选项,Rod 为备选
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
要切换授权方式,请在“基础配置”页面中修改
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Monitor,
|
||||
} from 'lucide-react'
|
||||
import { FileDropzone } from '../components/upload'
|
||||
import LogStream from '../components/upload/LogStream'
|
||||
@@ -70,14 +72,31 @@ export default function Upload() {
|
||||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||||
const [concurrentS2A, setConcurrentS2A] = useState(2) // 入库并发数
|
||||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
|
||||
const [browserType] = useState<'chromedp' | 'rod'>('chromedp') // 浏览器引擎类型(只读)
|
||||
const [useProxy, setUseProxy] = useState(false) // 是否使用全局代理
|
||||
const [includeOwner, setIncludeOwner] = useState(false) // 母号也入库
|
||||
const [processCount, setProcessCount] = useState(0) // 处理数量,0表示全部
|
||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser') // 授权方式
|
||||
|
||||
// 获取全局代理地址
|
||||
const globalProxy = config.proxy?.default || ''
|
||||
|
||||
// 加载全局配置中的授权方式
|
||||
useEffect(() => {
|
||||
const fetchAuthMethod = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data?.auth_method) {
|
||||
setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auth method:', error)
|
||||
}
|
||||
}
|
||||
fetchAuthMethod()
|
||||
}, [])
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
// Load stats
|
||||
@@ -456,31 +475,33 @@ export default function Upload() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
浏览器引擎
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBrowserType('chromedp')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Chromedp (推荐)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBrowserType('rod')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Rod
|
||||
</button>
|
||||
{/* 授权方式状态显示 */}
|
||||
<div className={`p-3 rounded-lg border ${authMethod === 'api'
|
||||
? 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 border-emerald-200 dark:border-emerald-800'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{authMethod === 'api' ? (
|
||||
<>
|
||||
<div className="p-1.5 rounded bg-blue-500 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-blue-700 dark:text-blue-300">CodexAuth API 模式</div>
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">纯 API 调用,无需浏览器,速度更快</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-1.5 rounded bg-emerald-500 text-white">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-emerald-700 dark:text-emerald-300">浏览器模拟模式 ({browserType})</div>
|
||||
<div className="text-xs text-emerald-600/70 dark:text-emerald-400/70">使用浏览器自动化进行授权</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user