diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 4976cc1..e0e5baf 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -85,6 +85,9 @@ func main() { // 启动错误账号定期清理服务(需在配置中启用) api.StartErrorCleanerService() + // 启动母号封禁检查服务(需在配置中启用) + api.StartBanCheckService() + // 启动服务器 startServer(cfg) } @@ -121,6 +124,11 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/db/owners/refetch-account-ids", api.CORS(api.HandleRefetchAccountIDs)) mux.HandleFunc("/api/upload/validate", api.CORS(api.HandleUploadValidate)) + // 母号封禁检查 API + mux.HandleFunc("/api/db/owners/ban-check", api.CORS(api.HandleManualBanCheck)) // 手动触发检查 + mux.HandleFunc("/api/db/owners/ban-check/status", api.CORS(api.HandleBanCheckStatus)) // 检查状态 + mux.HandleFunc("/api/db/owners/ban-check/settings", api.CORS(api.HandleBanCheckSettings)) // 配置 + // 注册测试 API mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest)) diff --git a/backend/internal/api/owner_ban_check.go b/backend/internal/api/owner_ban_check.go new file mode 100644 index 0000000..1ca94cc --- /dev/null +++ b/backend/internal/api/owner_ban_check.go @@ -0,0 +1,417 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "codex-pool/internal/config" + "codex-pool/internal/database" + "codex-pool/internal/invite" + "codex-pool/internal/logger" +) + +// 封禁检查服务状态 +var ( + banCheckRunning bool + banCheckStopChan chan struct{} + banCheckMu sync.Mutex + lastBanCheckTime time.Time + banCheckTaskState BanCheckTaskState +) + +// BanCheckTaskState 封禁检查任务状态 +type BanCheckTaskState struct { + Running bool `json:"running"` + StartedAt time.Time `json:"started_at"` + Total int32 `json:"total"` + Checked int32 `json:"checked"` + Banned int32 `json:"banned"` + Valid int32 `json:"valid"` + Failed int32 `json:"failed"` +} + +// BanCheckResult 单个检查结果 +type BanCheckResult struct { + Email string `json:"email"` + Status string `json:"status"` // valid, banned, error + Message string `json:"message,omitempty"` +} + +// StartBanCheckService 启动定期封禁检查服务 +func StartBanCheckService() { + banCheckMu.Lock() + if banCheckRunning { + banCheckMu.Unlock() + return + } + banCheckRunning = true + banCheckStopChan = make(chan struct{}) + banCheckMu.Unlock() + + logger.Info("母号封禁检查服务已启动", "", "ban-check") + + go func() { + // 默认检查间隔 30 分钟 + checkInterval := 1800 + + for { + // 动态读取检查间隔配置 + if database.Instance != nil { + if val, _ := database.Instance.GetConfig("ban_check_interval"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v >= 60 { + checkInterval = v + } + } + } + + select { + case <-banCheckStopChan: + logger.Info("母号封禁检查服务已停止", "", "ban-check") + return + case <-time.After(time.Duration(checkInterval) * time.Second): + runScheduledBanCheck() + } + } + }() +} + +// StopBanCheckService 停止定期封禁检查服务 +func StopBanCheckService() { + banCheckMu.Lock() + defer banCheckMu.Unlock() + + if banCheckRunning && banCheckStopChan != nil { + close(banCheckStopChan) + banCheckRunning = false + } +} + +// runScheduledBanCheck 执行定期封禁检查 +func runScheduledBanCheck() { + if database.Instance == nil { + return + } + + // 检查是否开启定期检查 + enabled := false + if val, _ := database.Instance.GetConfig("ban_check_enabled"); val == "true" { + enabled = true + } + if !enabled { + return + } + + // 检查是否有任务在运行 + if banCheckTaskState.Running || teamProcessState.Running { + logger.Info("有其他任务在运行,跳过定期封禁检查", "", "ban-check") + return + } + + // 获取检查间隔(小时) + checkIntervalHours := 24 + if val, _ := database.Instance.GetConfig("ban_check_hours"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v > 0 { + checkIntervalHours = v + } + } + + // 获取需要检查的母号 + owners, err := database.Instance.GetOwnersForBanCheck(checkIntervalHours) + if err != nil { + logger.Error(fmt.Sprintf("获取待检查母号失败: %v", err), "", "ban-check") + return + } + + if len(owners) == 0 { + logger.Info("没有需要检查的母号", "", "ban-check") + return + } + + logger.Info(fmt.Sprintf("定期封禁检查: 发现 %d 个需要检查的母号", len(owners)), "", "ban-check") + + // 执行检查 + go runBanCheckTask(owners, 2) +} + +// HandleBanCheckSettings 获取/设置封禁检查配置 +func HandleBanCheckSettings(w http.ResponseWriter, r *http.Request) { + if database.Instance == nil { + Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + switch r.Method { + case http.MethodGet: + settings := map[string]interface{}{ + "enabled": false, + "interval": 1800, // 检查服务间隔(秒) + "check_hours": 24, // 多少小时后重新检查 + "last_check": lastBanCheckTime, + "task_state": banCheckTaskState, + "service_running": banCheckRunning, + } + + if val, _ := database.Instance.GetConfig("ban_check_enabled"); val == "true" { + settings["enabled"] = true + } + if val, _ := database.Instance.GetConfig("ban_check_interval"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings["interval"] = v + } + } + if val, _ := database.Instance.GetConfig("ban_check_hours"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings["check_hours"] = v + } + } + + Success(w, settings) + + case http.MethodPut: + var req struct { + Enabled *bool `json:"enabled"` + Interval *int `json:"interval"` + CheckHours *int `json:"check_hours"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + Error(w, http.StatusBadRequest, "请求格式错误") + return + } + + if req.Enabled != nil { + database.Instance.SetConfig("ban_check_enabled", strconv.FormatBool(*req.Enabled)) + } + if req.Interval != nil && *req.Interval >= 60 { + database.Instance.SetConfig("ban_check_interval", strconv.Itoa(*req.Interval)) + } + if req.CheckHours != nil && *req.CheckHours > 0 { + database.Instance.SetConfig("ban_check_hours", strconv.Itoa(*req.CheckHours)) + } + + logger.Success("封禁检查配置已更新", "", "ban-check") + Success(w, map[string]string{"message": "配置已更新"}) + + default: + Error(w, http.StatusMethodNotAllowed, "不支持的方法") + } +} + +// HandleManualBanCheck 手动触发封禁检查 +func HandleManualBanCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "不支持的方法") + return + } + + if database.Instance == nil { + Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + // 检查是否有任务在运行 + if banCheckTaskState.Running { + Error(w, http.StatusConflict, "封禁检查任务正在运行中") + return + } + + if teamProcessState.Running { + Error(w, http.StatusConflict, "Team 处理任务正在运行中,请稍后再试") + return + } + + var req struct { + IDs []int64 `json:"ids"` // 指定检查的母号 ID,为空则检查所有有效母号 + Concurrency int `json:"concurrency"` // 并发数 + ForceCheck bool `json:"force_check"` // 是否强制检查(忽略上次检查时间) + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // 允许空 body + req.Concurrency = 2 + } + + if req.Concurrency <= 0 { + req.Concurrency = 2 + } + if req.Concurrency > 5 { + req.Concurrency = 5 + } + + var owners []database.TeamOwner + var err error + + if len(req.IDs) > 0 { + // 检查指定的母号 + for _, id := range req.IDs { + ownerList, _, err := database.Instance.GetTeamOwners("", 1000, 0) + if err != nil { + continue + } + for _, o := range ownerList { + if o.ID == id && o.Status == "valid" { + owners = append(owners, o) + break + } + } + } + } else if req.ForceCheck { + // 强制检查所有有效母号 + owners, err = database.Instance.GetPendingOwners() + } else { + // 检查需要检查的母号 + checkIntervalHours := 24 + if val, _ := database.Instance.GetConfig("ban_check_hours"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v > 0 { + checkIntervalHours = v + } + } + owners, err = database.Instance.GetOwnersForBanCheck(checkIntervalHours) + } + + if err != nil { + Error(w, http.StatusInternalServerError, fmt.Sprintf("获取母号失败: %v", err)) + return + } + + if len(owners) == 0 { + Success(w, map[string]interface{}{ + "message": "没有需要检查的母号", + "total": 0, + }) + return + } + + // 异步执行检查 + go runBanCheckTask(owners, req.Concurrency) + + Success(w, map[string]interface{}{ + "message": "封禁检查任务已启动", + "total": len(owners), + "concurrency": req.Concurrency, + }) +} + +// HandleBanCheckStatus 获取封禁检查任务状态 +func HandleBanCheckStatus(w http.ResponseWriter, r *http.Request) { + Success(w, banCheckTaskState) +} + +// runBanCheckTask 执行封禁检查任务 +func runBanCheckTask(owners []database.TeamOwner, concurrency int) { + banCheckTaskState = BanCheckTaskState{ + Running: true, + StartedAt: time.Now(), + Total: int32(len(owners)), + } + defer func() { + banCheckTaskState.Running = false + lastBanCheckTime = time.Now() + }() + + logger.Info(fmt.Sprintf("开始封禁检查: 共 %d 个母号, 并发数: %d", len(owners), concurrency), "", "ban-check") + + // 任务队列 + taskChan := make(chan database.TeamOwner, len(owners)) + var wg sync.WaitGroup + + // 获取代理配置 + proxy := "" + if config.Global != nil { + proxy = config.Global.GetProxy() + } + + // 启动 worker + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for owner := range taskChan { + result := checkSingleOwnerBan(owner, proxy) + + // 更新计数 + atomic.AddInt32(&banCheckTaskState.Checked, 1) + switch result.Status { + case "valid": + atomic.AddInt32(&banCheckTaskState.Valid, 1) + case "banned": + atomic.AddInt32(&banCheckTaskState.Banned, 1) + case "error": + atomic.AddInt32(&banCheckTaskState.Failed, 1) + } + } + }() + } + + // 发送任务 + for _, owner := range owners { + taskChan <- owner + } + close(taskChan) + + // 等待完成 + wg.Wait() + + logger.Success(fmt.Sprintf("封禁检查完成: 共 %d, 有效 %d, 封禁 %d, 失败 %d", + banCheckTaskState.Total, banCheckTaskState.Valid, banCheckTaskState.Banned, banCheckTaskState.Failed), "", "ban-check") +} + +// checkSingleOwnerBan 检查单个母号的封禁状态 +// 使用 accounts/check API 直接检测,不发送邀请 +func checkSingleOwnerBan(owner database.TeamOwner, proxy string) BanCheckResult { + result := BanCheckResult{ + Email: owner.Email, + Status: "error", + } + + // 创建检查器 + var checker *invite.TeamInviter + if proxy != "" { + checker = invite.NewWithProxy(owner.Token, proxy) + } else { + checker = invite.New(owner.Token) + } + + // 调用 accounts/check API 检测状态 + accountStatus := checker.CheckAccountStatus() + + // 更新最后检查时间 + database.Instance.UpdateOwnerLastCheckedAtByEmail(owner.Email) + + switch accountStatus.Status { + case "active": + // 账户正常 + logger.Info(fmt.Sprintf("母号有效: %s (plan: %s)", owner.Email, accountStatus.PlanType), owner.Email, "ban-check") + result.Status = "valid" + result.Message = fmt.Sprintf("母号状态正常 (plan: %s)", accountStatus.PlanType) + + // 如果获取到了 account_id 且数据库中没有,则更新 + if accountStatus.AccountID != "" && owner.AccountID == "" { + database.Instance.UpdateOwnerAccountID(owner.ID, accountStatus.AccountID) + } + + case "banned": + // 账户被封禁 + logger.Warning(fmt.Sprintf("母号被封禁: %s - %s", owner.Email, accountStatus.Error), owner.Email, "ban-check") + database.Instance.MarkOwnerAsInvalid(owner.Email) + result.Status = "banned" + result.Message = accountStatus.Error + + case "token_expired": + // Token 过期 + logger.Warning(fmt.Sprintf("母号 Token 过期: %s", owner.Email), owner.Email, "ban-check") + database.Instance.MarkOwnerAsInvalid(owner.Email) + result.Status = "banned" + result.Message = "Token 已过期" + + default: + // 其他错误 + logger.Error(fmt.Sprintf("检查失败: %s - %s", owner.Email, accountStatus.Error), owner.Email, "ban-check") + result.Message = accountStatus.Error + } + + return result +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f9309a8..7427ef8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -174,6 +174,9 @@ func InitFromDB() *Config { cfg.MailServices = services } } + if v, _ := configDB.GetConfig("site_name"); v != "" { + cfg.SiteName = v + } Global = cfg return cfg @@ -210,6 +213,10 @@ func SaveToDB() error { configDB.SetConfig("mail_services", string(data)) } + if cfg.SiteName != "" { + configDB.SetConfig("site_name", cfg.SiteName) + } + return nil } diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 26c320c..189ab44 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -10,13 +10,14 @@ import ( // 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"` + 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"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` } // DB 数据库管理器 @@ -40,10 +41,31 @@ func Init(dbPath string) error { return fmt.Errorf("创建表失败: %w", err) } + // 执行数据库迁移 + if err := Instance.migrate(); err != nil { + fmt.Printf("[数据库] 迁移警告: %v\n", err) + } + fmt.Printf("[数据库] SQLite 已连接: %s\n", dbPath) return nil } +// migrate 数据库迁移 +func (d *DB) migrate() error { + // 添加 last_checked_at 列(如果不存在) + _, err := d.db.Exec(`ALTER TABLE team_owners ADD COLUMN last_checked_at DATETIME`) + if err != nil && !isColumnExistsError(err) { + return err + } + return nil +} + +// isColumnExistsError 判断是否是列已存在的错误 +func isColumnExistsError(err error) bool { + return err != nil && (err.Error() == "duplicate column name: last_checked_at" || + err.Error() == "table team_owners already has a column named last_checked_at") +} + // createTables 创建表 func (d *DB) createTables() error { _, err := d.db.Exec(` @@ -54,9 +76,10 @@ func (d *DB) createTables() error { token TEXT, account_id TEXT NOT NULL, status TEXT DEFAULT 'valid', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_checked_at DATETIME ); - + 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); @@ -173,7 +196,7 @@ func (d *DB) AddTeamOwners(owners []TeamOwner) (int, error) { // 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" + query := "SELECT id, email, password, token, account_id, status, created_at, last_checked_at FROM team_owners WHERE 1=1" countQuery := "SELECT COUNT(*) FROM team_owners WHERE 1=1" args := []interface{}{} @@ -202,10 +225,14 @@ func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int, 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) + var lastCheckedAt sql.NullTime + err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt, &lastCheckedAt) if err != nil { continue } + if lastCheckedAt.Valid { + owner.LastCheckedAt = &lastCheckedAt.Time + } owners = append(owners, owner) } @@ -215,7 +242,7 @@ func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int, // GetPendingOwners 获取待处理(排除已使用和处理中的) func (d *DB) GetPendingOwners() ([]TeamOwner, error) { rows, err := d.db.Query(` - SELECT id, email, password, token, account_id, status, created_at + SELECT id, email, password, token, account_id, status, created_at, last_checked_at FROM team_owners WHERE status = 'valid' ORDER BY created_at ASC `) @@ -227,15 +254,62 @@ func (d *DB) GetPendingOwners() ([]TeamOwner, error) { 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) + var lastCheckedAt sql.NullTime + err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt, &lastCheckedAt) if err != nil { continue } + if lastCheckedAt.Valid { + owner.LastCheckedAt = &lastCheckedAt.Time + } owners = append(owners, owner) } return owners, nil } +// GetOwnersForBanCheck 获取需要检查封禁状态的母号 +func (d *DB) GetOwnersForBanCheck(checkIntervalHours int) ([]TeamOwner, error) { + // 获取 valid 状态且 超过检查间隔 或 从未检查过 的母号 + rows, err := d.db.Query(` + SELECT id, email, password, token, account_id, status, created_at, last_checked_at + FROM team_owners + WHERE status = 'valid' + AND (last_checked_at IS NULL OR last_checked_at < datetime('now', ?)) + ORDER BY last_checked_at ASC NULLS FIRST, created_at ASC + `, fmt.Sprintf("-%d hours", checkIntervalHours)) + if err != nil { + return nil, err + } + defer rows.Close() + + var owners []TeamOwner + for rows.Next() { + var owner TeamOwner + var lastCheckedAt sql.NullTime + err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt, &lastCheckedAt) + if err != nil { + continue + } + if lastCheckedAt.Valid { + owner.LastCheckedAt = &lastCheckedAt.Time + } + owners = append(owners, owner) + } + return owners, nil +} + +// UpdateOwnerLastCheckedAt 更新母号的最后检查时间 +func (d *DB) UpdateOwnerLastCheckedAt(id int64) error { + _, err := d.db.Exec("UPDATE team_owners SET last_checked_at = CURRENT_TIMESTAMP WHERE id = ?", id) + return err +} + +// UpdateOwnerLastCheckedAtByEmail 通过邮箱更新母号的最后检查时间 +func (d *DB) UpdateOwnerLastCheckedAtByEmail(email string) error { + _, err := d.db.Exec("UPDATE team_owners SET last_checked_at = CURRENT_TIMESTAMP WHERE email = ?", email) + return err +} + // MarkOwnerAsUsed 标记 Owner 为已使用 func (d *DB) MarkOwnerAsUsed(email string) error { _, err := d.db.Exec("UPDATE team_owners SET status = 'used' WHERE email = ?", email) @@ -290,7 +364,7 @@ func (d *DB) ClearUsedOwners() (int64, error) { // GetOwnersWithoutAccountID 获取缺少 account_id 的 owners func (d *DB) GetOwnersWithoutAccountID() ([]TeamOwner, error) { rows, err := d.db.Query(` - SELECT id, email, password, token, account_id, status, created_at + SELECT id, email, password, token, account_id, status, created_at, last_checked_at FROM team_owners WHERE account_id = '' OR account_id IS NULL ORDER BY created_at DESC `) @@ -302,10 +376,14 @@ func (d *DB) GetOwnersWithoutAccountID() ([]TeamOwner, error) { 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) + var lastCheckedAt sql.NullTime + err := rows.Scan(&owner.ID, &owner.Email, &owner.Password, &owner.Token, &owner.AccountID, &owner.Status, &owner.CreatedAt, &lastCheckedAt) if err != nil { continue } + if lastCheckedAt.Valid { + owner.LastCheckedAt = &lastCheckedAt.Time + } owners = append(owners, owner) } return owners, nil diff --git a/backend/internal/invite/team.go b/backend/internal/invite/team.go index b0d3877..4e00195 100644 --- a/backend/internal/invite/team.go +++ b/backend/internal/invite/team.go @@ -36,6 +36,14 @@ type AccountCheckResponse struct { } `json:"accounts"` } +// AccountStatus 账户状态检查结果 +type AccountStatus struct { + Status string // active, banned, token_expired, error + PlanType string // team, plus, free 等 + AccountID string // 账户 ID + Error string // 错误信息 +} + // New 创建邀请器 (使用默认代理) func New(accessToken string) *TeamInviter { c, _ := client.New(DefaultProxy) @@ -59,6 +67,77 @@ func (t *TeamInviter) SetAccountID(accountID string) { t.accountID = accountID } +// CheckAccountStatus 检查账户状态(封禁检测) +// 基于 check_ban.py 的逻辑: +// - HTTP 200 + 有账户数据 → active +// - HTTP 200 + 无账户数据 → banned +// - HTTP 401 → token_expired +// - HTTP 403 → banned +func (t *TeamInviter) CheckAccountStatus() AccountStatus { + result := AccountStatus{Status: "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") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + + resp, err := t.client.Do(req) + if err != nil { + result.Error = fmt.Sprintf("请求失败: %v", err) + return result + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case 200: + var checkResp AccountCheckResponse + if err := json.Unmarshal(body, &checkResp); err != nil { + result.Error = fmt.Sprintf("解析失败: %v", err) + return result + } + + // 检查是否有账户数据 + if len(checkResp.Accounts) == 0 { + result.Status = "banned" + result.Error = "无账户数据" + return result + } + + // 查找非 default 的账户 + for accountID, info := range checkResp.Accounts { + if accountID == "default" { + continue + } + result.Status = "active" + result.AccountID = accountID + result.PlanType = info.Account.PlanType + t.accountID = accountID + return result + } + + // 只有 default 账户,也视为正常 + result.Status = "active" + result.PlanType = "default" + return result + + case 401: + result.Status = "token_expired" + result.Error = "Token 已过期" + return result + + case 403: + result.Status = "banned" + result.Error = "账户被封禁 (403)" + return result + + default: + result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) + return result + } +} + // 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) diff --git a/frontend/src/components/upload/OwnerList.tsx b/frontend/src/components/upload/OwnerList.tsx index b8b1902..73c01ff 100644 --- a/frontend/src/components/upload/OwnerList.tsx +++ b/frontend/src/components/upload/OwnerList.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react' -import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight, Key, CheckSquare, Square } from 'lucide-react' -import { Card, CardHeader, CardTitle, CardContent, Button } from '../common' +import { useState, useEffect, useCallback } from 'react' +import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight, Key, CheckSquare, Square, ShieldCheck, Settings, Clock } from 'lucide-react' +import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../common' interface TeamOwner { id: number @@ -8,6 +8,25 @@ interface TeamOwner { account_id: string status: string created_at: string + last_checked_at?: string +} + +interface BanCheckTaskState { + running: boolean + started_at: string + total: number + checked: number + banned: number + valid: number + failed: number +} + +interface BanCheckSettings { + enabled: boolean + interval: number + check_hours: number + service_running: boolean + task_state: BanCheckTaskState } const statusColors: Record = { @@ -42,6 +61,12 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { const [deleting, setDeleting] = useState(false) const limit = 20 + // 封禁检查相关状态 + const [banCheckSettings, setBanCheckSettings] = useState(null) + const [showBanCheckSettings, setShowBanCheckSettings] = useState(false) + const [banCheckRunning, setBanCheckRunning] = useState(false) + const [checkHours, setCheckHours] = useState(24) + const loadOwners = async () => { setLoading(true) try { @@ -72,6 +97,99 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { setSelectedIds(new Set()) }, [page, filter]) + // 加载封禁检查配置 + const loadBanCheckSettings = useCallback(async () => { + try { + const res = await fetch('/api/db/owners/ban-check/settings') + const data = await res.json() + if (data.code === 0) { + setBanCheckSettings(data.data) + setCheckHours(data.data.check_hours || 24) + setBanCheckRunning(data.data.task_state?.running || false) + } + } catch (e) { + console.error('Failed to load ban check settings:', e) + } + }, []) + + useEffect(() => { + loadBanCheckSettings() + // 轮询检查状态 + const interval = setInterval(() => { + if (banCheckRunning) { + loadBanCheckSettings() + } + }, 2000) + return () => clearInterval(interval) + }, [loadBanCheckSettings, banCheckRunning]) + + // 手动触发封禁检查 + const handleBanCheck = async (forceCheck = false) => { + if (banCheckRunning) return + setBanCheckRunning(true) + try { + const body: { ids?: number[], force_check?: boolean } = {} + if (selectedIds.size > 0) { + body.ids = Array.from(selectedIds) + } + if (forceCheck) { + body.force_check = true + } + const res = await fetch('/api/db/owners/ban-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json() + if (data.code === 0) { + // 开始轮询状态 + const pollInterval = setInterval(async () => { + const statusRes = await fetch('/api/db/owners/ban-check/status') + const statusData = await statusRes.json() + if (statusData.code === 0) { + if (!statusData.data.running) { + clearInterval(pollInterval) + setBanCheckRunning(false) + loadOwners() + onStatsChange?.() + loadBanCheckSettings() + } + } + }, 2000) + } else { + alert(data.message || '启动检查失败') + setBanCheckRunning(false) + } + } catch (e) { + console.error('Failed to start ban check:', e) + alert('启动检查失败') + setBanCheckRunning(false) + } + } + + // 保存封禁检查配置 + const handleSaveBanCheckSettings = async (enabled: boolean) => { + try { + const res = await fetch('/api/db/owners/ban-check/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled, + check_hours: checkHours, + }), + }) + const data = await res.json() + if (data.code === 0) { + loadBanCheckSettings() + } else { + alert(data.message || '保存失败') + } + } catch (e) { + console.error('Failed to save ban check settings:', e) + alert('保存失败') + } + } + // 单个删除 const handleDelete = async (id: number) => { if (!confirm('确认删除此账号?')) return @@ -247,6 +365,29 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { > {refetching ? '获取中...' : '重新获取ID'} + + {selectedIds.size > 0 && ( + +
+ + + setCheckHours(parseInt(e.target.value) || 24)} + className="w-20 h-8 text-sm" + min={1} + max={168} + /> + 小时 + +
+
+ {banCheckSettings?.task_state?.running ? ( + + 检查中: {banCheckSettings.task_state.checked}/{banCheckSettings.task_state.total} + (有效: {banCheckSettings.task_state.valid}, 封禁: {banCheckSettings.task_state.banned}) + + ) : banCheckSettings?.enabled ? ( + 服务已启用 + ) : ( + 服务未启用 + )} +
+ + + )} +
@@ -298,19 +495,20 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { + {loading ? ( - ) : owners.length === 0 ? ( - @@ -336,6 +534,9 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) { +
Account ID 状态 创建时间上次检查 操作
+ 加载中...
+ 暂无数据
{formatTime(owner.created_at)} + {owner.last_checked_at ? formatTime(owner.last_checked_at) : '-'} +