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:
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