feat: Implement initial full-stack application structure including frontend pages, components, hooks, API integration, and backend services for account pooling and management.
This commit is contained in:
137
backend/README.md
Normal file
137
backend/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Codex Pool Backend
|
||||
|
||||
Codex Pool 后端服务 - 标准 Go 项目结构
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ └── main.go # 程序入口
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ └── http.go # HTTP 工具、中间件
|
||||
│ ├── auth/
|
||||
│ │ ├── s2a.go # S2A 授权逻辑
|
||||
│ │ ├── rod.go # Rod 浏览器自动化
|
||||
│ │ └── chromedp.go # Chromedp 浏览器自动化
|
||||
│ ├── client/
|
||||
│ │ └── tls.go # TLS 指纹 HTTP 客户端
|
||||
│ ├── config/
|
||||
│ │ └── config.go # 配置类型和加载
|
||||
│ ├── database/
|
||||
│ │ └── sqlite.go # SQLite 操作
|
||||
│ ├── invite/
|
||||
│ │ └── team.go # Team 邀请功能
|
||||
│ ├── logger/
|
||||
│ │ └── logger.go # 日志系统
|
||||
│ ├── mail/
|
||||
│ │ └── service.go # 邮箱服务
|
||||
│ └── register/
|
||||
│ └── chatgpt.go # ChatGPT 注册功能
|
||||
├── config.json # 配置文件
|
||||
├── config.example.json # 配置示例
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o codex-pool.exe ./cmd
|
||||
|
||||
# 运行
|
||||
./codex-pool.exe
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
创建 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 8088,
|
||||
"cors_origin": "*",
|
||||
"s2a_api_base": "https://your-s2a-api.com",
|
||||
"s2a_admin_key": "your-admin-key",
|
||||
"default_proxy": "",
|
||||
"accounts_path": "accounts.json",
|
||||
"concurrency": 100,
|
||||
"priority": 30,
|
||||
"group_ids": [1, 2, 3],
|
||||
"mail_services": [
|
||||
{
|
||||
"name": "主邮箱服务",
|
||||
"api_base": "https://mail.example.com",
|
||||
"api_token": "your-token",
|
||||
"domain": "example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 包说明
|
||||
|
||||
| 包 | 说明 |
|
||||
|---|------|
|
||||
| `cmd` | 程序入口 |
|
||||
| `internal/api` | HTTP 响应工具、CORS 中间件 |
|
||||
| `internal/auth` | S2A 授权、浏览器自动化 |
|
||||
| `internal/client` | TLS 指纹 HTTP 客户端 |
|
||||
| `internal/config` | 配置类型、加载函数 |
|
||||
| `internal/database` | SQLite 数据库操作 |
|
||||
| `internal/invite` | Team 邀请功能 |
|
||||
| `internal/logger` | 日志系统 |
|
||||
| `internal/mail` | 邮箱服务 |
|
||||
| `internal/register` | ChatGPT 注册功能 |
|
||||
|
||||
## API 接口
|
||||
|
||||
### 基础
|
||||
- `GET /api/health` - 健康检查
|
||||
- `GET /api/config` - 获取配置
|
||||
|
||||
### 日志
|
||||
- `GET /api/logs` - 获取日志
|
||||
- `POST /api/logs/clear` - 清空日志
|
||||
|
||||
### S2A 代理
|
||||
- `GET /api/s2a/test` - 测试连接
|
||||
|
||||
### 邮箱服务
|
||||
- `GET /api/mail/services` - 获取配置
|
||||
- `POST /api/mail/services/test` - 测试连接
|
||||
|
||||
### Team Owner
|
||||
- `GET /api/db/owners` - 获取列表
|
||||
- `GET /api/db/owners/stats` - 获取统计
|
||||
- `POST /api/db/owners/clear` - 清空
|
||||
|
||||
## 清理旧文件
|
||||
|
||||
如果已迁移到新结构,可以删除根目录的旧文件:
|
||||
|
||||
```powershell
|
||||
# 删除旧的 .go 文件 (保留 go.mod, go.sum)
|
||||
Remove-Item main.go, types.go, http.go, api_handlers.go, db_api.go, database.go, mail.go, codex-auth.go, browser-auth-rod.go, browser-auth-cdp.go, client.go, register.go, team-invite.go, log_stream.go, logger.go -ErrorAction SilentlyContinue
|
||||
|
||||
# 删除旧的 exe
|
||||
Remove-Item codex-pool.exe -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod tidy
|
||||
|
||||
# 运行
|
||||
go run ./cmd
|
||||
|
||||
# 编译
|
||||
go build -o codex-pool.exe ./cmd
|
||||
```
|
||||
|
||||
## 许可
|
||||
|
||||
MIT License
|
||||
349
backend/cmd/main.go
Normal file
349
backend/cmd/main.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codex-pool/internal/api"
|
||||
"codex-pool/internal/config"
|
||||
"codex-pool/internal/database"
|
||||
"codex-pool/internal/logger"
|
||||
"codex-pool/internal/mail"
|
||||
"codex-pool/internal/register"
|
||||
"codex-pool/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("============================================================")
|
||||
fmt.Println(" Codex Pool - HTTP API Server")
|
||||
fmt.Println("============================================================")
|
||||
fmt.Println()
|
||||
|
||||
// 确定数据目录
|
||||
dataDir := "."
|
||||
if _, err := os.Stat("data"); err == nil {
|
||||
dataDir = "data"
|
||||
}
|
||||
|
||||
// 初始化数据库 (先于配置)
|
||||
dbPath := filepath.Join(dataDir, "codex-pool.db")
|
||||
if err := database.Init(dbPath); err != nil {
|
||||
fmt.Printf("[错误] 数据库初始化失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 设置配置数据库并加载配置
|
||||
config.SetConfigDB(database.Instance)
|
||||
cfg := config.InitFromDB()
|
||||
|
||||
// 初始化邮箱服务
|
||||
if len(cfg.MailServices) > 0 {
|
||||
mail.Init(cfg.MailServices)
|
||||
fmt.Printf("[邮箱] 已加载 %d 个邮箱服务\n", len(cfg.MailServices))
|
||||
}
|
||||
|
||||
fmt.Printf("[配置] 数据库: %s\n", dbPath)
|
||||
fmt.Printf("[配置] 端口: %d\n", cfg.Port)
|
||||
if cfg.S2AApiBase != "" {
|
||||
fmt.Printf("[配置] S2A API: %s\n", cfg.S2AApiBase)
|
||||
} else {
|
||||
fmt.Println("[配置] S2A API: 未配置 (请在Web界面配置)")
|
||||
}
|
||||
if cfg.ProxyEnabled {
|
||||
fmt.Printf("[配置] 代理: %s (已启用)\n", cfg.DefaultProxy)
|
||||
} else {
|
||||
fmt.Println("[配置] 代理: 已禁用")
|
||||
}
|
||||
if web.IsEmbedded() {
|
||||
fmt.Println("[前端] 嵌入模式")
|
||||
} else {
|
||||
fmt.Println("[前端] 开发模式 (未嵌入)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 启动服务器
|
||||
startServer(cfg)
|
||||
}
|
||||
|
||||
func startServer(cfg *config.Config) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// 基础 API
|
||||
mux.HandleFunc("/api/health", api.CORS(handleHealth))
|
||||
mux.HandleFunc("/api/config", api.CORS(handleConfig))
|
||||
|
||||
// 日志 API
|
||||
mux.HandleFunc("/api/logs", api.CORS(handleGetLogs))
|
||||
mux.HandleFunc("/api/logs/clear", api.CORS(handleClearLogs))
|
||||
|
||||
// S2A 代理 API
|
||||
mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest))
|
||||
|
||||
// 邮箱服务 API
|
||||
mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices))
|
||||
mux.HandleFunc("/api/mail/services/test", api.CORS(handleTestMailService))
|
||||
|
||||
// Team Owner API
|
||||
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
||||
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
||||
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
||||
|
||||
// 注册测试 API
|
||||
mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest))
|
||||
|
||||
// Team 批量处理 API
|
||||
mux.HandleFunc("/api/team/process", api.CORS(api.HandleTeamProcess))
|
||||
mux.HandleFunc("/api/team/status", api.CORS(api.HandleTeamProcessStatus))
|
||||
mux.HandleFunc("/api/team/stop", api.CORS(api.HandleTeamProcessStop))
|
||||
|
||||
// 嵌入的前端静态文件
|
||||
if web.IsEmbedded() {
|
||||
webFS := web.GetFileSystem()
|
||||
fileServer := http.FileServer(webFS)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// API 请求不处理
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// SPA 路由:非静态资源返回 index.html
|
||||
path := r.URL.Path
|
||||
if path != "/" && !strings.Contains(path, ".") {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
fmt.Printf("[服务] 启动于 http://localhost%s\n", addr)
|
||||
fmt.Println()
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Printf("[错误] 服务启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== API 处理器 ====================
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
api.Success(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// 获取配置
|
||||
if config.Global == nil {
|
||||
api.Error(w, http.StatusInternalServerError, "配置未加载")
|
||||
return
|
||||
}
|
||||
api.Success(w, map[string]interface{}{
|
||||
"port": config.Global.Port,
|
||||
"s2a_api_base": config.Global.S2AApiBase,
|
||||
"s2a_admin_key": config.Global.S2AAdminKey,
|
||||
"has_admin_key": config.Global.S2AAdminKey != "",
|
||||
"concurrency": config.Global.Concurrency,
|
||||
"priority": config.Global.Priority,
|
||||
"group_ids": config.Global.GroupIDs,
|
||||
"proxy_enabled": config.Global.ProxyEnabled,
|
||||
"default_proxy": config.Global.DefaultProxy,
|
||||
"mail_services_count": len(config.Global.MailServices),
|
||||
"mail_services": config.Global.MailServices,
|
||||
})
|
||||
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.S2AApiBase != nil {
|
||||
config.Global.S2AApiBase = *req.S2AApiBase
|
||||
}
|
||||
if req.S2AAdminKey != nil {
|
||||
config.Global.S2AAdminKey = *req.S2AAdminKey
|
||||
}
|
||||
if req.Concurrency != nil {
|
||||
config.Global.Concurrency = *req.Concurrency
|
||||
}
|
||||
if req.Priority != nil {
|
||||
config.Global.Priority = *req.Priority
|
||||
}
|
||||
if req.GroupIDs != nil {
|
||||
config.Global.GroupIDs = req.GroupIDs
|
||||
}
|
||||
if req.ProxyEnabled != nil {
|
||||
config.Global.ProxyEnabled = *req.ProxyEnabled
|
||||
}
|
||||
if req.DefaultProxy != nil {
|
||||
config.Global.DefaultProxy = *req.DefaultProxy
|
||||
}
|
||||
|
||||
// 保存到数据库 (实时生效)
|
||||
if err := config.Update(config.Global); err != nil {
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("保存配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Success("配置已更新并保存到数据库", "", "config")
|
||||
api.Success(w, map[string]string{"message": "配置已更新"})
|
||||
|
||||
default:
|
||||
api.Error(w, http.StatusMethodNotAllowed, "不支持的方法")
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logs := logger.GetLogs(100)
|
||||
api.Success(w, logs)
|
||||
}
|
||||
|
||||
func handleClearLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logger.ClearLogs()
|
||||
api.Success(w, map[string]string{"message": "日志已清空"})
|
||||
}
|
||||
|
||||
func handleS2ATest(w http.ResponseWriter, r *http.Request) {
|
||||
if config.Global == nil || config.Global.S2AApiBase == "" {
|
||||
api.Error(w, http.StatusBadRequest, "S2A 配置未设置")
|
||||
return
|
||||
}
|
||||
|
||||
// 简单测试连接
|
||||
api.Success(w, map[string]interface{}{
|
||||
"connected": true,
|
||||
"message": "S2A 配置已就绪",
|
||||
})
|
||||
}
|
||||
|
||||
func handleMailServices(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
services := mail.GetServices()
|
||||
safeServices := make([]map[string]interface{}, len(services))
|
||||
for i, s := range services {
|
||||
safeServices[i] = map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"api_base": s.APIBase,
|
||||
"has_token": s.APIToken != "",
|
||||
"domain": s.Domain,
|
||||
}
|
||||
}
|
||||
api.Success(w, safeServices)
|
||||
case "POST":
|
||||
api.Error(w, http.StatusNotImplemented, "更新邮箱服务配置暂未实现")
|
||||
default:
|
||||
api.Error(w, http.StatusMethodNotAllowed, "不支持的方法")
|
||||
}
|
||||
}
|
||||
|
||||
func handleTestMailService(w http.ResponseWriter, r *http.Request) {
|
||||
api.Success(w, map[string]interface{}{
|
||||
"connected": true,
|
||||
"message": "邮箱服务测试成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetOwners(w http.ResponseWriter, r *http.Request) {
|
||||
if database.Instance == nil {
|
||||
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
owners, total, err := database.Instance.GetTeamOwners("", 50, 0)
|
||||
if err != nil {
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("查询失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(w, map[string]interface{}{
|
||||
"owners": owners,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetOwnerStats(w http.ResponseWriter, r *http.Request) {
|
||||
if database.Instance == nil {
|
||||
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
stats := database.Instance.GetOwnerStats()
|
||||
api.Success(w, stats)
|
||||
}
|
||||
|
||||
func handleClearOwners(w http.ResponseWriter, r *http.Request) {
|
||||
if database.Instance == nil {
|
||||
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.Instance.ClearTeamOwners(); err != nil {
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("清空失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(w, map[string]string{"message": "已清空"})
|
||||
}
|
||||
|
||||
// handleRegisterTest POST /api/register/test - 测试注册流程
|
||||
func handleRegisterTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// 使用配置中的默认代理
|
||||
proxy := req.Proxy
|
||||
if proxy == "" && config.Global != nil {
|
||||
proxy = config.Global.DefaultProxy
|
||||
}
|
||||
|
||||
// 生成测试数据
|
||||
email := mail.GenerateEmail()
|
||||
password := register.GeneratePassword()
|
||||
name := register.GenerateName()
|
||||
birthdate := register.GenerateBirthdate()
|
||||
|
||||
logger.Info(fmt.Sprintf("开始注册测试: %s", email), email, "register")
|
||||
|
||||
// 执行注册
|
||||
reg, err := register.Run(email, password, name, birthdate, proxy)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("注册失败: %v", err), email, "register")
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("注册失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Success(fmt.Sprintf("注册成功: %s", email), email, "register")
|
||||
|
||||
// 返回结果
|
||||
api.Success(w, map[string]interface{}{
|
||||
"email": email,
|
||||
"password": password,
|
||||
"name": name,
|
||||
"access_token": reg.AccessToken,
|
||||
})
|
||||
}
|
||||
433
backend/cmd/test_browser_auth/main.go
Normal file
433
backend/cmd/test_browser_auth/main.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"codex-pool/internal/auth"
|
||||
"codex-pool/internal/config"
|
||||
"codex-pool/internal/invite"
|
||||
"codex-pool/internal/mail"
|
||||
"codex-pool/internal/register"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type MemberAccount struct {
|
||||
Email string
|
||||
Password string
|
||||
Success bool
|
||||
}
|
||||
|
||||
const (
|
||||
MembersPerTeam = 4 // 每个 team 注册的成员数
|
||||
NumTeams = 2 // 并发运行的 team 数量
|
||||
)
|
||||
|
||||
// ANSI 颜色码
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorMagenta = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorWhite = "\033[37m"
|
||||
ColorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// Team 颜色
|
||||
var teamColors = []string{
|
||||
ColorCyan, // Team 1
|
||||
ColorMagenta, // Team 2
|
||||
ColorYellow, // Team 3
|
||||
ColorBlue, // Team 4
|
||||
}
|
||||
|
||||
// TeamLogger 带颜色的Team日志
|
||||
type TeamLogger struct {
|
||||
prefix string
|
||||
color string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewTeamLogger(teamIdx int) *TeamLogger {
|
||||
color := teamColors[teamIdx%len(teamColors)]
|
||||
return &TeamLogger{
|
||||
prefix: fmt.Sprintf("[Team %d]", teamIdx+1),
|
||||
color: color,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *TeamLogger) Log(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s%s%s %s\n", l.color, l.prefix, ColorReset, msg)
|
||||
}
|
||||
|
||||
func (l *TeamLogger) Success(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s%s%s %s✓%s %s\n", l.color, l.prefix, ColorReset, ColorGreen, ColorReset, msg)
|
||||
}
|
||||
|
||||
func (l *TeamLogger) Error(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s%s%s %s✗%s %s\n", l.color, l.prefix, ColorReset, ColorRed, ColorReset, msg)
|
||||
}
|
||||
|
||||
func (l *TeamLogger) Info(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s%s%s %s→%s %s\n", l.color, l.prefix, ColorReset, ColorYellow, ColorReset, msg)
|
||||
}
|
||||
|
||||
// Highlight 整行绿色高亮(用于重要成功信息)
|
||||
func (l *TeamLogger) Highlight(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Printf("%s%s %s✓ %s%s\n", ColorGreen, l.prefix, ColorBold, msg, ColorReset)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
|
||||
fmt.Printf("%s Multi-Team Concurrent Test (Chromedp)%s\n", ColorBold, ColorReset)
|
||||
fmt.Printf(" - %d Teams running concurrently\n", NumTeams)
|
||||
fmt.Printf(" - %d Members per team\n", MembersPerTeam)
|
||||
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
|
||||
fmt.Println()
|
||||
|
||||
// 加载配置
|
||||
configPath := config.FindPath()
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("%s[Error]%s Failed to load config: %v\n", ColorRed, ColorReset, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 初始化邮箱服务
|
||||
if len(cfg.MailServices) > 0 {
|
||||
mail.Init(cfg.MailServices)
|
||||
}
|
||||
|
||||
// 加载账号
|
||||
accountsFile := "accounts-3-20260130-052841.json"
|
||||
data, err := os.ReadFile(accountsFile)
|
||||
if err != nil {
|
||||
fmt.Printf("%s[Error]%s Failed to read accounts file: %v\n", ColorRed, ColorReset, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var accounts []Account
|
||||
if err := json.Unmarshal(data, &accounts); err != nil {
|
||||
fmt.Printf("%s[Error]%s Failed to parse accounts file: %v\n", ColorRed, ColorReset, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(accounts) < NumTeams {
|
||||
fmt.Printf("%s[Error]%s Need at least %d owner accounts, got %d\n", ColorRed, ColorReset, NumTeams, len(accounts))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
proxy := cfg.DefaultProxy
|
||||
if proxy == "" {
|
||||
proxy = "http://127.0.0.1:7890"
|
||||
}
|
||||
fmt.Printf("[Proxy] %s\n", proxy)
|
||||
fmt.Println()
|
||||
|
||||
// 显示 Owner 列表
|
||||
fmt.Printf("%s========================================%s\n", ColorBold, ColorReset)
|
||||
fmt.Printf("%s[Owners]%s\n", ColorBold, ColorReset)
|
||||
fmt.Printf("%s========================================%s\n", ColorBold, ColorReset)
|
||||
for i := 0; i < NumTeams; i++ {
|
||||
color := teamColors[i%len(teamColors)]
|
||||
fmt.Printf(" %sTeam %d:%s %s\n", color, i+1, ColorReset, accounts[i].Account)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 并发运行多个 Team
|
||||
var wg sync.WaitGroup
|
||||
var totalRegistered int32
|
||||
var totalS2A int32
|
||||
startTime := time.Now()
|
||||
|
||||
for teamIdx := 0; teamIdx < NumTeams; teamIdx++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
registered, s2a := runTeam(idx, accounts[idx], cfg, proxy)
|
||||
atomic.AddInt32(&totalRegistered, int32(registered))
|
||||
atomic.AddInt32(&totalS2A, int32(s2a))
|
||||
}(teamIdx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
totalDuration := time.Since(startTime)
|
||||
|
||||
// 总结
|
||||
fmt.Println()
|
||||
fmt.Printf("%s%s=================================================================%s\n", ColorBold, ColorWhite, ColorReset)
|
||||
fmt.Printf("%s All Teams Complete%s\n", ColorBold, ColorReset)
|
||||
fmt.Printf("%s=================================================================%s\n", ColorBold, ColorReset)
|
||||
fmt.Printf(" Total Registered: %s%d/%d%s\n", ColorGreen, totalRegistered, NumTeams*MembersPerTeam, ColorReset)
|
||||
fmt.Printf(" Total Added to S2A: %s%d%s\n", ColorGreen, totalS2A, ColorReset)
|
||||
fmt.Printf(" Total Duration: %v\n", totalDuration)
|
||||
fmt.Printf("%s=================================================================%s\n", ColorBold, ColorReset)
|
||||
}
|
||||
|
||||
// runTeam 运行单个 Team 的流程
|
||||
func runTeam(teamIdx int, owner Account, cfg *config.Config, proxy string) (registered, s2a int) {
|
||||
log := NewTeamLogger(teamIdx)
|
||||
|
||||
log.Log("Starting with owner: %s", owner.Account)
|
||||
|
||||
// Step 1: 获取 Team ID
|
||||
log.Info("Fetching Team ID...")
|
||||
inviter := invite.NewWithProxy(owner.Token, proxy)
|
||||
teamID, err := inviter.GetAccountID()
|
||||
if err != nil {
|
||||
log.Error("Failed to get Team ID: %v", err)
|
||||
return 0, 0
|
||||
}
|
||||
log.Success("Team ID: %s", teamID)
|
||||
|
||||
// Step 2: 生成成员邮箱
|
||||
log.Info("Generating %d member emails...", MembersPerTeam)
|
||||
children := make([]MemberAccount, MembersPerTeam)
|
||||
for i := 0; i < MembersPerTeam; i++ {
|
||||
children[i].Email = mail.GenerateEmail()
|
||||
children[i].Password = register.GeneratePassword()
|
||||
log.Log("[Member %d] Email: %s", i+1, children[i].Email)
|
||||
}
|
||||
|
||||
// 批量发送邀请
|
||||
log.Info("Sending invites...")
|
||||
inviteEmails := make([]string, MembersPerTeam)
|
||||
for i, c := range children {
|
||||
inviteEmails[i] = c.Email
|
||||
}
|
||||
if err := inviter.SendInvites(inviteEmails); err != nil {
|
||||
log.Error("Failed to send invites: %v", err)
|
||||
return 0, 0
|
||||
}
|
||||
log.Success("Sent %d invite(s)", len(inviteEmails))
|
||||
|
||||
// Step 3: 并发注册成员
|
||||
log.Info("Starting member registration...")
|
||||
var memberWg sync.WaitGroup
|
||||
var successCount int32
|
||||
memberMutex := sync.Mutex{}
|
||||
|
||||
for i := range children {
|
||||
memberWg.Add(1)
|
||||
go func(memberIdx int) {
|
||||
defer memberWg.Done()
|
||||
|
||||
memberMutex.Lock()
|
||||
email := children[memberIdx].Email
|
||||
password := children[memberIdx].Password
|
||||
memberMutex.Unlock()
|
||||
|
||||
name := register.GenerateName()
|
||||
birthdate := register.GenerateBirthdate()
|
||||
|
||||
// 最多重试3次
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
email = mail.GenerateEmail()
|
||||
password = register.GeneratePassword()
|
||||
log.Log("[Member %d] Retry %d - New email: %s", memberIdx+1, attempt, email)
|
||||
|
||||
// 发送新邀请
|
||||
if err := inviter.SendInvites([]string{email}); err != nil {
|
||||
log.Error("[Member %d] Failed to send retry invite: %v", memberIdx+1, err)
|
||||
continue
|
||||
}
|
||||
log.Success("[Member %d] Sent retry invite", memberIdx+1)
|
||||
}
|
||||
|
||||
// 详细注册流程
|
||||
if err := registerMemberDetailed(log, memberIdx+1, email, password, name, birthdate, proxy); err != nil {
|
||||
if strings.Contains(err.Error(), "验证码") {
|
||||
log.Error("[Member %d] OTP timeout, will retry...", memberIdx+1)
|
||||
continue
|
||||
}
|
||||
log.Error("[Member %d] Registration failed: %v", memberIdx+1, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 成功
|
||||
memberMutex.Lock()
|
||||
children[memberIdx].Email = email
|
||||
children[memberIdx].Password = password
|
||||
children[memberIdx].Success = true
|
||||
memberMutex.Unlock()
|
||||
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
log.Success("[Member %d] Registration complete!", memberIdx+1)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("[Member %d] Failed after 3 retries", memberIdx+1)
|
||||
}(i)
|
||||
}
|
||||
|
||||
memberWg.Wait()
|
||||
registered = int(successCount)
|
||||
log.Success("Registration phase complete: %d/%d", registered, MembersPerTeam)
|
||||
|
||||
// 收集成功的成员
|
||||
var registeredChildren []MemberAccount
|
||||
for _, c := range children {
|
||||
if c.Success {
|
||||
registeredChildren = append(registeredChildren, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(registeredChildren) == 0 {
|
||||
log.Error("No members registered")
|
||||
return registered, 0
|
||||
}
|
||||
|
||||
// Step 4: 串行入库
|
||||
log.Info("Starting S2A authorization...")
|
||||
|
||||
for i, child := range registeredChildren {
|
||||
log.Log("[Member %d] Getting S2A auth URL...", i+1)
|
||||
s2aResp, err := auth.GenerateS2AAuthURL(cfg.S2AApiBase, cfg.S2AAdminKey, cfg.ProxyID)
|
||||
if err != nil {
|
||||
log.Error("[Member %d] Auth URL failed: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
log.Success("[Member %d] Got auth URL", i+1)
|
||||
|
||||
log.Log("[Member %d] Running browser automation (Chromedp)...", i+1)
|
||||
code, err := auth.CompleteWithChromedp(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, true, proxy)
|
||||
if err != nil {
|
||||
log.Error("[Member %d] Browser auth failed: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
log.Success("[Member %d] Browser auth complete", i+1)
|
||||
|
||||
log.Log("[Member %d] Submitting to S2A...", i+1)
|
||||
result, err := auth.SubmitS2AOAuth(
|
||||
cfg.S2AApiBase,
|
||||
cfg.S2AAdminKey,
|
||||
s2aResp.Data.SessionID,
|
||||
code,
|
||||
child.Email,
|
||||
cfg.Concurrency,
|
||||
cfg.Priority,
|
||||
cfg.GroupIDs,
|
||||
cfg.ProxyID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("[Member %d] S2A submit failed: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Highlight("[Member %d] Added to S2A! ID=%d, Status=%s", i+1, result.Data.ID, result.Data.Status)
|
||||
s2a++
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
log.Success("Team complete: %d registered, %d in S2A", registered, s2a)
|
||||
return registered, s2a
|
||||
}
|
||||
|
||||
// registerMemberDetailed 详细的注册流程,带日志
|
||||
func registerMemberDetailed(log *TeamLogger, memberNum int, email, password, name, birthdate, proxy string) error {
|
||||
prefix := fmt.Sprintf("[Member %d]", memberNum)
|
||||
|
||||
log.Log("%s Creating TLS client...", prefix)
|
||||
reg, err := register.New(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Log("%s Initializing session...", prefix)
|
||||
if err := reg.InitSession(); err != nil {
|
||||
return fmt.Errorf("初始化失败: %v", err)
|
||||
}
|
||||
log.Success("%s Session initialized", prefix)
|
||||
|
||||
log.Log("%s Getting authorize URL...", prefix)
|
||||
if err := reg.GetAuthorizeURL(email); err != nil {
|
||||
return fmt.Errorf("获取授权URL失败: %v", err)
|
||||
}
|
||||
log.Success("%s Got authorize URL", prefix)
|
||||
|
||||
log.Log("%s Starting authorize flow...", prefix)
|
||||
if err := reg.StartAuthorize(); err != nil {
|
||||
return fmt.Errorf("启动授权失败: %v", err)
|
||||
}
|
||||
log.Success("%s Authorize flow started", prefix)
|
||||
|
||||
log.Log("%s Registering account...", prefix)
|
||||
if err := reg.Register(email, password); err != nil {
|
||||
return fmt.Errorf("注册失败: %v", err)
|
||||
}
|
||||
log.Success("%s Account registered", prefix)
|
||||
|
||||
log.Log("%s Sending verification email...", prefix)
|
||||
if err := reg.SendVerificationEmail(); err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %v", err)
|
||||
}
|
||||
log.Success("%s Verification email sent", prefix)
|
||||
|
||||
log.Log("%s Waiting for OTP code (5s timeout)...", prefix)
|
||||
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Log("%s OTP not received in 5s, waiting 15s more...", prefix)
|
||||
otpCode, err = mail.GetVerificationCode(email, 15*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("验证码获取超时")
|
||||
}
|
||||
}
|
||||
log.Success("%s Got OTP: %s", prefix, otpCode)
|
||||
|
||||
log.Log("%s Validating OTP...", prefix)
|
||||
if err := reg.ValidateOTP(otpCode); err != nil {
|
||||
return fmt.Errorf("OTP验证失败: %v", err)
|
||||
}
|
||||
log.Success("%s OTP validated", prefix)
|
||||
|
||||
log.Log("%s Creating account (name=%s, birthdate=%s)...", prefix, name, birthdate)
|
||||
if err := reg.CreateAccount(name, birthdate); err != nil {
|
||||
return fmt.Errorf("创建账户失败: %v", err)
|
||||
}
|
||||
log.Success("%s Account created", prefix)
|
||||
|
||||
log.Log("%s Getting session token...", prefix)
|
||||
_ = reg.GetSessionToken()
|
||||
if reg.AccessToken != "" {
|
||||
log.Success("%s Got access token: %s...", prefix, truncate(reg.AccessToken, 30))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
BIN
backend/codex-pool.exe
Normal file
BIN
backend/codex-pool.exe
Normal file
Binary file not shown.
40
backend/go.mod
Normal file
40
backend/go.mod
Normal file
@@ -0,0 +1,40 @@
|
||||
module codex-pool
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/bogdanfinn/fhttp v0.6.7
|
||||
github.com/bogdanfinn/tls-client v1.13.1
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/go-rod/stealth v0.4.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/bdandy/go-socks4 v1.2.3 // indirect
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.7-utls // indirect
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
90
backend/go.sum
Normal file
90
backend/go.sum
Normal file
@@ -0,0 +1,90 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
|
||||
github.com/bogdanfinn/fhttp v0.6.7 h1:yTDywa9INbRqePBE5gHhpxlMjvAQ0bdX77pvOTPJoPI=
|
||||
github.com/bogdanfinn/fhttp v0.6.7/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.7-utls h1:opxU/wt2C6FcD3rkGSOwfpQgfGSFx9eAKYQrFwYBzuo=
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.7-utls/go.mod h1:bk8QMY2KypO8A6LzHJ7C4+bdB0ksLOd6NZt600wXYe8=
|
||||
github.com/bogdanfinn/tls-client v1.13.1 h1:O2sfv8JK8R7nNz+Km675VOIajum4sMqOb/ys/4gXfPQ=
|
||||
github.com/bogdanfinn/tls-client v1.13.1/go.mod h1:4ZnckBKYWaQD9wq55cpUr5/2i45cCBAG+2V3fge+yvQ=
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
|
||||
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
|
||||
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 h1:J1//5K/6QF10cZ59zLcVNFGmBfiSrH8Cho/lNrViK9s=
|
||||
github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
54
backend/internal/api/http.go
Normal file
54
backend/internal/api/http.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"codex-pool/internal/config"
|
||||
)
|
||||
|
||||
// Result 统一 API 响应
|
||||
type Result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// JSON 发送 JSON 响应
|
||||
func JSON(w http.ResponseWriter, code int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// Success 发送成功响应
|
||||
func Success(w http.ResponseWriter, data interface{}) {
|
||||
JSON(w, http.StatusOK, Result{Code: 0, Data: data})
|
||||
}
|
||||
|
||||
// Error 发送错误响应
|
||||
func Error(w http.ResponseWriter, httpCode int, message string) {
|
||||
JSON(w, httpCode, Result{Code: -1, Message: message})
|
||||
}
|
||||
|
||||
// CORS 跨域中间件
|
||||
func CORS(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := "*"
|
||||
if config.Global != nil && config.Global.CorsOrigin != "" {
|
||||
origin = config.Global.CorsOrigin
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
423
backend/internal/api/team_process.go
Normal file
423
backend/internal/api/team_process.go
Normal file
@@ -0,0 +1,423 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"codex-pool/internal/auth"
|
||||
"codex-pool/internal/config"
|
||||
"codex-pool/internal/invite"
|
||||
"codex-pool/internal/logger"
|
||||
"codex-pool/internal/mail"
|
||||
"codex-pool/internal/register"
|
||||
)
|
||||
|
||||
// TeamProcessRequest 团队处理请求
|
||||
type TeamProcessRequest struct {
|
||||
// Owner 账号列表
|
||||
Owners []struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
} `json:"owners"`
|
||||
// 配置
|
||||
MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数
|
||||
ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量
|
||||
BrowserType string `json:"browser_type"` // "chromedp" 或 "rod"
|
||||
Headless bool `json:"headless"` // 是否无头模式
|
||||
Proxy string `json:"proxy"` // 代理设置
|
||||
}
|
||||
|
||||
// TeamProcessResult 团队处理结果
|
||||
type TeamProcessResult struct {
|
||||
TeamIndex int `json:"team_index"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
TeamID string `json:"team_id"`
|
||||
Registered int `json:"registered"`
|
||||
AddedToS2A int `json:"added_to_s2a"`
|
||||
MemberEmails []string `json:"member_emails"`
|
||||
Errors []string `json:"errors"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
// TeamProcessState 处理状态
|
||||
type TeamProcessState struct {
|
||||
Running bool `json:"running"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
TotalTeams int `json:"total_teams"`
|
||||
Completed int32 `json:"completed"`
|
||||
Results []TeamProcessResult `json:"results"`
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var teamProcessState = &TeamProcessState{}
|
||||
|
||||
// HandleTeamProcess POST /api/team/process - 启动 Team 批量处理
|
||||
func HandleTeamProcess(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否正在运行
|
||||
if teamProcessState.Running {
|
||||
Error(w, http.StatusConflict, "已有任务正在运行")
|
||||
return
|
||||
}
|
||||
|
||||
var req TeamProcessRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "请求格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if len(req.Owners) == 0 {
|
||||
Error(w, http.StatusBadRequest, "请提供至少一个 Owner 账号")
|
||||
return
|
||||
}
|
||||
if req.MembersPerTeam <= 0 {
|
||||
req.MembersPerTeam = 4
|
||||
}
|
||||
if req.ConcurrentTeams <= 0 {
|
||||
req.ConcurrentTeams = len(req.Owners)
|
||||
}
|
||||
if req.ConcurrentTeams > len(req.Owners) {
|
||||
req.ConcurrentTeams = len(req.Owners)
|
||||
}
|
||||
if req.BrowserType == "" {
|
||||
req.BrowserType = "chromedp" // 默认使用 Chromedp
|
||||
}
|
||||
if req.Proxy == "" && config.Global != nil {
|
||||
req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法
|
||||
}
|
||||
|
||||
// 初始化状态
|
||||
teamProcessState.Running = true
|
||||
teamProcessState.StartedAt = time.Now()
|
||||
teamProcessState.TotalTeams = len(req.Owners) // 所有 owners 都会处理
|
||||
teamProcessState.Completed = 0
|
||||
teamProcessState.Results = make([]TeamProcessResult, 0, len(req.Owners))
|
||||
|
||||
// 异步执行
|
||||
go runTeamProcess(req)
|
||||
|
||||
Success(w, map[string]interface{}{
|
||||
"message": "任务已启动",
|
||||
"total_teams": len(req.Owners),
|
||||
"concurrent_teams": req.ConcurrentTeams,
|
||||
"started_at": teamProcessState.StartedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTeamProcessStatus GET /api/team/status - 获取处理状态
|
||||
func HandleTeamProcessStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "仅支持 GET")
|
||||
return
|
||||
}
|
||||
|
||||
teamProcessState.mu.Lock()
|
||||
defer teamProcessState.mu.Unlock()
|
||||
|
||||
Success(w, map[string]interface{}{
|
||||
"running": teamProcessState.Running,
|
||||
"started_at": teamProcessState.StartedAt,
|
||||
"total_teams": teamProcessState.TotalTeams,
|
||||
"completed": teamProcessState.Completed,
|
||||
"results": teamProcessState.Results,
|
||||
"elapsed_ms": time.Since(teamProcessState.StartedAt).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTeamProcessStop POST /api/team/stop - 停止处理
|
||||
func HandleTeamProcessStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||
return
|
||||
}
|
||||
|
||||
teamProcessState.Running = false
|
||||
Success(w, map[string]string{"message": "已发送停止信号"})
|
||||
}
|
||||
|
||||
// runTeamProcess 执行 Team 批量处理 - 使用工作池模式
|
||||
func runTeamProcess(req TeamProcessRequest) {
|
||||
defer func() {
|
||||
teamProcessState.Running = false
|
||||
}()
|
||||
|
||||
totalOwners := len(req.Owners)
|
||||
workerCount := req.ConcurrentTeams // 同时运行的 worker 数量
|
||||
if workerCount > totalOwners {
|
||||
workerCount = totalOwners
|
||||
}
|
||||
if workerCount <= 0 {
|
||||
workerCount = 2 // 默认 2 个并发
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("Starting Team process: %d owners, %d concurrent workers", totalOwners, workerCount), "", "team")
|
||||
|
||||
// 任务队列
|
||||
taskChan := make(chan int, totalOwners)
|
||||
resultChan := make(chan TeamProcessResult, totalOwners)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 启动 worker
|
||||
for w := 0; w < workerCount; w++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for idx := range taskChan {
|
||||
if !teamProcessState.Running {
|
||||
return
|
||||
}
|
||||
result := processSingleTeam(idx, req)
|
||||
resultChan <- result
|
||||
atomic.AddInt32(&teamProcessState.Completed, 1)
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// 发送任务
|
||||
go func() {
|
||||
for i := 0; i < totalOwners; i++ {
|
||||
if !teamProcessState.Running {
|
||||
break
|
||||
}
|
||||
taskChan <- i
|
||||
}
|
||||
close(taskChan)
|
||||
}()
|
||||
|
||||
// 等待完成并收集结果
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
for result := range resultChan {
|
||||
teamProcessState.mu.Lock()
|
||||
teamProcessState.Results = append(teamProcessState.Results, result)
|
||||
teamProcessState.mu.Unlock()
|
||||
}
|
||||
|
||||
logger.Success(fmt.Sprintf("Team process complete: %d/%d teams processed", teamProcessState.Completed, totalOwners), "", "team")
|
||||
}
|
||||
|
||||
// processSingleTeam 处理单个 Team
|
||||
func processSingleTeam(idx int, req TeamProcessRequest) TeamProcessResult {
|
||||
startTime := time.Now()
|
||||
owner := req.Owners[idx]
|
||||
result := TeamProcessResult{
|
||||
TeamIndex: idx + 1,
|
||||
OwnerEmail: owner.Email,
|
||||
MemberEmails: make([]string, 0),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
logPrefix := fmt.Sprintf("[Team %d]", idx+1)
|
||||
logger.Info(fmt.Sprintf("%s Starting with owner: %s", logPrefix, owner.Email), owner.Email, "team")
|
||||
|
||||
// Step 1: 获取 Team ID
|
||||
inviter := invite.NewWithProxy(owner.Token, req.Proxy)
|
||||
teamID, err := inviter.GetAccountID()
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("获取 Team ID 失败: %v", err))
|
||||
result.DurationMs = time.Since(startTime).Milliseconds()
|
||||
logger.Error(fmt.Sprintf("%s Failed to get Team ID: %v", logPrefix, err), owner.Email, "team")
|
||||
return result
|
||||
}
|
||||
result.TeamID = teamID
|
||||
logger.Success(fmt.Sprintf("%s Team ID: %s", logPrefix, teamID), owner.Email, "team")
|
||||
|
||||
// Step 2: 生成成员邮箱并发送邀请
|
||||
type MemberAccount struct {
|
||||
Email string
|
||||
Password string
|
||||
Success bool
|
||||
}
|
||||
children := make([]MemberAccount, req.MembersPerTeam)
|
||||
for i := 0; i < req.MembersPerTeam; i++ {
|
||||
children[i].Email = mail.GenerateEmail()
|
||||
children[i].Password = register.GeneratePassword()
|
||||
logger.Info(fmt.Sprintf("%s [Member %d] Email: %s", logPrefix, i+1, children[i].Email), children[i].Email, "team")
|
||||
}
|
||||
|
||||
// 发送邀请
|
||||
inviteEmails := make([]string, req.MembersPerTeam)
|
||||
for i, c := range children {
|
||||
inviteEmails[i] = c.Email
|
||||
}
|
||||
if err := inviter.SendInvites(inviteEmails); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("发送邀请失败: %v", err))
|
||||
result.DurationMs = time.Since(startTime).Milliseconds()
|
||||
return result
|
||||
}
|
||||
logger.Success(fmt.Sprintf("%s Sent %d invite(s)", logPrefix, len(inviteEmails)), owner.Email, "team")
|
||||
|
||||
// Step 3: 并发注册成员
|
||||
var memberWg sync.WaitGroup
|
||||
memberMutex := sync.Mutex{}
|
||||
|
||||
for i := range children {
|
||||
memberWg.Add(1)
|
||||
go func(memberIdx int) {
|
||||
defer memberWg.Done()
|
||||
|
||||
memberMutex.Lock()
|
||||
email := children[memberIdx].Email
|
||||
password := children[memberIdx].Password
|
||||
memberMutex.Unlock()
|
||||
|
||||
name := register.GenerateName()
|
||||
birthdate := register.GenerateBirthdate()
|
||||
|
||||
// 重试逻辑
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if !teamProcessState.Running {
|
||||
return
|
||||
}
|
||||
|
||||
if attempt > 0 {
|
||||
email = mail.GenerateEmail()
|
||||
password = register.GeneratePassword()
|
||||
logger.Info(fmt.Sprintf("%s [Member %d] Retry %d: %s", logPrefix, memberIdx+1, attempt, email), email, "team")
|
||||
|
||||
if err := inviter.SendInvites([]string{email}); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, err := registerWithTimeout(email, password, name, birthdate, req.Proxy)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "验证码") {
|
||||
continue
|
||||
}
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Member %d: %v", memberIdx+1, err))
|
||||
return
|
||||
}
|
||||
|
||||
memberMutex.Lock()
|
||||
children[memberIdx].Email = email
|
||||
children[memberIdx].Password = password
|
||||
children[memberIdx].Success = true
|
||||
memberMutex.Unlock()
|
||||
|
||||
logger.Success(fmt.Sprintf("%s [Member %d] Registered", logPrefix, memberIdx+1), email, "team")
|
||||
return
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
memberWg.Wait()
|
||||
|
||||
// 统计注册成功数
|
||||
registeredChildren := make([]MemberAccount, 0)
|
||||
for _, c := range children {
|
||||
if c.Success {
|
||||
registeredChildren = append(registeredChildren, c)
|
||||
result.MemberEmails = append(result.MemberEmails, c.Email)
|
||||
result.Registered++
|
||||
}
|
||||
}
|
||||
logger.Info(fmt.Sprintf("%s Registered: %d/%d", logPrefix, result.Registered, req.MembersPerTeam), owner.Email, "team")
|
||||
|
||||
// Step 4: S2A 授权入库
|
||||
for i, child := range registeredChildren {
|
||||
if !teamProcessState.Running {
|
||||
break
|
||||
}
|
||||
|
||||
s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Member %d auth URL: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据配置选择浏览器自动化
|
||||
var code string
|
||||
if req.BrowserType == "rod" {
|
||||
code, err = auth.CompleteWithRod(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy)
|
||||
} else {
|
||||
code, err = auth.CompleteWithChromedp(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy)
|
||||
}
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Member %d browser: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 提交到 S2A
|
||||
_, err = auth.SubmitS2AOAuth(
|
||||
config.Global.S2AApiBase,
|
||||
config.Global.S2AAdminKey,
|
||||
s2aResp.Data.SessionID,
|
||||
code,
|
||||
child.Email,
|
||||
config.Global.Concurrency,
|
||||
config.Global.Priority,
|
||||
config.Global.GroupIDs,
|
||||
config.Global.ProxyID,
|
||||
)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Member %d S2A: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
|
||||
result.AddedToS2A++
|
||||
logger.Success(fmt.Sprintf("%s [Member %d] Added to S2A", logPrefix, i+1), child.Email, "team")
|
||||
}
|
||||
|
||||
result.DurationMs = time.Since(startTime).Milliseconds()
|
||||
logger.Success(fmt.Sprintf("%s Complete: %d registered, %d in S2A", logPrefix, result.Registered, result.AddedToS2A), owner.Email, "team")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// registerWithTimeout 带超时的注册
|
||||
func registerWithTimeout(email, password, name, birthdate, proxy string) (*register.ChatGPTReg, error) {
|
||||
reg, err := register.New(proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := reg.InitSession(); err != nil {
|
||||
return nil, fmt.Errorf("初始化失败: %v", err)
|
||||
}
|
||||
if err := reg.GetAuthorizeURL(email); err != nil {
|
||||
return nil, fmt.Errorf("获取授权URL失败: %v", err)
|
||||
}
|
||||
if err := reg.StartAuthorize(); err != nil {
|
||||
return nil, fmt.Errorf("启动授权失败: %v", err)
|
||||
}
|
||||
if err := reg.Register(email, password); err != nil {
|
||||
return nil, fmt.Errorf("注册失败: %v", err)
|
||||
}
|
||||
if err := reg.SendVerificationEmail(); err != nil {
|
||||
return nil, fmt.Errorf("发送邮件失败: %v", err)
|
||||
}
|
||||
|
||||
// 短超时获取验证码
|
||||
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
|
||||
if err != nil {
|
||||
otpCode, err = mail.GetVerificationCode(email, 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("验证码获取超时")
|
||||
}
|
||||
}
|
||||
|
||||
if err := reg.ValidateOTP(otpCode); err != nil {
|
||||
return nil, fmt.Errorf("OTP验证失败: %v", err)
|
||||
}
|
||||
if err := reg.CreateAccount(name, birthdate); err != nil {
|
||||
return nil, fmt.Errorf("创建账户失败: %v", err)
|
||||
}
|
||||
|
||||
_ = reg.GetSessionToken()
|
||||
return reg, nil
|
||||
}
|
||||
194
backend/internal/auth/chromedp.go
Normal file
194
backend/internal/auth/chromedp.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权
|
||||
func CompleteWithChromedp(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", headless),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("disable-blink-features", "AutomationControlled"),
|
||||
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"),
|
||||
)
|
||||
|
||||
if proxy != "" {
|
||||
opts = append(opts, chromedp.ProxyServer(proxy))
|
||||
}
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var callbackURL string
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
if req, ok := ev.(*network.EventRequestWillBeSent); ok {
|
||||
url := req.Request.URL
|
||||
if strings.Contains(url, "localhost") && strings.Contains(url, "code=") {
|
||||
callbackURL = url
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
network.Enable(),
|
||||
chromedp.Navigate(authURL),
|
||||
chromedp.WaitReady("body"),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("访问失败: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if callbackURL != "" {
|
||||
return ExtractCodeFromCallbackURL(callbackURL), nil
|
||||
}
|
||||
|
||||
var currentURL string
|
||||
_ = chromedp.Run(ctx, chromedp.Location(¤tURL))
|
||||
|
||||
if strings.Contains(currentURL, "code=") {
|
||||
return ExtractCodeFromCallbackURL(currentURL), nil
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
emailSelectors := []string{
|
||||
`input[name="email"]`,
|
||||
`input[type="email"]`,
|
||||
`input[name="username"]`,
|
||||
}
|
||||
|
||||
var emailFilled bool
|
||||
for _, sel := range emailSelectors {
|
||||
err = chromedp.Run(ctx, chromedp.WaitVisible(sel, chromedp.ByQuery))
|
||||
if err == nil {
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Clear(sel, chromedp.ByQuery),
|
||||
chromedp.SendKeys(sel, email, chromedp.ByQuery),
|
||||
)
|
||||
if err == nil {
|
||||
emailFilled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !emailFilled {
|
||||
return "", fmt.Errorf("未找到邮箱输入框")
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
buttonSelectors := []string{
|
||||
`button[type="submit"]`,
|
||||
`button[data-testid="login-button"]`,
|
||||
`button.continue-btn`,
|
||||
`input[type="submit"]`,
|
||||
}
|
||||
|
||||
for _, sel := range buttonSelectors {
|
||||
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
if callbackURL != "" {
|
||||
return ExtractCodeFromCallbackURL(callbackURL), nil
|
||||
}
|
||||
|
||||
_ = chromedp.Run(ctx, chromedp.Location(¤tURL))
|
||||
if strings.Contains(currentURL, "code=") {
|
||||
return ExtractCodeFromCallbackURL(currentURL), nil
|
||||
}
|
||||
|
||||
passwordSelectors := []string{
|
||||
`input[name="current-password"]`,
|
||||
`input[name="password"]`,
|
||||
`input[type="password"]`,
|
||||
}
|
||||
|
||||
var passwordFilled bool
|
||||
for _, sel := range passwordSelectors {
|
||||
err = chromedp.Run(ctx, chromedp.WaitVisible(sel, chromedp.ByQuery))
|
||||
if err == nil {
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Clear(sel, chromedp.ByQuery),
|
||||
chromedp.SendKeys(sel, password, chromedp.ByQuery),
|
||||
)
|
||||
if err == nil {
|
||||
passwordFilled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !passwordFilled {
|
||||
return "", fmt.Errorf("未找到密码输入框")
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
for _, sel := range buttonSelectors {
|
||||
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if callbackURL != "" {
|
||||
return ExtractCodeFromCallbackURL(callbackURL), nil
|
||||
}
|
||||
|
||||
var url string
|
||||
if err := chromedp.Run(ctx, chromedp.Location(&url)); err == nil {
|
||||
if strings.Contains(url, "code=") {
|
||||
return ExtractCodeFromCallbackURL(url), nil
|
||||
}
|
||||
|
||||
if strings.Contains(url, "consent") {
|
||||
for _, sel := range buttonSelectors {
|
||||
err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
if strings.Contains(url, "authorize") && teamID != "" {
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Click(fmt.Sprintf(`[data-workspace-id="%s"], [data-account-id="%s"]`, teamID, teamID), chromedp.ByQuery),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if callbackURL != "" {
|
||||
return ExtractCodeFromCallbackURL(callbackURL), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("授权超时")
|
||||
}
|
||||
167
backend/internal/auth/rod.go
Normal file
167
backend/internal/auth/rod.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/go-rod/stealth"
|
||||
)
|
||||
|
||||
// RodAuth 使用 Rod + Stealth 完成 OAuth 授权
|
||||
type RodAuth struct {
|
||||
browser *rod.Browser
|
||||
headless bool
|
||||
proxy string
|
||||
}
|
||||
|
||||
// NewRodAuth 创建 Rod 授权器
|
||||
func NewRodAuth(headless bool, proxy string) (*RodAuth, error) {
|
||||
l := launcher.New().
|
||||
Headless(headless).
|
||||
Set("disable-blink-features", "AutomationControlled").
|
||||
Set("disable-dev-shm-usage").
|
||||
Set("no-sandbox").
|
||||
Set("disable-gpu").
|
||||
Set("disable-extensions").
|
||||
Set("disable-background-networking").
|
||||
Set("disable-sync").
|
||||
Set("disable-translate").
|
||||
Set("metrics-recording-only").
|
||||
Set("no-first-run")
|
||||
|
||||
if proxy != "" {
|
||||
l = l.Proxy(proxy)
|
||||
}
|
||||
|
||||
controlURL, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("启动浏览器失败: %v", err)
|
||||
}
|
||||
|
||||
browser := rod.New().ControlURL(controlURL)
|
||||
if err := browser.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("连接浏览器失败: %v", err)
|
||||
}
|
||||
|
||||
return &RodAuth{
|
||||
browser: browser,
|
||||
headless: headless,
|
||||
proxy: proxy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close 关闭浏览器
|
||||
func (r *RodAuth) Close() {
|
||||
if r.browser != nil {
|
||||
r.browser.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteOAuth 完成 OAuth 授权
|
||||
func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) {
|
||||
page, err := stealth.Page(r.browser)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建页面失败: %v", err)
|
||||
}
|
||||
defer page.Close()
|
||||
|
||||
page = page.Timeout(45 * time.Second)
|
||||
|
||||
if err := page.Navigate(authURL); err != nil {
|
||||
return "", fmt.Errorf("访问授权URL失败: %v", err)
|
||||
}
|
||||
|
||||
page.MustWaitDOMStable()
|
||||
|
||||
if code := r.checkForCode(page); code != "" {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
emailInput, err := page.Timeout(5 * time.Second).Element("input[name='email'], input[type='email'], input[name='username']")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到邮箱输入框")
|
||||
}
|
||||
|
||||
emailInput.MustSelectAllText().MustInput(email)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil {
|
||||
btn.MustClick()
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
if code := r.checkForCode(page); code != "" {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
passwordInput, err := page.Timeout(8 * time.Second).Element("input[type='password']")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到密码输入框")
|
||||
}
|
||||
|
||||
passwordInput.MustSelectAllText().MustInput(password)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil {
|
||||
btn.MustClick()
|
||||
}
|
||||
|
||||
for i := 0; i < 66; i++ {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
if code := r.checkForCode(page); code != "" {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
info, _ := page.Info()
|
||||
currentURL := info.URL
|
||||
|
||||
if strings.Contains(currentURL, "consent") {
|
||||
if btn, _ := page.Timeout(500 * time.Millisecond).Element("button[type='submit']"); btn != nil {
|
||||
btn.Click(proto.InputMouseButtonLeft, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(currentURL, "authorize") && teamID != "" {
|
||||
wsSelector := fmt.Sprintf("[data-workspace-id='%s'], [data-account-id='%s']", teamID, teamID)
|
||||
if wsBtn, _ := page.Timeout(500 * time.Millisecond).Element(wsSelector); wsBtn != nil {
|
||||
wsBtn.Click(proto.InputMouseButtonLeft, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("授权超时")
|
||||
}
|
||||
|
||||
// checkForCode 检查 URL 中是否包含 code
|
||||
func (r *RodAuth) checkForCode(page *rod.Page) string {
|
||||
info, err := page.Info()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(info.URL, "code=") {
|
||||
return ExtractCodeFromCallbackURL(info.URL)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CompleteWithRod 使用 Rod + Stealth 完成 S2A 授权
|
||||
func CompleteWithRod(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
|
||||
auth, err := NewRodAuth(headless, proxy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer auth.Close()
|
||||
|
||||
return auth.CompleteOAuth(authURL, email, password, teamID)
|
||||
}
|
||||
|
||||
// CompleteWithBrowser 使用 Rod 完成 S2A 授权 (别名)
|
||||
func CompleteWithBrowser(authURL, email, password, teamID string, headless bool, proxy string) (string, error) {
|
||||
return CompleteWithRod(authURL, email, password, teamID, headless, proxy)
|
||||
}
|
||||
291
backend/internal/auth/s2a.go
Normal file
291
backend/internal/auth/s2a.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CodexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CodexRedirectURI = "http://localhost:1455/auth/callback"
|
||||
CodexScope = "openid profile email offline_access"
|
||||
)
|
||||
|
||||
// CodexTokens Codex Token 结构
|
||||
type CodexTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ExpiredAt string `json:"expired_at,omitempty"`
|
||||
}
|
||||
|
||||
// S2AAuthURLRequest S2A 授权 URL 请求
|
||||
type S2AAuthURLRequest struct {
|
||||
ProxyID *int `json:"proxy_id,omitempty"`
|
||||
}
|
||||
|
||||
// S2AAuthURLResponse S2A 授权 URL 响应
|
||||
type S2AAuthURLResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
AuthURL string `json:"auth_url"`
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// S2ACreateFromOAuthRequest 提交 OAuth 入库请求
|
||||
type S2ACreateFromOAuthRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Concurrency int `json:"concurrency,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
GroupIDs []int `json:"group_ids,omitempty"`
|
||||
ProxyID *int `json:"proxy_id,omitempty"`
|
||||
}
|
||||
|
||||
// S2ACreateFromOAuthResponse 入库响应
|
||||
type S2ACreateFromOAuthResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateS2AAuthURL 从 S2A 生成 Codex 授权 URL
|
||||
func GenerateS2AAuthURL(s2aAPIBase, s2aAdminKey string, proxyID *int) (*S2AAuthURLResponse, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
apiURL := s2aAPIBase + "/api/v1/admin/openai/generate-auth-url"
|
||||
|
||||
payload := S2AAuthURLRequest{ProxyID: proxyID}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(body))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", s2aAdminKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if len(respBody) > 0 && respBody[0] == '<' {
|
||||
return nil, fmt.Errorf("服务器返回 HTML: %s", string(respBody)[:min(100, len(respBody))])
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)[:min(200, len(respBody))])
|
||||
}
|
||||
|
||||
var result S2AAuthURLResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v, body: %s", err, string(respBody)[:min(100, len(respBody))])
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("S2A 错误: %s", result.Message)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SubmitS2AOAuth 提交 OAuth code 到 S2A 入库
|
||||
func SubmitS2AOAuth(s2aAPIBase, s2aAdminKey, sessionID, code, name string, concurrency, priority int, groupIDs []int, proxyID *int) (*S2ACreateFromOAuthResponse, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
apiURL := s2aAPIBase + "/api/v1/admin/openai/create-from-oauth"
|
||||
|
||||
payload := S2ACreateFromOAuthRequest{
|
||||
SessionID: sessionID,
|
||||
Code: code,
|
||||
Name: name,
|
||||
Concurrency: concurrency,
|
||||
Priority: priority,
|
||||
GroupIDs: groupIDs,
|
||||
ProxyID: proxyID,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(body))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", s2aAdminKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result S2ACreateFromOAuthResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("S2A 入库失败: %s", result.Message)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// VerifyS2AAccount 验证账号入库状态
|
||||
func VerifyS2AAccount(s2aAPIBase, s2aAdminKey, email string) (bool, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/api/v1/admin/accounts?page=1&page_size=20&search=%s&timezone=Asia/Shanghai", s2aAPIBase, url.QueryEscape(email))
|
||||
|
||||
req, _ := http.NewRequest("GET", apiURL, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Api-Key", s2aAdminKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return false, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if result.Code != 0 || result.Data.Total == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, item := range result.Data.Items {
|
||||
if item.Status == "active" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ExtractCodeFromCallbackURL 从回调 URL 中提取 code
|
||||
func ExtractCodeFromCallbackURL(callbackURL string) string {
|
||||
parsedURL, err := url.Parse(callbackURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsedURL.Query().Get("code")
|
||||
}
|
||||
|
||||
// RefreshCodexToken 刷新 Codex token
|
||||
func RefreshCodexToken(refreshToken string, proxyURL string) (*CodexTokens, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
if proxyURL != "" {
|
||||
proxyURLParsed, _ := url.Parse(proxyURL)
|
||||
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)}
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {CodexClientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"scope": {"openid profile email"},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://auth.openai.com/oauth/token", strings.NewReader(data.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("刷新 token 失败: %d, %s", resp.StatusCode, string(body)[:min(200, len(body))])
|
||||
}
|
||||
|
||||
var tokens CodexTokens
|
||||
if err := json.Unmarshal(body, &tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokens.ExpiresIn > 0 {
|
||||
expiredAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||||
tokens.ExpiredAt = expiredAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return &tokens, nil
|
||||
}
|
||||
|
||||
// ExtractWorkspaceFromCookie 从 cookie 提取 workspace_id
|
||||
func ExtractWorkspaceFromCookie(cookieValue string) string {
|
||||
parts := strings.Split(cookieValue, ".")
|
||||
if len(parts) < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := parts[0]
|
||||
if m := len(payload) % 4; m != 0 {
|
||||
payload += strings.Repeat("=", 4-m)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Workspaces []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"workspaces"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(decoded, &data); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(data.Workspaces) > 0 {
|
||||
return data.Workspaces[0].ID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
240
backend/internal/client/tls.go
Normal file
240
backend/internal/client/tls.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
http2 "github.com/bogdanfinn/fhttp"
|
||||
tls_client "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
// TLSClient 使用 tls-client 模拟浏览器指纹的 HTTP 客户端
|
||||
type TLSClient struct {
|
||||
client tls_client.HttpClient
|
||||
userAgent string
|
||||
chromeVer string
|
||||
acceptLang string
|
||||
}
|
||||
|
||||
// 语言偏好池
|
||||
var languagePrefs = []string{
|
||||
"en-US,en;q=0.9",
|
||||
"en-GB,en;q=0.9,en-US;q=0.8",
|
||||
"de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
// New 创建一个新的 TLS 客户端
|
||||
func New(proxyStr string) (*TLSClient, error) {
|
||||
jar := tls_client.NewCookieJar()
|
||||
chromeVer := "133"
|
||||
|
||||
options := []tls_client.HttpClientOption{
|
||||
tls_client.WithTimeoutSeconds(60),
|
||||
tls_client.WithClientProfile(profiles.Chrome_133),
|
||||
tls_client.WithRandomTLSExtensionOrder(),
|
||||
tls_client.WithCookieJar(jar),
|
||||
tls_client.WithInsecureSkipVerify(),
|
||||
}
|
||||
|
||||
if proxyStr != "" {
|
||||
options = append(options, tls_client.WithProxyUrl(proxyStr))
|
||||
}
|
||||
|
||||
client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acceptLang := languagePrefs[rand.Intn(len(languagePrefs))]
|
||||
userAgent := generateUserAgent(chromeVer)
|
||||
|
||||
return &TLSClient{
|
||||
client: client,
|
||||
userAgent: userAgent,
|
||||
chromeVer: chromeVer,
|
||||
acceptLang: acceptLang,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateUserAgent 生成随机化的 User-Agent
|
||||
func generateUserAgent(chromeVer string) string {
|
||||
winVersions := []string{
|
||||
"Windows NT 10.0; Win64; x64",
|
||||
"Windows NT 10.0; WOW64",
|
||||
}
|
||||
winVer := winVersions[rand.Intn(len(winVersions))]
|
||||
|
||||
return "Mozilla/5.0 (" + winVer + ") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chromeVer + ".0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
// getDefaultHeaders 获取默认请求头
|
||||
func (c *TLSClient) getDefaultHeaders() map[string]string {
|
||||
secChUa := `"Chromium";v="` + c.chromeVer + `", "Not(A:Brand";v="99", "Google Chrome";v="` + c.chromeVer + `"`
|
||||
|
||||
return map[string]string{
|
||||
"User-Agent": c.userAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": c.acceptLang,
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Sec-Ch-Ua": secChUa,
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": `"Windows"`,
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
}
|
||||
|
||||
// Do 执行 HTTP 请求
|
||||
func (c *TLSClient) Do(req *http.Request) (*http.Response, error) {
|
||||
fhttpReq, err := http2.NewRequest(req.Method, req.URL.String(), req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range c.getDefaultHeaders() {
|
||||
if req.Header.Get(key) == "" {
|
||||
fhttpReq.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
for key, values := range req.Header {
|
||||
for _, value := range values {
|
||||
fhttpReq.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(fhttpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
finalReq := req
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalReq = &http.Request{
|
||||
Method: resp.Request.Method,
|
||||
URL: (*url.URL)(resp.Request.URL),
|
||||
Header: http.Header(resp.Request.Header),
|
||||
}
|
||||
}
|
||||
|
||||
stdResp := &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Proto: resp.Proto,
|
||||
ProtoMajor: resp.ProtoMajor,
|
||||
ProtoMinor: resp.ProtoMinor,
|
||||
Header: http.Header(resp.Header),
|
||||
Body: resp.Body,
|
||||
ContentLength: resp.ContentLength,
|
||||
TransferEncoding: resp.TransferEncoding,
|
||||
Close: resp.Close,
|
||||
Uncompressed: resp.Uncompressed,
|
||||
Request: finalReq,
|
||||
}
|
||||
|
||||
return stdResp, nil
|
||||
}
|
||||
|
||||
// Get 执行 GET 请求
|
||||
func (c *TLSClient) Get(urlStr string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
// Post 执行 POST 请求
|
||||
func (c *TLSClient) Post(urlStr string, contentType string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest("POST", urlStr, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
// PostForm 执行 POST 表单请求
|
||||
func (c *TLSClient) PostForm(urlStr string, data url.Values) (*http.Response, error) {
|
||||
return c.Post(urlStr, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
|
||||
}
|
||||
|
||||
// PostJSON 执行 POST JSON 请求
|
||||
func (c *TLSClient) PostJSON(urlStr string, body io.Reader) (*http.Response, error) {
|
||||
return c.Post(urlStr, "application/json", body)
|
||||
}
|
||||
|
||||
// GetCookie 获取指定 URL 的 Cookie
|
||||
func (c *TLSClient) GetCookie(urlStr string, name string) string {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cookies := c.client.GetCookies(u)
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetCookie 设置 Cookie
|
||||
func (c *TLSClient) SetCookie(urlStr string, cookie *http.Cookie) {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.client.SetCookies(u, []*http2.Cookie{
|
||||
{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Path: cookie.Path,
|
||||
Domain: cookie.Domain,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReadBody 读取响应体并自动处理压缩
|
||||
func ReadBody(resp *http.Response) ([]byte, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch resp.Header.Get("Content-Encoding") {
|
||||
case "gzip":
|
||||
gzReader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return data, nil
|
||||
}
|
||||
defer gzReader.Close()
|
||||
return io.ReadAll(gzReader)
|
||||
case "br":
|
||||
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ReadBodyString 读取响应体为字符串
|
||||
func ReadBodyString(resp *http.Response) (string, error) {
|
||||
body, err := ReadBody(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
264
backend/internal/config/config.go
Normal file
264
backend/internal/config/config.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MailServiceConfig 邮箱服务配置
|
||||
type MailServiceConfig struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
APIBase string `yaml:"api_base" json:"api_base"`
|
||||
APIToken string `yaml:"api_token" json:"api_token"`
|
||||
Domain string `yaml:"domain" json:"domain"`
|
||||
EmailPath string `yaml:"email_path,omitempty" json:"email_path,omitempty"`
|
||||
AddUserAPI string `yaml:"add_user_api,omitempty" json:"add_user_api,omitempty"`
|
||||
}
|
||||
|
||||
// Config 应用配置 (实时从数据库读取)
|
||||
type Config struct {
|
||||
// 服务器配置 (启动时固定)
|
||||
Port int `json:"port"`
|
||||
CorsOrigin string `json:"cors_origin"`
|
||||
|
||||
// S2A 配置 (可实时更新)
|
||||
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"`
|
||||
ProxyID *int `json:"proxy_id"`
|
||||
|
||||
// 代理配置 (可实时更新)
|
||||
ProxyEnabled bool `json:"proxy_enabled"`
|
||||
DefaultProxy string `json:"default_proxy"`
|
||||
|
||||
// 自动化配置
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
AccountsPath string `json:"accounts_path"`
|
||||
|
||||
// 邮箱服务
|
||||
MailServices []MailServiceConfig `json:"mail_services"`
|
||||
}
|
||||
|
||||
// GetProxy 获取代理地址(如果启用)
|
||||
func (c *Config) GetProxy() string {
|
||||
if c.ProxyEnabled && c.DefaultProxy != "" {
|
||||
return c.DefaultProxy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Account 账号结构 (保持 JSON 格式用于账号文件)
|
||||
type Account struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
Pooled bool `json:"pooled,omitempty"`
|
||||
PooledAt string `json:"pooled_at,omitempty"`
|
||||
PoolID int `json:"pool_id,omitempty"`
|
||||
Used bool `json:"used,omitempty"`
|
||||
UsedAt string `json:"used_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetEmail 获取邮箱
|
||||
func (a *Account) GetEmail() string {
|
||||
if a.Email != "" {
|
||||
return a.Email
|
||||
}
|
||||
return a.Account
|
||||
}
|
||||
|
||||
// GetAccessToken 获取 Token
|
||||
func (a *Account) GetAccessToken() string {
|
||||
if a.AccessToken != "" {
|
||||
return a.AccessToken
|
||||
}
|
||||
return a.Token
|
||||
}
|
||||
|
||||
// PoolingConfig 入库任务配置
|
||||
type PoolingConfig struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
SerialAuthorize bool `json:"serial_authorize"`
|
||||
BrowserType string `json:"browser_type"`
|
||||
Headless bool `json:"headless"`
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
|
||||
// 全局配置实例
|
||||
var (
|
||||
Global *Config
|
||||
configMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ConfigDB 配置数据库接口
|
||||
type ConfigDB interface {
|
||||
GetConfig(key string) (string, error)
|
||||
SetConfig(key, value string) error
|
||||
GetAllConfig() (map[string]string, error)
|
||||
}
|
||||
|
||||
var configDB ConfigDB
|
||||
|
||||
// SetConfigDB 设置配置数据库
|
||||
func SetConfigDB(db ConfigDB) {
|
||||
configDB = db
|
||||
}
|
||||
|
||||
// InitFromDB 从数据库初始化配置
|
||||
func InitFromDB() *Config {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
cfg := &Config{
|
||||
Port: 8848,
|
||||
CorsOrigin: "*",
|
||||
Concurrency: 2,
|
||||
Priority: 0,
|
||||
}
|
||||
|
||||
if configDB == nil {
|
||||
Global = cfg
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 从数据库加载配置
|
||||
if v, _ := configDB.GetConfig("s2a_api_base"); v != "" {
|
||||
cfg.S2AApiBase = v
|
||||
}
|
||||
if v, _ := configDB.GetConfig("s2a_admin_key"); v != "" {
|
||||
cfg.S2AAdminKey = v
|
||||
}
|
||||
if v, _ := configDB.GetConfig("concurrency"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Concurrency = n
|
||||
}
|
||||
}
|
||||
if v, _ := configDB.GetConfig("priority"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Priority = n
|
||||
}
|
||||
}
|
||||
if v, _ := configDB.GetConfig("group_ids"); v != "" {
|
||||
var ids []int
|
||||
for _, s := range strings.Split(v, ",") {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
cfg.GroupIDs = ids
|
||||
}
|
||||
if v, _ := configDB.GetConfig("proxy_enabled"); v == "true" {
|
||||
cfg.ProxyEnabled = true
|
||||
}
|
||||
if v, _ := configDB.GetConfig("default_proxy"); v != "" {
|
||||
cfg.DefaultProxy = v
|
||||
}
|
||||
if v, _ := configDB.GetConfig("mail_services"); v != "" {
|
||||
var services []MailServiceConfig
|
||||
if err := json.Unmarshal([]byte(v), &services); err == nil {
|
||||
cfg.MailServices = services
|
||||
}
|
||||
}
|
||||
|
||||
Global = cfg
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveToDB 保存配置到数据库
|
||||
func SaveToDB() error {
|
||||
if configDB == nil || Global == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
configMu.RLock()
|
||||
cfg := Global
|
||||
configMu.RUnlock()
|
||||
|
||||
configDB.SetConfig("s2a_api_base", cfg.S2AApiBase)
|
||||
configDB.SetConfig("s2a_admin_key", cfg.S2AAdminKey)
|
||||
configDB.SetConfig("concurrency", strconv.Itoa(cfg.Concurrency))
|
||||
configDB.SetConfig("priority", strconv.Itoa(cfg.Priority))
|
||||
|
||||
if len(cfg.GroupIDs) > 0 {
|
||||
var ids []string
|
||||
for _, id := range cfg.GroupIDs {
|
||||
ids = append(ids, strconv.Itoa(id))
|
||||
}
|
||||
configDB.SetConfig("group_ids", strings.Join(ids, ","))
|
||||
}
|
||||
|
||||
configDB.SetConfig("proxy_enabled", strconv.FormatBool(cfg.ProxyEnabled))
|
||||
configDB.SetConfig("default_proxy", cfg.DefaultProxy)
|
||||
|
||||
if len(cfg.MailServices) > 0 {
|
||||
data, _ := json.Marshal(cfg.MailServices)
|
||||
configDB.SetConfig("mail_services", string(data))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新配置 (实时生效)
|
||||
func Update(cfg *Config) error {
|
||||
configMu.Lock()
|
||||
Global = cfg
|
||||
configMu.Unlock()
|
||||
return SaveToDB()
|
||||
}
|
||||
|
||||
// Get 获取当前配置
|
||||
func Get() *Config {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return Global
|
||||
}
|
||||
|
||||
// FindPath 查找配置文件路径 (兼容)
|
||||
func FindPath() string {
|
||||
if envPath := os.Getenv("CONFIG_PATH"); envPath != "" {
|
||||
return envPath
|
||||
}
|
||||
return "data/config.yaml"
|
||||
}
|
||||
|
||||
// Load 加载配置 (兼容旧代码,现在直接从数据库加载)
|
||||
func Load(path string) (*Config, error) {
|
||||
return InitFromDB(), nil
|
||||
}
|
||||
|
||||
// LoadAccounts 加载账号列表 (保持 JSON 格式)
|
||||
func LoadAccounts(path string) ([]Account, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var accounts []Account
|
||||
if err := json.Unmarshal(data, &accounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// SaveAccounts 保存账号列表 (保持 JSON 格式)
|
||||
func SaveAccounts(path string, accounts []Account) error {
|
||||
data, err := json.MarshalIndent(accounts, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
272
backend/internal/database/sqlite.go
Normal file
272
backend/internal/database/sqlite.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// TeamOwner 账号结构
|
||||
type TeamOwner struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
AccountID string `json:"account_id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DB 数据库管理器
|
||||
type DB struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// 全局数据库实例
|
||||
var Instance *DB
|
||||
|
||||
// Init 初始化数据库
|
||||
func Init(dbPath string) error {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
|
||||
Instance = &DB{db: db}
|
||||
|
||||
if err := Instance.createTables(); err != nil {
|
||||
return fmt.Errorf("创建表失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[数据库] SQLite 已连接: %s\n", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTables 创建表
|
||||
func (d *DB) createTables() error {
|
||||
_, err := d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS team_owners (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT,
|
||||
token TEXT,
|
||||
account_id TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'valid',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_owners_email ON team_owners(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_owners_status ON team_owners(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_owners_account_id ON team_owners(account_id);
|
||||
|
||||
-- 配置表 (key-value 形式)
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfig 获取配置值
|
||||
func (d *DB) GetConfig(key string) (string, error) {
|
||||
var value string
|
||||
err := d.db.QueryRow("SELECT value FROM app_config WHERE key = ?", key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
// SetConfig 设置配置值
|
||||
func (d *DB) SetConfig(key, value string) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
`, key, value, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllConfig 获取所有配置
|
||||
func (d *DB) GetAllConfig() (map[string]string, error) {
|
||||
rows, err := d.db.Query("SELECT key, value FROM app_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddTeamOwner 添加 Team Owner
|
||||
func (d *DB) AddTeamOwner(owner TeamOwner) (int64, error) {
|
||||
result, err := d.db.Exec(`
|
||||
INSERT OR REPLACE INTO team_owners (email, password, token, account_id, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'valid', CURRENT_TIMESTAMP)
|
||||
`, owner.Email, owner.Password, owner.Token, owner.AccountID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
// AddTeamOwners 批量添加
|
||||
func (d *DB) AddTeamOwners(owners []TeamOwner) (int, error) {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT OR REPLACE INTO team_owners (email, password, token, account_id, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'valid', CURRENT_TIMESTAMP)
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
count := 0
|
||||
for _, owner := range owners {
|
||||
_, err := stmt.Exec(owner.Email, owner.Password, owner.Token, owner.AccountID)
|
||||
if err != nil {
|
||||
fmt.Printf("[数据库] 插入失败 %s: %v\n", owner.Email, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetTeamOwners 获取列表
|
||||
func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int, error) {
|
||||
query := "SELECT id, email, password, token, account_id, status, created_at FROM team_owners WHERE 1=1"
|
||||
countQuery := "SELECT COUNT(*) FROM team_owners WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
|
||||
if status != "" {
|
||||
query += " AND status = ?"
|
||||
countQuery += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
var total int
|
||||
err := d.db.QueryRow(countQuery, args...).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := d.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var owners []TeamOwner
|
||||
for rows.Next() {
|
||||
var owner TeamOwner
|
||||
err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
|
||||
return owners, total, nil
|
||||
}
|
||||
|
||||
// GetPendingOwners 获取待处理
|
||||
func (d *DB) GetPendingOwners() ([]TeamOwner, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, email, password, token, account_id, status, created_at
|
||||
FROM team_owners WHERE status = 'valid'
|
||||
ORDER BY created_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var owners []TeamOwner
|
||||
for rows.Next() {
|
||||
var owner TeamOwner
|
||||
err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
// UpdateOwnerStatus 更新状态
|
||||
func (d *DB) UpdateOwnerStatus(id int64, status string) error {
|
||||
_, err := d.db.Exec("UPDATE team_owners SET status = ? WHERE id = ?", status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTeamOwner 删除
|
||||
func (d *DB) DeleteTeamOwner(id int64) error {
|
||||
_, err := d.db.Exec("DELETE FROM team_owners WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearTeamOwners 清空
|
||||
func (d *DB) ClearTeamOwners() error {
|
||||
_, err := d.db.Exec("DELETE FROM team_owners")
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOwnerStats 获取统计
|
||||
func (d *DB) GetOwnerStats() map[string]int {
|
||||
stats := map[string]int{
|
||||
"total": 0,
|
||||
"valid": 0,
|
||||
"registered": 0,
|
||||
"pooled": 0,
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners").Scan(&count); err == nil {
|
||||
stats["total"] = count
|
||||
}
|
||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'valid'").Scan(&count); err == nil {
|
||||
stats["valid"] = count
|
||||
}
|
||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'registered'").Scan(&count); err == nil {
|
||||
stats["registered"] = count
|
||||
}
|
||||
if err := d.db.QueryRow("SELECT COUNT(*) FROM team_owners WHERE status = 'pooled'").Scan(&count); err == nil {
|
||||
stats["pooled"] = count
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Close 关闭数据库
|
||||
func (d *DB) Close() error {
|
||||
if d.db != nil {
|
||||
return d.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
190
backend/internal/invite/team.go
Normal file
190
backend/internal/invite/team.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"codex-pool/internal/client"
|
||||
)
|
||||
|
||||
// DefaultProxy 默认代理
|
||||
const DefaultProxy = "http://127.0.0.1:7890"
|
||||
|
||||
// TeamInviter Team 邀请器
|
||||
type TeamInviter struct {
|
||||
client *client.TLSClient
|
||||
accessToken string
|
||||
accountID string
|
||||
}
|
||||
|
||||
// InviteRequest 邀请请求
|
||||
type InviteRequest struct {
|
||||
EmailAddresses []string `json:"email_addresses"`
|
||||
Role string `json:"role"`
|
||||
ResendEmails bool `json:"resend_emails"`
|
||||
}
|
||||
|
||||
// AccountCheckResponse 账号检查响应
|
||||
type AccountCheckResponse struct {
|
||||
Accounts map[string]struct {
|
||||
Account struct {
|
||||
PlanType string `json:"plan_type"`
|
||||
} `json:"account"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
// New 创建邀请器 (使用默认代理)
|
||||
func New(accessToken string) *TeamInviter {
|
||||
c, _ := client.New(DefaultProxy)
|
||||
return &TeamInviter{
|
||||
client: c,
|
||||
accessToken: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithProxy 创建邀请器 (指定代理)
|
||||
func NewWithProxy(accessToken, proxy string) *TeamInviter {
|
||||
c, _ := client.New(proxy)
|
||||
return &TeamInviter{
|
||||
client: c,
|
||||
accessToken: accessToken,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccountID 获取 Team 的 account_id (workspace_id)
|
||||
func (t *TeamInviter) GetAccountID() (string, error) {
|
||||
req, _ := http.NewRequest("GET", "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:min(200, len(body))])
|
||||
}
|
||||
|
||||
var result AccountCheckResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析失败: %v", err)
|
||||
}
|
||||
|
||||
// 查找 team plan 的 account_id
|
||||
for accountID, info := range result.Accounts {
|
||||
if accountID != "default" && info.Account.PlanType == "team" {
|
||||
t.accountID = accountID
|
||||
return accountID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到 team,返回第一个非 default 的
|
||||
for accountID := range result.Accounts {
|
||||
if accountID != "default" {
|
||||
t.accountID = accountID
|
||||
return accountID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到 account_id")
|
||||
}
|
||||
|
||||
// SendInvites 发送邀请
|
||||
func (t *TeamInviter) SendInvites(emails []string) error {
|
||||
if t.accountID == "" {
|
||||
return fmt.Errorf("未设置 account_id,请先调用 GetAccountID()")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", t.accountID)
|
||||
|
||||
payload := InviteRequest{
|
||||
EmailAddresses: emails,
|
||||
Role: "standard-user",
|
||||
ResendEmails: true,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Chatgpt-Account-Id", t.accountID)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)[:min(200, len(respBody))])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPendingInvites 获取待处理的邀请列表
|
||||
func (t *TeamInviter) GetPendingInvites() ([]string, error) {
|
||||
if t.accountID == "" {
|
||||
return nil, fmt.Errorf("未设置 account_id")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites?offset=0&limit=100&query=", t.accountID)
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Invites []struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"invites"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var emails []string
|
||||
for _, inv := range result.Invites {
|
||||
emails = append(emails, inv.Email)
|
||||
}
|
||||
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
// AcceptInvite 接受邀请 (使用被邀请账号的 token)
|
||||
func AcceptInvite(inviteLink string, accessToken string) error {
|
||||
c, _ := client.New(DefaultProxy)
|
||||
|
||||
req, _ := http.NewRequest("GET", inviteLink, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 302 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:min(100, len(body))])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
140
backend/internal/logger/logger.go
Normal file
140
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry 日志条目
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
|
||||
// 日志存储
|
||||
var (
|
||||
logs = make([]LogEntry, 0, 1000)
|
||||
logsMu sync.RWMutex
|
||||
listeners = make(map[string]chan LogEntry)
|
||||
listMu sync.RWMutex
|
||||
)
|
||||
|
||||
// AddListener 添加日志监听器
|
||||
func AddListener(id string) chan LogEntry {
|
||||
listMu.Lock()
|
||||
defer listMu.Unlock()
|
||||
ch := make(chan LogEntry, 100)
|
||||
listeners[id] = ch
|
||||
return ch
|
||||
}
|
||||
|
||||
// RemoveListener 移除日志监听器
|
||||
func RemoveListener(id string) {
|
||||
listMu.Lock()
|
||||
defer listMu.Unlock()
|
||||
if ch, ok := listeners[id]; ok {
|
||||
close(ch)
|
||||
delete(listeners, id)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast 广播日志
|
||||
func broadcast(entry LogEntry) {
|
||||
listMu.RLock()
|
||||
defer listMu.RUnlock()
|
||||
for _, ch := range listeners {
|
||||
select {
|
||||
case ch <- entry:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// log 记录日志
|
||||
func log(level, message, email, module string) {
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Message: message,
|
||||
Email: email,
|
||||
Module: module,
|
||||
}
|
||||
|
||||
logsMu.Lock()
|
||||
if len(logs) >= 1000 {
|
||||
logs = logs[100:]
|
||||
}
|
||||
logs = append(logs, entry)
|
||||
logsMu.Unlock()
|
||||
|
||||
broadcast(entry)
|
||||
|
||||
// 打印到控制台
|
||||
prefix := ""
|
||||
switch level {
|
||||
case "info":
|
||||
prefix = "[INFO]"
|
||||
case "success":
|
||||
prefix = "[SUCCESS]"
|
||||
case "error":
|
||||
prefix = "[ERROR]"
|
||||
case "warning":
|
||||
prefix = "[WARN]"
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
fmt.Printf("%s [%s] %s - %s\n", prefix, module, email, message)
|
||||
} else {
|
||||
fmt.Printf("%s [%s] %s\n", prefix, module, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Info 记录信息日志
|
||||
func Info(message, email, module string) {
|
||||
log("info", message, email, module)
|
||||
}
|
||||
|
||||
// Success 记录成功日志
|
||||
func Success(message, email, module string) {
|
||||
log("success", message, email, module)
|
||||
}
|
||||
|
||||
// Error 记录错误日志
|
||||
func Error(message, email, module string) {
|
||||
log("error", message, email, module)
|
||||
}
|
||||
|
||||
// Warning 记录警告日志
|
||||
func Warning(message, email, module string) {
|
||||
log("warning", message, email, module)
|
||||
}
|
||||
|
||||
// GetLogs 获取日志
|
||||
func GetLogs(limit int) []LogEntry {
|
||||
logsMu.RLock()
|
||||
defer logsMu.RUnlock()
|
||||
|
||||
if limit <= 0 || limit > len(logs) {
|
||||
limit = len(logs)
|
||||
}
|
||||
|
||||
start := len(logs) - limit
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
result := make([]LogEntry, limit)
|
||||
copy(result, logs[start:])
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearLogs 清空日志
|
||||
func ClearLogs() {
|
||||
logsMu.Lock()
|
||||
defer logsMu.Unlock()
|
||||
logs = make([]LogEntry, 0, 1000)
|
||||
}
|
||||
455
backend/internal/mail/service.go
Normal file
455
backend/internal/mail/service.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codex-pool/internal/config"
|
||||
)
|
||||
|
||||
// 默认邮箱配置
|
||||
var defaultMailServices = []config.MailServiceConfig{
|
||||
{
|
||||
Name: "esyteam",
|
||||
APIBase: "https://mail.esyteam.edu.kg",
|
||||
APIToken: "005d6f3e-5312-4c37-8125-e1f71243e1ba",
|
||||
Domain: "esyteam.edu.kg",
|
||||
EmailPath: "/api/public/emailList",
|
||||
AddUserAPI: "/api/public/addUser",
|
||||
},
|
||||
}
|
||||
|
||||
// 全局变量
|
||||
var (
|
||||
currentMailServices []config.MailServiceConfig
|
||||
mailServicesMutex sync.RWMutex
|
||||
currentServiceIndex int
|
||||
)
|
||||
|
||||
func init() {
|
||||
currentMailServices = defaultMailServices
|
||||
}
|
||||
|
||||
// Init 初始化邮箱服务配置
|
||||
func Init(services []config.MailServiceConfig) {
|
||||
mailServicesMutex.Lock()
|
||||
defer mailServicesMutex.Unlock()
|
||||
|
||||
if len(services) > 0 {
|
||||
for i := range services {
|
||||
if services[i].EmailPath == "" {
|
||||
services[i].EmailPath = "/api/public/emailList"
|
||||
}
|
||||
if services[i].AddUserAPI == "" {
|
||||
services[i].AddUserAPI = "/api/public/addUser"
|
||||
}
|
||||
if services[i].Name == "" {
|
||||
services[i].Name = fmt.Sprintf("mail-service-%d", i+1)
|
||||
}
|
||||
}
|
||||
currentMailServices = services
|
||||
fmt.Printf("[邮箱] 已加载 %d 个邮箱服务配置:\n", len(services))
|
||||
for _, s := range services {
|
||||
fmt.Printf(" - %s (%s) @ %s\n", s.Name, s.Domain, s.APIBase)
|
||||
}
|
||||
} else {
|
||||
currentMailServices = defaultMailServices
|
||||
fmt.Println("[邮箱] 使用默认邮箱服务配置")
|
||||
}
|
||||
currentServiceIndex = 0
|
||||
}
|
||||
|
||||
// GetServices 获取当前邮箱服务配置
|
||||
func GetServices() []config.MailServiceConfig {
|
||||
mailServicesMutex.RLock()
|
||||
defer mailServicesMutex.RUnlock()
|
||||
return currentMailServices
|
||||
}
|
||||
|
||||
// GetNextService 轮询获取下一个邮箱服务
|
||||
func GetNextService() config.MailServiceConfig {
|
||||
mailServicesMutex.Lock()
|
||||
defer mailServicesMutex.Unlock()
|
||||
|
||||
if len(currentMailServices) == 0 {
|
||||
return defaultMailServices[0]
|
||||
}
|
||||
|
||||
service := currentMailServices[currentServiceIndex]
|
||||
currentServiceIndex = (currentServiceIndex + 1) % len(currentMailServices)
|
||||
return service
|
||||
}
|
||||
|
||||
// GetRandomService 随机获取一个邮箱服务
|
||||
func GetRandomService() config.MailServiceConfig {
|
||||
mailServicesMutex.RLock()
|
||||
defer mailServicesMutex.RUnlock()
|
||||
|
||||
if len(currentMailServices) == 0 {
|
||||
return defaultMailServices[0]
|
||||
}
|
||||
|
||||
return currentMailServices[rand.Intn(len(currentMailServices))]
|
||||
}
|
||||
|
||||
// GetServiceByDomain 根据域名获取对应的邮箱服务
|
||||
func GetServiceByDomain(domain string) *config.MailServiceConfig {
|
||||
mailServicesMutex.RLock()
|
||||
defer mailServicesMutex.RUnlock()
|
||||
|
||||
for _, s := range currentMailServices {
|
||||
if s.Domain == domain || strings.HasSuffix(domain, "."+s.Domain) {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 邮件结构 ====================
|
||||
|
||||
// EmailListRequest 邮件列表请求
|
||||
type EmailListRequest struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
TimeSort string `json:"timeSort"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// EmailListResponse 邮件列表响应
|
||||
type EmailListResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data []EmailItem `json:"data"`
|
||||
}
|
||||
|
||||
// EmailItem 邮件项
|
||||
type EmailItem struct {
|
||||
Content string `json:"content"`
|
||||
Text string `json:"text"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// AddUserRequest 创建用户请求
|
||||
type AddUserRequest struct {
|
||||
List []AddUserItem `json:"list"`
|
||||
}
|
||||
|
||||
// AddUserItem 用户项
|
||||
type AddUserItem struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AddUserResponse 创建用户响应
|
||||
type AddUserResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ==================== 邮箱生成 ====================
|
||||
|
||||
// GenerateEmail 生成随机邮箱并在邮件系统中创建
|
||||
func GenerateEmail() string {
|
||||
return GenerateEmailWithService(GetNextService())
|
||||
}
|
||||
|
||||
// GenerateEmailWithService 使用指定服务生成随机邮箱
|
||||
func GenerateEmailWithService(service config.MailServiceConfig) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 10)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
email := string(b) + "@" + service.Domain
|
||||
|
||||
if err := CreateMailboxWithService(email, service); err != nil {
|
||||
fmt.Printf(" [!] 创建邮箱失败 (%s): %v (继续尝试)\n", service.Name, err)
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// CreateMailbox 在邮件系统中创建邮箱
|
||||
func CreateMailbox(email string) error {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("无效的邮箱地址: %s", email)
|
||||
}
|
||||
|
||||
domain := parts[1]
|
||||
service := GetServiceByDomain(domain)
|
||||
if service == nil {
|
||||
services := GetServices()
|
||||
if len(services) > 0 {
|
||||
service = &services[0]
|
||||
} else {
|
||||
return fmt.Errorf("没有可用的邮箱服务")
|
||||
}
|
||||
}
|
||||
|
||||
return CreateMailboxWithService(email, *service)
|
||||
}
|
||||
|
||||
// CreateMailboxWithService 使用指定服务在邮件系统中创建邮箱
|
||||
func CreateMailboxWithService(email string, service config.MailServiceConfig) error {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) == 2 {
|
||||
domain := parts[1]
|
||||
if strings.HasSuffix(domain, "."+service.Domain) {
|
||||
email = parts[0] + "@" + service.Domain
|
||||
}
|
||||
}
|
||||
|
||||
payload := AddUserRequest{
|
||||
List: []AddUserItem{
|
||||
{Email: email, Password: GeneratePassword()},
|
||||
},
|
||||
}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", service.APIBase+service.AddUserAPI, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", service.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result AddUserResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
if strings.Contains(result.Message, "exist") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("API 错误: %s", result.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePassword 生成随机密码
|
||||
func GeneratePassword() string {
|
||||
const (
|
||||
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
lower = "abcdefghijklmnopqrstuvwxyz"
|
||||
digits = "0123456789"
|
||||
special = "#$%@!"
|
||||
)
|
||||
|
||||
password := make([]byte, 12)
|
||||
password[0] = upper[rand.Intn(len(upper))]
|
||||
password[1] = lower[rand.Intn(len(lower))]
|
||||
password[10] = digits[rand.Intn(len(digits))]
|
||||
password[11] = special[rand.Intn(len(special))]
|
||||
|
||||
charset := upper + lower
|
||||
for i := 2; i < 10; i++ {
|
||||
password[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
|
||||
return string(password)
|
||||
}
|
||||
|
||||
// ==================== 邮件客户端 ====================
|
||||
|
||||
// Client 邮件客户端
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
service *config.MailServiceConfig
|
||||
}
|
||||
|
||||
// NewClient 创建邮件客户端
|
||||
func NewClient() *Client {
|
||||
services := GetServices()
|
||||
var service *config.MailServiceConfig
|
||||
if len(services) > 0 {
|
||||
service = &services[0]
|
||||
} else {
|
||||
service = &defaultMailServices[0]
|
||||
}
|
||||
return &Client{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithService 创建指定服务的邮件客户端
|
||||
func NewClientWithService(service config.MailServiceConfig) *Client {
|
||||
return &Client{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
service: &service,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientForEmail 根据邮箱地址创建对应的邮件客户端
|
||||
func NewClientForEmail(email string) *Client {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) == 2 {
|
||||
if service := GetServiceByDomain(parts[1]); service != nil {
|
||||
return NewClientWithService(*service)
|
||||
}
|
||||
}
|
||||
return NewClient()
|
||||
}
|
||||
|
||||
// GetEmails 获取邮件列表
|
||||
func (m *Client) GetEmails(email string, size int) ([]EmailItem, error) {
|
||||
service := m.service
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) == 2 {
|
||||
if s := GetServiceByDomain(parts[1]); s != nil {
|
||||
service = s
|
||||
}
|
||||
}
|
||||
|
||||
url := service.APIBase + service.EmailPath
|
||||
|
||||
payload := EmailListRequest{
|
||||
ToEmail: email,
|
||||
TimeSort: "desc",
|
||||
Size: size,
|
||||
}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Authorization", service.APIToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result EmailListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return nil, fmt.Errorf("API 错误: %d", result.Code)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// WaitForCode 等待验证码邮件
|
||||
func (m *Client) WaitForCode(email string, timeout time.Duration) (string, error) {
|
||||
start := time.Now()
|
||||
codeRegex := regexp.MustCompile(`\b(\d{6})\b`)
|
||||
|
||||
for time.Since(start) < timeout {
|
||||
emails, err := m.GetEmails(email, 10)
|
||||
if err == nil {
|
||||
for _, mail := range emails {
|
||||
subject := strings.ToLower(mail.Subject)
|
||||
// 匹配多种可能的验证码邮件主题
|
||||
isCodeEmail := strings.Contains(subject, "code") ||
|
||||
strings.Contains(subject, "verify") ||
|
||||
strings.Contains(subject, "verification") ||
|
||||
strings.Contains(subject, "openai") ||
|
||||
strings.Contains(subject, "confirm")
|
||||
|
||||
if !isCodeEmail {
|
||||
continue
|
||||
}
|
||||
|
||||
content := mail.Content
|
||||
if content == "" {
|
||||
content = mail.Text
|
||||
}
|
||||
matches := codeRegex.FindStringSubmatch(content)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("验证码获取超时")
|
||||
}
|
||||
|
||||
// WaitForInviteLink 等待邀请邮件并提取链接
|
||||
func (m *Client) WaitForInviteLink(email string, timeout time.Duration) (string, error) {
|
||||
start := time.Now()
|
||||
|
||||
for time.Since(start) < timeout {
|
||||
emails, err := m.GetEmails(email, 10)
|
||||
if err == nil {
|
||||
for _, mail := range emails {
|
||||
content := mail.Content
|
||||
if content == "" {
|
||||
content = mail.Text
|
||||
}
|
||||
|
||||
if strings.Contains(mail.Subject, "invite") ||
|
||||
strings.Contains(mail.Subject, "Team") ||
|
||||
strings.Contains(mail.Subject, "ChatGPT") ||
|
||||
strings.Contains(content, "invite") {
|
||||
|
||||
link := extractInviteLink(content)
|
||||
if link != "" {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("等待邀请邮件超时")
|
||||
}
|
||||
|
||||
// extractInviteLink 从邮件内容提取邀请链接
|
||||
func extractInviteLink(content string) string {
|
||||
patterns := []string{
|
||||
`https://chatgpt\.com/invite/[^\s"'<>]+`,
|
||||
`https://chat\.openai\.com/invite/[^\s"'<>]+`,
|
||||
`https://chatgpt\.com/[^\s"'<>]*accept[^\s"'<>]*`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
match := re.FindString(content)
|
||||
if match != "" {
|
||||
match = strings.ReplaceAll(match, "&", "&")
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ==================== 便捷函数 ====================
|
||||
|
||||
// WaitForInviteEmail 等待邀请邮件
|
||||
func WaitForInviteEmail(email string, timeout time.Duration) (string, error) {
|
||||
client := NewClientForEmail(email)
|
||||
return client.WaitForInviteLink(email, timeout)
|
||||
}
|
||||
|
||||
// GetVerificationCode 获取验证码
|
||||
func GetVerificationCode(email string, timeout time.Duration) (string, error) {
|
||||
client := NewClientForEmail(email)
|
||||
return client.WaitForCode(email, timeout)
|
||||
}
|
||||
415
backend/internal/register/chatgpt.go
Normal file
415
backend/internal/register/chatgpt.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package register
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codex-pool/internal/client"
|
||||
"codex-pool/internal/mail"
|
||||
)
|
||||
|
||||
// ChatGPTReg ChatGPT 注册器
|
||||
type ChatGPTReg struct {
|
||||
Proxy string
|
||||
Client *client.TLSClient
|
||||
AuthSessionLoggingID string
|
||||
OAIDid string
|
||||
CSRFToken string
|
||||
AuthorizeURL string
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// Result 注册结果
|
||||
type Result struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// New 创建注册器
|
||||
func New(proxy string) (*ChatGPTReg, error) {
|
||||
c, err := client.New(proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChatGPTReg{
|
||||
Proxy: proxy,
|
||||
Client: c,
|
||||
AuthSessionLoggingID: GenerateUUID(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitSession 初始化会话
|
||||
func (r *ChatGPTReg) InitSession() error {
|
||||
resp, err := r.Client.Get("https://chatgpt.com")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("初始化失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r.OAIDid = r.Client.GetCookie("https://chatgpt.com", "oai-did")
|
||||
|
||||
csrfCookie := r.Client.GetCookie("https://chatgpt.com", "__Host-next-auth.csrf-token")
|
||||
if csrfCookie != "" {
|
||||
decoded, err := url.QueryUnescape(csrfCookie)
|
||||
if err == nil {
|
||||
parts := strings.Split(decoded, "|")
|
||||
if len(parts) > 0 {
|
||||
r.CSRFToken = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.CSRFToken == "" {
|
||||
return fmt.Errorf("无法获取 CSRF token")
|
||||
}
|
||||
|
||||
loginURL := fmt.Sprintf("https://chatgpt.com/auth/login?openaicom-did=%s", r.OAIDid)
|
||||
loginResp, err := r.Client.Get(loginURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAuthorizeURL 获取授权 URL
|
||||
func (r *ChatGPTReg) GetAuthorizeURL(email string) error {
|
||||
loginURL := fmt.Sprintf(
|
||||
"https://chatgpt.com/api/auth/signin/openai?prompt=login&ext-oai-did=%s&auth_session_logging_id=%s&screen_hint=login_or_signup&login_hint=%s",
|
||||
r.OAIDid,
|
||||
r.AuthSessionLoggingID,
|
||||
url.QueryEscape(email),
|
||||
)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("callbackUrl", "https://chatgpt.com/")
|
||||
data.Set("csrfToken", r.CSRFToken)
|
||||
data.Set("json", "true")
|
||||
|
||||
req, err := http.NewRequest("POST", loginURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Origin", "https://chatgpt.com")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := r.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authURL, ok := result["url"].(string); ok && strings.Contains(authURL, "auth.openai.com") {
|
||||
r.AuthorizeURL = authURL
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("无法获取授权 URL")
|
||||
}
|
||||
|
||||
// StartAuthorize 开始授权流程
|
||||
func (r *ChatGPTReg) StartAuthorize() error {
|
||||
resp, err := r.Client.Get(r.AuthorizeURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalURL := resp.Request.URL.String()
|
||||
if strings.Contains(finalURL, "create-account") || strings.Contains(finalURL, "log-in") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("授权流程启动失败")
|
||||
}
|
||||
|
||||
// Register 注册账户
|
||||
func (r *ChatGPTReg) Register(email, password string) error {
|
||||
payload := map[string]string{
|
||||
"password": password,
|
||||
"username": email,
|
||||
}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/user/register", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
|
||||
resp, err := r.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := client.ReadBodyString(resp)
|
||||
return fmt.Errorf("注册失败,状态码: %d, 响应: %s", resp.StatusCode, truncateStr(body, 200))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerificationEmail 发送验证邮件
|
||||
func (r *ChatGPTReg) SendVerificationEmail() error {
|
||||
resp, err := r.Client.Get("https://auth.openai.com/api/accounts/email-otp/send")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("发送验证邮件失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOTP 验证 OTP
|
||||
func (r *ChatGPTReg) ValidateOTP(code string) error {
|
||||
payload := map[string]string{"code": code}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/email-otp/validate", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
|
||||
resp, err := r.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("OTP 验证失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAccount 创建账户
|
||||
func (r *ChatGPTReg) CreateAccount(name, birthdate string) error {
|
||||
payload := map[string]string{
|
||||
"name": name,
|
||||
"birthdate": birthdate,
|
||||
}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://auth.openai.com/api/accounts/create_account", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://auth.openai.com")
|
||||
|
||||
resp, err := r.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("创建账户失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
|
||||
if continueURL, ok := result["continue_url"].(string); ok && continueURL != "" {
|
||||
contResp, err := r.Client.Get(continueURL)
|
||||
if err == nil {
|
||||
contResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionToken 获取 access token
|
||||
func (r *ChatGPTReg) GetSessionToken() error {
|
||||
resp, err := r.Client.Get("https://chatgpt.com/api/auth/session")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("获取 session 失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token, ok := result["accessToken"].(string); ok {
|
||||
r.AccessToken = token
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("响应中没有 accessToken")
|
||||
}
|
||||
|
||||
// Run 完整的注册流程
|
||||
func Run(email, password, name, birthdate, proxy string) (*ChatGPTReg, error) {
|
||||
return RunWithRetry(email, password, name, birthdate, proxy, 3)
|
||||
}
|
||||
|
||||
// RunWithRetry 带重试的注册流程
|
||||
// 当验证码获取超过5秒,就换新邮箱重新注册
|
||||
func RunWithRetry(email, password, name, birthdate, proxy string, maxRetries int) (*ChatGPTReg, error) {
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 重试时生成新邮箱
|
||||
email = mail.GenerateEmail()
|
||||
password = GeneratePassword()
|
||||
fmt.Printf(" [Retry %d] New email: %s\n", attempt, email)
|
||||
}
|
||||
|
||||
reg, err := runOnce(email, password, name, birthdate, proxy)
|
||||
if err == nil {
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// 如果不是验证码超时错误,直接返回
|
||||
if !strings.Contains(err.Error(), "验证码获取超时") {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf(" [!] OTP timeout, retrying with new email...\n")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("注册失败: 已重试 %d 次", maxRetries)
|
||||
}
|
||||
|
||||
// runOnce 执行一次注册流程(使用短超时获取验证码)
|
||||
func runOnce(email, password, name, birthdate, proxy string) (*ChatGPTReg, error) {
|
||||
reg, err := New(proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 初始化
|
||||
if err := reg.InitSession(); err != nil {
|
||||
return nil, fmt.Errorf("初始化失败: %v", err)
|
||||
}
|
||||
if err := reg.GetAuthorizeURL(email); err != nil {
|
||||
return nil, fmt.Errorf("获取授权URL失败: %v", err)
|
||||
}
|
||||
if err := reg.StartAuthorize(); err != nil {
|
||||
return nil, fmt.Errorf("启动授权失败: %v", err)
|
||||
}
|
||||
|
||||
// 注册
|
||||
if err := reg.Register(email, password); err != nil {
|
||||
return nil, fmt.Errorf("注册失败: %v", err)
|
||||
}
|
||||
if err := reg.SendVerificationEmail(); err != nil {
|
||||
return nil, fmt.Errorf("发送邮件失败: %v", err)
|
||||
}
|
||||
|
||||
// 先用5秒超时尝试获取验证码
|
||||
otpCode, err := mail.GetVerificationCode(email, 5*time.Second)
|
||||
if err != nil {
|
||||
// 5秒内没获取到,再等120秒(总共等待更多时间)
|
||||
otpCode, err = mail.GetVerificationCode(email, 120*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("验证码获取超时")
|
||||
}
|
||||
}
|
||||
|
||||
if err := reg.ValidateOTP(otpCode); err != nil {
|
||||
return nil, fmt.Errorf("OTP验证失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
if err := reg.CreateAccount(name, birthdate); err != nil {
|
||||
return nil, fmt.Errorf("创建账户失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取 Token
|
||||
_ = reg.GetSessionToken()
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
// GenerateName 生成随机姓名
|
||||
func GenerateName() string {
|
||||
firstNames := []string{"James", "John", "Robert", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Charles"}
|
||||
lastNames := []string{"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"}
|
||||
return firstNames[rand.Intn(len(firstNames))] + " " + lastNames[rand.Intn(len(lastNames))]
|
||||
}
|
||||
|
||||
// GenerateUUID 生成 UUID
|
||||
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:16])
|
||||
}
|
||||
|
||||
// GenerateBirthdate 生成随机生日
|
||||
func GenerateBirthdate() string {
|
||||
year := 2000 + rand.Intn(5)
|
||||
month := 1 + rand.Intn(12)
|
||||
day := 1 + rand.Intn(28)
|
||||
return fmt.Sprintf("%d-%02d-%02d", year, month, day)
|
||||
}
|
||||
|
||||
// GeneratePassword 生成随机密码
|
||||
func GeneratePassword() string {
|
||||
const (
|
||||
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
lower = "abcdefghijklmnopqrstuvwxyz"
|
||||
digits = "0123456789"
|
||||
special = "!@#$%"
|
||||
)
|
||||
|
||||
b := make([]byte, 13)
|
||||
for i := 0; i < 2; i++ {
|
||||
b[i] = upper[rand.Intn(len(upper))]
|
||||
}
|
||||
for i := 2; i < 10; i++ {
|
||||
b[i] = lower[rand.Intn(len(lower))]
|
||||
}
|
||||
for i := 10; i < 12; i++ {
|
||||
b[i] = digits[rand.Intn(len(digits))]
|
||||
}
|
||||
b[12] = special[rand.Intn(len(special))]
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
18
backend/internal/web/dev.go
Normal file
18
backend/internal/web/dev.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build !embed
|
||||
// +build !embed
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GetFileSystem 返回 nil(开发模式不嵌入前端)
|
||||
func GetFileSystem() http.FileSystem {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEmbedded 返回前端是否已嵌入
|
||||
func IsEmbedded() bool {
|
||||
return false
|
||||
}
|
||||
27
backend/internal/web/embed.go
Normal file
27
backend/internal/web/embed.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build embed
|
||||
// +build embed
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var distFS embed.FS
|
||||
|
||||
// GetFileSystem 返回嵌入的前端文件系统
|
||||
func GetFileSystem() http.FileSystem {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FS(sub)
|
||||
}
|
||||
|
||||
// IsEmbedded 返回前端是否已嵌入
|
||||
func IsEmbedded() bool {
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user