Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

78
internal/db/crypto.go Normal file
View File

@@ -0,0 +1,78 @@
package db
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
var encryptionKey []byte
func SetEncryptionKey(hexKey string) error {
key, err := hex.DecodeString(hexKey)
if err != nil {
return fmt.Errorf("decode encryption key: %w", err)
}
if len(key) != 32 {
return fmt.Errorf("encryption key must be 32 bytes (64 hex chars), got %d bytes", len(key))
}
encryptionKey = key
return nil
}
func Encrypt(plaintext string) (string, error) {
if len(encryptionKey) == 0 {
return plaintext, nil
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aesGCM.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)
return hex.EncodeToString(ciphertext), nil
}
func Decrypt(ciphertextHex string) (string, error) {
if len(encryptionKey) == 0 {
return ciphertextHex, nil
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", err
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := aesGCM.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func HashSHA256(data string) string {
h := sha256.Sum256([]byte(data))
return hex.EncodeToString(h[:])
}

101
internal/db/crypto_test.go Normal file
View File

@@ -0,0 +1,101 @@
package db
import (
"testing"
)
func TestHashSHA256(t *testing.T) {
h1 := HashSHA256("hello")
h2 := HashSHA256("hello")
if h1 != h2 {
t.Fatal("same input should produce same hash")
}
if h1 == HashSHA256("world") {
t.Fatal("different input should produce different hash")
}
if len(h1) != 64 {
t.Fatalf("SHA-256 hex should be 64 chars, got %d", len(h1))
}
}
func TestEncryptDecryptWithoutKey(t *testing.T) {
old := encryptionKey
encryptionKey = nil
defer func() { encryptionKey = old }()
enc, err := Encrypt("plain")
if err != nil {
t.Fatalf("encrypt without key: %v", err)
}
if enc != "plain" {
t.Fatal("without key, Encrypt should return plaintext")
}
dec, err := Decrypt("plain")
if err != nil {
t.Fatalf("decrypt without key: %v", err)
}
if dec != "plain" {
t.Fatal("without key, Decrypt should return input as-is")
}
}
func TestEncryptDecryptWithKey(t *testing.T) {
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("set key: %v", err)
}
defer func() { encryptionKey = nil }()
original := "4242424242424242"
enc, err := Encrypt(original)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if enc == original {
t.Fatal("encrypted text should differ from plaintext")
}
dec, err := Decrypt(enc)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if dec != original {
t.Fatalf("decrypted = %q, want %q", dec, original)
}
}
func TestEncryptNondeterministic(t *testing.T) {
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("set key: %v", err)
}
defer func() { encryptionKey = nil }()
e1, _ := Encrypt("same")
e2, _ := Encrypt("same")
if e1 == e2 {
t.Fatal("GCM encryption should produce different ciphertext each time (random nonce)")
}
}
func TestSetEncryptionKeyInvalidLength(t *testing.T) {
if err := SetEncryptionKey("abcd"); err == nil {
t.Fatal("expected error for short key")
}
}
func TestSetEncryptionKeyInvalidHex(t *testing.T) {
if err := SetEncryptionKey("zzzz"); err == nil {
t.Fatal("expected error for non-hex key")
}
}
func TestDecryptInvalidCiphertext(t *testing.T) {
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("set key: %v", err)
}
defer func() { encryptionKey = nil }()
_, err := Decrypt("00")
if err == nil {
t.Fatal("expected error for short ciphertext")
}
}

54
internal/db/db.go Normal file
View File

@@ -0,0 +1,54 @@
package db
import (
"fmt"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Init(dbPath string) error {
dir := filepath.Dir(dbPath)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create db directory: %w", err)
}
}
db, err := gorm.Open(sqlite.Open(dbPath+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("get sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(
&SystemConfig{},
&EmailRecord{},
&Task{},
&TaskLog{},
&CardCode{},
&Card{},
&Account{},
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
DB = db
return nil
}
func GetDB() *gorm.DB {
return DB
}

123
internal/db/models.go Normal file
View File

@@ -0,0 +1,123 @@
package db
import "time"
type SystemConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"uniqueIndex;size:100" json:"key"`
Value string `gorm:"type:text" json:"value"`
Group string `gorm:"size:50;index" json:"group"`
Label string `gorm:"size:100" json:"label"`
Type string `gorm:"size:20" json:"type"` // string, int, bool, password, textarea
UpdatedAt time.Time `json:"updated_at"`
}
type EmailRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:200;uniqueIndex" json:"email"`
Status string `gorm:"size:20;index;default:in_use" json:"status"` // in_use, used, used_member, used_failed
UsedByAccountID *uint `gorm:"index" json:"used_by_account_id"`
UsedForRole string `gorm:"size:20" json:"used_for_role"` // owner, member
TaskID string `gorm:"size:36;index" json:"task_id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Task struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Type string `gorm:"size:20;index" json:"type"` // plus, team, both
TotalCount int `json:"total_count"`
DoneCount int `json:"done_count"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
Status string `gorm:"size:20;index" json:"status"` // pending, running, stopping, stopped, interrupted, completed
Config string `gorm:"type:text" json:"-"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
StartedAt *time.Time `json:"started_at"`
StoppedAt *time.Time `json:"stopped_at"`
}
type TaskLog struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID string `gorm:"size:36;index" json:"task_id"`
Index int `json:"index"`
Email string `gorm:"size:200" json:"email"`
Status string `gorm:"size:20" json:"status"` // step, success, failed, skipped
Plan string `gorm:"size:20" json:"plan"` // plus, team, free
Message string `gorm:"size:500" json:"message"`
Error string `gorm:"type:text" json:"error"`
Duration int `json:"duration"` // seconds
CreatedAt time.Time `json:"created_at"`
}
type CardCode struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:100;uniqueIndex" json:"code"`
Status string `gorm:"size:20;index;default:unused" json:"status"` // unused, redeeming, redeemed, failed
CardID *uint `gorm:"index" json:"card_id"`
Error string `gorm:"size:200" json:"error"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
}
type Card struct {
ID uint `gorm:"primaryKey" json:"id"`
NumberHash string `gorm:"size:64;uniqueIndex" json:"-"`
NumberEnc string `gorm:"type:text" json:"-"`
CVCEnc string `gorm:"type:text" json:"-"`
ExpMonth string `gorm:"size:2" json:"exp_month"`
ExpYear string `gorm:"size:4" json:"exp_year"`
Name string `gorm:"size:100" json:"name"`
Country string `gorm:"size:10" json:"country"`
Address string `gorm:"size:200" json:"address"`
City string `gorm:"size:100" json:"city"`
State string `gorm:"size:100" json:"state"`
PostalCode string `gorm:"size:20" json:"postal_code"`
Source string `gorm:"size:20;index" json:"source"` // api, manual
CardCodeID *uint `gorm:"index" json:"card_code_id"`
Status string `gorm:"size:20;index;default:available" json:"status"` // available, active, exhausted, rejected, disabled, expired
BindCount int `gorm:"default:0" json:"bind_count"`
MaxBinds int `gorm:"default:1" json:"max_binds"`
ActivatedAt *time.Time `json:"activated_at"`
LastUsedAt *time.Time `json:"last_used_at"`
LastError string `gorm:"size:200" json:"last_error"`
BoundAccounts string `gorm:"type:text" json:"bound_accounts"` // JSON array
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Transient fields for API response (not stored in DB)
NumberLast4 string `gorm:"-" json:"number_last4,omitempty"`
CVCPlain string `gorm:"-" json:"cvc_plain,omitempty"`
}
type Account struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID string `gorm:"size:36;index" json:"task_id"`
Email string `gorm:"size:200;uniqueIndex" json:"email"`
Password string `gorm:"size:100" json:"-"`
Plan string `gorm:"size:20;index" json:"plan"` // plus, team_owner, team_member
ParentID *uint `gorm:"index" json:"parent_id"`
Parent *Account `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
SubAccounts []Account `gorm:"foreignKey:ParentID" json:"sub_accounts,omitempty"`
AccessToken string `gorm:"type:text" json:"-"`
RefreshToken string `gorm:"type:text" json:"-"`
IDToken string `gorm:"type:text" json:"-"`
AccountID string `gorm:"size:100" json:"account_id"`
DeviceID string `gorm:"size:100" json:"-"`
UserID string `gorm:"size:100" json:"user_id"`
TeamWorkspaceID string `gorm:"size:100" json:"team_workspace_id"`
WorkspaceToken string `gorm:"type:text" json:"-"`
Status string `gorm:"size:20;index;default:active" json:"status"` // active, free, plus, team, banned, unknown
StatusCheckedAt *time.Time `json:"status_checked_at"`
Note string `gorm:"type:text" json:"note"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

144
internal/db/query.go Normal file
View File

@@ -0,0 +1,144 @@
package db
import (
"time"
"gorm.io/gorm"
)
type PaginationParams struct {
Page int
Size int
}
func (p *PaginationParams) Normalize() {
if p.Page < 1 {
p.Page = 1
}
if p.Size < 1 || p.Size > 100 {
p.Size = 20
}
}
func (p *PaginationParams) Offset() int {
return (p.Page - 1) * p.Size
}
type PaginatedResult struct {
Items interface{} `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func Paginate(db *gorm.DB, p PaginationParams, dest interface{}) (*PaginatedResult, error) {
p.Normalize()
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
if err := db.Offset(p.Offset()).Limit(p.Size).Find(dest).Error; err != nil {
return nil, err
}
return &PaginatedResult{
Items: dest,
Total: total,
Page: p.Page,
Size: p.Size,
}, nil
}
// Dashboard stats
type DashboardStats struct {
TotalAccounts int64 `json:"total_accounts"`
PlusCount int64 `json:"plus_count"`
TeamCount int64 `json:"team_count"`
TodayRegistrations int64 `json:"today_registrations"`
SuccessRate float64 `json:"success_rate"`
ActiveTask *Task `json:"active_task"`
RecentTasks []Task `json:"recent_tasks"`
}
func GetDashboardStats(db *gorm.DB) (*DashboardStats, error) {
stats := &DashboardStats{}
db.Model(&Account{}).Count(&stats.TotalAccounts)
db.Model(&Account{}).Where("plan = ?", "plus").Count(&stats.PlusCount)
db.Model(&Account{}).Where("plan IN ?", []string{"team_owner", "team_member"}).Count(&stats.TeamCount)
today := time.Now().Truncate(24 * time.Hour)
db.Model(&Account{}).Where("created_at >= ?", today).Count(&stats.TodayRegistrations)
var totalLogs, successLogs int64
db.Model(&TaskLog{}).Count(&totalLogs)
db.Model(&TaskLog{}).Where("status = ?", "success").Count(&successLogs)
if totalLogs > 0 {
stats.SuccessRate = float64(successLogs) / float64(totalLogs) * 100
}
var activeTask Task
if err := db.Where("status IN ?", []string{"running", "stopping"}).First(&activeTask).Error; err == nil {
stats.ActiveTask = &activeTask
}
db.Order("created_at DESC").Limit(5).Find(&stats.RecentTasks)
return stats, nil
}
// Card stats
type CardStats struct {
Total int64 `json:"total"`
Available int64 `json:"available"`
Active int64 `json:"active"`
Exhausted int64 `json:"exhausted"`
Rejected int64 `json:"rejected"`
Expired int64 `json:"expired"`
Disabled int64 `json:"disabled"`
}
func GetCardStats(db *gorm.DB) *CardStats {
s := &CardStats{}
db.Model(&Card{}).Count(&s.Total)
db.Model(&Card{}).Where("status = ?", "available").Count(&s.Available)
db.Model(&Card{}).Where("status = ?", "active").Count(&s.Active)
db.Model(&Card{}).Where("status = ?", "exhausted").Count(&s.Exhausted)
db.Model(&Card{}).Where("status = ?", "rejected").Count(&s.Rejected)
db.Model(&Card{}).Where("status = ?", "expired").Count(&s.Expired)
db.Model(&Card{}).Where("status = ?", "disabled").Count(&s.Disabled)
return s
}
type CardCodeStats struct {
Total int64 `json:"total"`
Unused int64 `json:"unused"`
Redeemed int64 `json:"redeemed"`
Failed int64 `json:"failed"`
}
func GetCardCodeStats(db *gorm.DB) *CardCodeStats {
s := &CardCodeStats{}
db.Model(&CardCode{}).Count(&s.Total)
db.Model(&CardCode{}).Where("status = ?", "unused").Count(&s.Unused)
db.Model(&CardCode{}).Where("status = ?", "redeemed").Count(&s.Redeemed)
db.Model(&CardCode{}).Where("status = ?", "failed").Count(&s.Failed)
return s
}
type EmailRecordStats struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
UsedMember int64 `json:"used_member"`
UsedFailed int64 `json:"used_failed"`
}
func GetEmailRecordStats(db *gorm.DB) *EmailRecordStats {
s := &EmailRecordStats{}
db.Model(&EmailRecord{}).Count(&s.Total)
db.Model(&EmailRecord{}).Where("status = ?", "used").Count(&s.Used)
db.Model(&EmailRecord{}).Where("status = ?", "used_member").Count(&s.UsedMember)
db.Model(&EmailRecord{}).Where("status = ?", "used_failed").Count(&s.UsedFailed)
return s
}

128
internal/db/query_test.go Normal file
View File

@@ -0,0 +1,128 @@
package db
import (
"testing"
)
func TestPaginationNormalize(t *testing.T) {
tests := []struct {
in PaginationParams
wantPage int
wantSize int
}{
{PaginationParams{0, 0}, 1, 20},
{PaginationParams{-1, -5}, 1, 20},
{PaginationParams{1, 200}, 1, 20},
{PaginationParams{3, 10}, 3, 10},
}
for _, tt := range tests {
tt.in.Normalize()
if tt.in.Page != tt.wantPage || tt.in.Size != tt.wantSize {
t.Errorf("Normalize(%+v) = page=%d size=%d, want page=%d size=%d",
tt.in, tt.in.Page, tt.in.Size, tt.wantPage, tt.wantSize)
}
}
}
func TestPaginationOffset(t *testing.T) {
p := PaginationParams{Page: 3, Size: 10}
if p.Offset() != 20 {
t.Fatalf("Offset() = %d, want 20", p.Offset())
}
}
func TestPaginate(t *testing.T) {
d := setupTestDB(t)
for i := 0; i < 25; i++ {
d.Create(&EmailRecord{Email: "test" + string(rune('A'+i)) + "@x.com", Status: "used"})
}
var records []EmailRecord
result, err := Paginate(d.Model(&EmailRecord{}), PaginationParams{Page: 2, Size: 10}, &records)
if err != nil {
t.Fatalf("Paginate: %v", err)
}
if result.Total != 25 {
t.Fatalf("Total = %d, want 25", result.Total)
}
if result.Page != 2 || result.Size != 10 {
t.Fatalf("Page=%d Size=%d, want 2/10", result.Page, result.Size)
}
if len(records) != 10 {
t.Fatalf("got %d records, want 10", len(records))
}
}
func TestGetCardStats(t *testing.T) {
d := setupTestDB(t)
d.Create(&Card{NumberHash: "h1", Status: "available", MaxBinds: 1})
d.Create(&Card{NumberHash: "h2", Status: "active", MaxBinds: 1})
d.Create(&Card{NumberHash: "h3", Status: "exhausted", MaxBinds: 1})
d.Create(&Card{NumberHash: "h4", Status: "rejected", MaxBinds: 1})
d.Create(&Card{NumberHash: "h5", Status: "disabled", MaxBinds: 1})
s := GetCardStats(d)
if s.Total != 5 {
t.Fatalf("Total = %d, want 5", s.Total)
}
if s.Available != 1 || s.Active != 1 || s.Exhausted != 1 || s.Rejected != 1 || s.Disabled != 1 {
t.Fatalf("stats mismatch: %+v", s)
}
}
func TestGetCardCodeStats(t *testing.T) {
d := setupTestDB(t)
d.Create(&CardCode{Code: "c1", Status: "unused"})
d.Create(&CardCode{Code: "c2", Status: "unused"})
d.Create(&CardCode{Code: "c3", Status: "redeemed"})
d.Create(&CardCode{Code: "c4", Status: "failed"})
s := GetCardCodeStats(d)
if s.Total != 4 || s.Unused != 2 || s.Redeemed != 1 || s.Failed != 1 {
t.Fatalf("stats mismatch: %+v", s)
}
}
func TestGetEmailRecordStats(t *testing.T) {
d := setupTestDB(t)
d.Create(&EmailRecord{Email: "a@x.com", Status: "used"})
d.Create(&EmailRecord{Email: "b@x.com", Status: "used_member"})
d.Create(&EmailRecord{Email: "c@x.com", Status: "used_failed"})
d.Create(&EmailRecord{Email: "d@x.com", Status: "in_use"})
s := GetEmailRecordStats(d)
if s.Total != 4 || s.Used != 1 || s.UsedMember != 1 || s.UsedFailed != 1 {
t.Fatalf("stats mismatch: %+v", s)
}
}
func TestGetDashboardStats(t *testing.T) {
d := setupTestDB(t)
d.Create(&Account{Email: "p1@x.com", Plan: "plus", Status: "active"})
d.Create(&Account{Email: "p2@x.com", Plan: "plus", Status: "active"})
d.Create(&Account{Email: "t1@x.com", Plan: "team_owner", Status: "active"})
d.Create(&Account{Email: "m1@x.com", Plan: "team_member", Status: "active"})
d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p1@x.com"})
d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p2@x.com"})
d.Create(&TaskLog{TaskID: "t1", Status: "failed", Email: "f@x.com"})
stats, err := GetDashboardStats(d)
if err != nil {
t.Fatalf("GetDashboardStats: %v", err)
}
if stats.TotalAccounts != 4 {
t.Fatalf("TotalAccounts = %d, want 4", stats.TotalAccounts)
}
if stats.PlusCount != 2 {
t.Fatalf("PlusCount = %d, want 2", stats.PlusCount)
}
if stats.TeamCount != 2 {
t.Fatalf("TeamCount = %d, want 2", stats.TeamCount)
}
// success rate: 2/3 * 100 = 66.66...
if stats.SuccessRate < 66 || stats.SuccessRate > 67 {
t.Fatalf("SuccessRate = %.2f, want ~66.67", stats.SuccessRate)
}
}

View File

@@ -0,0 +1,28 @@
package db
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupTestDB creates an in-memory SQLite database for testing.
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := d.AutoMigrate(
&SystemConfig{}, &EmailRecord{}, &Task{}, &TaskLog{},
&CardCode{}, &Card{}, &Account{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
DB = d
return d
}

171
internal/handler/account.go Normal file
View File

@@ -0,0 +1,171 @@
package handler
import (
"fmt"
"net/http"
"gpt-plus/internal/db"
"gpt-plus/internal/service"
"github.com/gin-gonic/gin"
)
func RegisterAccountRoutes(api *gin.RouterGroup) {
api.GET("/accounts", ListAccounts)
api.GET("/accounts/:id", GetAccount)
api.POST("/accounts/check", CheckAccounts)
api.POST("/accounts/:id/check", CheckSingleAccount)
api.POST("/accounts/test-model", TestModel)
api.PUT("/accounts/:id/note", UpdateAccountNote)
api.POST("/accounts/export", ExportAccounts)
api.POST("/accounts/:id/transfer-to-cpa", TransferToCPA)
}
func ListAccounts(c *gin.Context) {
d := db.GetDB()
query := d.Model(&db.Account{}).Order("created_at DESC")
plan := c.Query("plan")
if plan != "" && plan != "all" {
query = query.Where("plan = ?", plan)
}
// "all" or empty: show everything
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
if search := c.Query("search"); search != "" {
query = query.Where("email LIKE ?", "%"+search+"%")
}
p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)}
var accounts []db.Account
result, err := db.Paginate(query, p, &accounts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range accounts {
if accounts[i].Plan == "team_owner" {
d.Where("parent_id = ?", accounts[i].ID).Find(&accounts[i].SubAccounts)
}
if accounts[i].Plan == "team_member" && accounts[i].ParentID != nil {
var parent db.Account
if d.First(&parent, *accounts[i].ParentID).Error == nil {
accounts[i].Parent = &parent
}
}
}
result.Items = accounts
c.JSON(http.StatusOK, result)
}
func GetAccount(c *gin.Context) {
var account db.Account
d := db.GetDB()
if err := d.First(&account, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在"})
return
}
if account.Plan == "team_owner" {
d.Where("parent_id = ?", account.ID).Find(&account.SubAccounts)
}
c.JSON(http.StatusOK, account)
}
func CheckAccounts(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
results := service.CheckAccountStatuses(db.GetDB(), req.IDs)
c.JSON(http.StatusOK, gin.H{"message": "状态检查完成", "results": results})
}
func CheckSingleAccount(c *gin.Context) {
id := c.Param("id")
var uid uint
if _, err := fmt.Sscanf(id, "%d", &uid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
results := service.CheckAccountStatuses(db.GetDB(), []uint{uid})
if len(results) > 0 {
c.JSON(http.StatusOK, results[0])
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在"})
}
}
func TestModel(c *gin.Context) {
var req struct {
ID uint `json:"id" binding:"required"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result := service.TestModelAvailability(db.GetDB(), req.ID, req.Model)
c.JSON(http.StatusOK, result)
}
func UpdateAccountNote(c *gin.Context) {
var req struct {
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.GetDB().Model(&db.Account{}).Where("id = ?", c.Param("id")).Update("note", req.Note).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "备注已更新"})
}
func TransferToCPA(c *gin.Context) {
id := c.Param("id")
var uid uint
if _, err := fmt.Sscanf(id, "%d", &uid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
result := service.TransferAccountToCPA(db.GetDB(), uid)
if result.OK {
c.JSON(http.StatusOK, result)
} else {
c.JSON(http.StatusInternalServerError, result)
}
}
func ExportAccounts(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
data, filename, err := service.ExportAccounts(db.GetDB(), req.IDs, req.Note)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
if len(req.IDs) > 1 {
c.Data(http.StatusOK, "application/zip", data)
} else {
c.Data(http.StatusOK, "application/json", data)
}
}

61
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,61 @@
package handler
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
var adminPasswordHash []byte
func InitAdminPassword() error {
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
password = "admin"
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
adminPasswordHash = hash
return nil
}
type loginRequest struct {
Password string `json:"password" binding:"required"`
}
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码不能为空"})
return
}
if err := bcrypt.CompareHashAndPassword(adminPasswordHash, []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
return
}
token, err := GenerateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
return
}
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie("token", token, 86400, "/", "", IsSecure(), true)
c.JSON(http.StatusOK, gin.H{"message": "登录成功"})
}
func Logout(c *gin.Context) {
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie("token", "", -1, "/", "", IsSecure(), true)
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
}
func CheckAuth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"authenticated": true})
}

324
internal/handler/card.go Normal file
View File

@@ -0,0 +1,324 @@
package handler
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"gpt-plus/internal/db"
"gpt-plus/pkg/provider/card"
"github.com/gin-gonic/gin"
)
func RegisterCardRoutes(api *gin.RouterGroup) {
// Cards
api.GET("/cards", ListCards)
api.GET("/cards/active", GetActiveCard)
api.GET("/cards/stats", GetCardStats)
api.POST("/cards", AddCard)
api.PUT("/cards/:id/activate", ActivateCard)
api.PUT("/cards/:id/status", UpdateCardStatus)
api.DELETE("/cards/:id", DeleteCard)
// Card codes
api.GET("/card-codes", ListCardCodes)
api.GET("/card-codes/stats", GetCardCodeStats)
api.POST("/card-codes/import", ImportCardCodes)
api.POST("/card-codes/redeem", RedeemCardCode)
api.DELETE("/card-codes/:id", DeleteCardCode)
}
func ListCards(c *gin.Context) {
d := db.GetDB()
query := d.Model(&db.Card{}).Order("created_at DESC")
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
p := db.PaginationParams{
Page: intQuery(c, "page", 1),
Size: intQuery(c, "size", 20),
}
var cards []db.Card
result, err := db.Paginate(query, p, &cards)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range cards {
number, _ := db.Decrypt(cards[i].NumberEnc)
cards[i].NumberLast4 = number
cvc, _ := db.Decrypt(cards[i].CVCEnc)
cards[i].CVCPlain = cvc
}
result.Items = cards
c.JSON(http.StatusOK, result)
}
func GetActiveCard(c *gin.Context) {
d := db.GetDB()
var card db.Card
if err := d.Where("status = ?", "active").First(&card).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "无激活的卡片"})
return
}
number, _ := db.Decrypt(card.NumberEnc)
card.NumberLast4 = number
cvc, _ := db.Decrypt(card.CVCEnc)
card.CVCPlain = cvc
c.JSON(http.StatusOK, card)
}
func GetCardStats(c *gin.Context) {
c.JSON(http.StatusOK, db.GetCardStats(db.GetDB()))
}
type addCardRequest struct {
Number string `json:"number" binding:"required"`
CVC string `json:"cvc" binding:"required"`
ExpMonth string `json:"exp_month" binding:"required"`
ExpYear string `json:"exp_year" binding:"required"`
Name string `json:"name"`
Country string `json:"country"`
}
func AddCard(c *gin.Context) {
var req addCardRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
numberEnc, _ := db.Encrypt(req.Number)
cvcEnc, _ := db.Encrypt(req.CVC)
maxBinds := 0 // 0 = unlimited
var cfg db.SystemConfig
if db.GetDB().Where("key = ?", "card.max_binds").First(&cfg).Error == nil {
maxBinds, _ = strconv.Atoi(cfg.Value)
}
card := &db.Card{
NumberHash: db.HashSHA256(req.Number),
NumberEnc: numberEnc,
CVCEnc: cvcEnc,
ExpMonth: req.ExpMonth,
ExpYear: req.ExpYear,
Name: req.Name,
Country: req.Country,
Source: "manual",
Status: "available",
MaxBinds: maxBinds,
}
if err := db.GetDB().Create(card).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "该卡号已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": card.ID, "message": "卡片已添加"})
}
func ActivateCard(c *gin.Context) {
id := c.Param("id")
d := db.GetDB()
// Deactivate current active card
d.Model(&db.Card{}).Where("status = ?", "active").Update("status", "available")
var card db.Card
if err := d.First(&card, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "卡片不存在"})
return
}
card.Status = "active"
d.Save(&card)
c.JSON(http.StatusOK, gin.H{"message": "已激活"})
}
func UpdateCardStatus(c *gin.Context) {
id := c.Param("id")
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Status != "disabled" && req.Status != "available" {
c.JSON(http.StatusBadRequest, gin.H{"error": "状态只能为 disabled 或 available"})
return
}
if err := db.GetDB().Model(&db.Card{}).Where("id = ?", id).Update("status", req.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "状态已更新"})
}
func DeleteCard(c *gin.Context) {
if err := db.GetDB().Delete(&db.Card{}, c.Param("id")).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// Card Codes
func ListCardCodes(c *gin.Context) {
d := db.GetDB()
query := d.Model(&db.CardCode{}).Order("created_at DESC")
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)}
var codes []db.CardCode
result, err := db.Paginate(query, p, &codes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func GetCardCodeStats(c *gin.Context) {
c.JSON(http.StatusOK, db.GetCardCodeStats(db.GetDB()))
}
func ImportCardCodes(c *gin.Context) {
var req struct {
Codes []string `json:"codes" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
d := db.GetDB()
var count int
for _, code := range req.Codes {
code = strings.TrimSpace(code)
if code == "" {
continue
}
cc := &db.CardCode{Code: code, Status: "unused"}
if d.Create(cc).Error == nil {
count++
}
}
c.JSON(http.StatusOK, gin.H{"imported": count})
}
func RedeemCardCode(c *gin.Context) {
var req struct {
ID uint `json:"id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
d := db.GetDB()
var code db.CardCode
if err := d.First(&code, req.ID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "卡密不存在"})
return
}
if code.Status != "unused" {
c.JSON(http.StatusBadRequest, gin.H{"error": "该卡密状态不是 unused"})
return
}
code.Status = "redeeming"
d.Save(&code)
// Redeem in background
go func() {
var cfgBase db.SystemConfig
apiBase := ""
if db.GetDB().Where("key = ?", "card.api_base_url").First(&cfgBase).Error == nil {
apiBase = cfgBase.Value
}
if apiBase == "" {
code.Status = "failed"
code.Error = "开卡 API 地址未配置"
d.Save(&code)
return
}
apiProv, err := card.NewAPIProvider(card.APIProviderConfig{
BaseURL: apiBase,
Codes: []string{code.Code},
PoolCfg: card.PoolConfig{MultiBind: true, MaxBinds: 999},
})
if err != nil {
code.Status = "failed"
code.Error = err.Error()
d.Save(&code)
return
}
cardInfo, err := apiProv.GetCard(context.Background())
if err != nil {
code.Status = "failed"
code.Error = err.Error()
d.Save(&code)
return
}
numberEnc, _ := db.Encrypt(cardInfo.Number)
cvcEnc, _ := db.Encrypt(cardInfo.CVC)
newCard := &db.Card{
NumberHash: db.HashSHA256(cardInfo.Number),
NumberEnc: numberEnc,
CVCEnc: cvcEnc,
ExpMonth: cardInfo.ExpMonth,
ExpYear: cardInfo.ExpYear,
Name: cardInfo.Name,
Country: cardInfo.Country,
Source: "api",
CardCodeID: &code.ID,
Status: "available",
MaxBinds: 1,
}
d.Create(newCard)
now := time.Now()
code.Status = "redeemed"
code.CardID = &newCard.ID
code.RedeemedAt = &now
d.Save(&code)
}()
c.JSON(http.StatusOK, gin.H{"message": "兑换已开始"})
}
func DeleteCardCode(c *gin.Context) {
if err := db.GetDB().Delete(&db.CardCode{}, c.Param("id")).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
func intQuery(c *gin.Context, key string, def int) int {
v := c.Query(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}

154
internal/handler/config.go Normal file
View File

@@ -0,0 +1,154 @@
package handler
import (
"net/http"
"gpt-plus/internal/db"
"github.com/gin-gonic/gin"
)
type configGroup struct {
Group string `json:"group"`
Items []db.SystemConfig `json:"items"`
}
func GetConfig(c *gin.Context) {
var configs []db.SystemConfig
if err := db.GetDB().Order("\"group\", key").Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Mask password fields
for i := range configs {
if configs[i].Type == "password" && configs[i].Value != "" {
configs[i].Value = "••••••••"
}
}
groups := make(map[string][]db.SystemConfig)
for _, cfg := range configs {
groups[cfg.Group] = append(groups[cfg.Group], cfg)
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
type updateConfigRequest struct {
Items []struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
} `json:"items" binding:"required"`
}
func UpdateConfig(c *gin.Context) {
var req updateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tx := db.GetDB().Begin()
for _, item := range req.Items {
// Skip masked password values
if item.Value == "••••••••" {
continue
}
var existing db.SystemConfig
if err := tx.Where("key = ?", item.Key).First(&existing).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusNotFound, gin.H{"error": "配置项不存在: " + item.Key})
return
}
value := item.Value
if existing.Type == "password" && value != "" {
encrypted, err := db.Encrypt(value)
if err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
value = encrypted
}
if err := tx.Model(&existing).Update("value", value).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
tx.Commit()
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
}
func SeedDefaultConfigs() {
defaults := []db.SystemConfig{
// Proxy group
{Key: "proxy.mode", Value: "b2proxy", Group: "proxy", Label: "代理模式", Type: "string"},
{Key: "proxy.url", Value: "", Group: "proxy", Label: "代理地址", Type: "string"},
{Key: "proxy.b2proxy.enabled", Value: "true", Group: "proxy", Label: "启用 B2Proxy", Type: "bool"},
{Key: "proxy.b2proxy.api_base", Value: "http://global.rrp.b2proxy.com:8089", Group: "proxy", Label: "B2Proxy API 地址", Type: "string"},
{Key: "proxy.b2proxy.zone", Value: "custom", Group: "proxy", Label: "B2Proxy 区域", Type: "string"},
{Key: "proxy.b2proxy.proto", Value: "socks5", Group: "proxy", Label: "B2Proxy 协议", Type: "string"},
{Key: "proxy.b2proxy.sess_time", Value: "5", Group: "proxy", Label: "会话时长(分钟)", Type: "int"},
// Email group
{Key: "email.gateway.base_url", Value: "https://regmail.zhengmi.org", Group: "email", Label: "邮箱网关地址", Type: "string"},
{Key: "email.gateway.api_key", Value: "Admin2026.", Group: "email", Label: "邮箱网关 API Key", Type: "password"},
{Key: "email.gateway.provider", Value: "mail", Group: "email", Label: "邮箱网关 Provider", Type: "string"},
// Card group
{Key: "card.max_binds", Value: "0", Group: "card", Label: "单卡最大绑定次数(0=无限)", Type: "int"},
{Key: "card.default_name", Value: "Anna Hoover", Group: "card", Label: "默认持卡人姓名", Type: "string"},
{Key: "card.default_country", Value: "US", Group: "card", Label: "默认国家", Type: "string"},
{Key: "card.default_currency", Value: "USD", Group: "card", Label: "默认货币", Type: "string"},
{Key: "card.default_address", Value: "1208 Oakdale Street", Group: "card", Label: "默认地址", Type: "string"},
{Key: "card.default_city", Value: "Jonesboro", Group: "card", Label: "默认城市", Type: "string"},
{Key: "card.default_state", Value: "AR", Group: "card", Label: "默认州/省", Type: "string"},
{Key: "card.default_postal_code", Value: "72401", Group: "card", Label: "默认邮编", Type: "string"},
{Key: "card.api_base_url", Value: "https://yyl.ncet.top", Group: "card", Label: "开卡 API 地址", Type: "string"},
{Key: "card.api_key", Value: "", Group: "card", Label: "开卡 API 密钥", Type: "password"},
// Stripe group
{Key: "stripe.build_hash", Value: "ede17ac9fd", Group: "stripe", Label: "Build Hash", Type: "string"},
{Key: "stripe.tag_version", Value: "4.5.43", Group: "stripe", Label: "Tag Version", Type: "string"},
{Key: "stripe.fingerprint_dir", Value: "./fingerprints", Group: "stripe", Label: "指纹目录", Type: "string"},
// Captcha group
{Key: "captcha.provider", Value: "hcaptchasolver", Group: "captcha", Label: "验证码提供商", Type: "string"},
{Key: "captcha.api_key", Value: "Kenzx_4ba2535aaf33bd1238345f795a085853ace94e54ade70a2a", Group: "captcha", Label: "验证码 API Key", Type: "password"},
{Key: "captcha.proxy", Value: "", Group: "captcha", Label: "验证码代理", Type: "string"},
// Account group
{Key: "account.password_length", Value: "16", Group: "account", Label: "密码长度", Type: "int"},
{Key: "account.locale", Value: "en-GB", Group: "account", Label: "区域设置", Type: "string"},
// Team group
{Key: "team.enabled", Value: "true", Group: "team", Label: "启用 Team", Type: "bool"},
{Key: "team.workspace_prefix", Value: "Team", Group: "team", Label: "工作区前缀", Type: "string"},
{Key: "team.seat_quantity", Value: "5", Group: "team", Label: "座位数", Type: "int"},
{Key: "team.coupon", Value: "team-1-month-free", Group: "team", Label: "优惠券", Type: "string"},
{Key: "team.invite_count", Value: "0", Group: "team", Label: "邀请人数", Type: "int"},
// CPA (CLI Proxy API) group
{Key: "cpa.base_url", Value: "http://127.0.0.1:8317", Group: "cpa", Label: "CPA 地址", Type: "string"},
{Key: "cpa.management_key", Value: "gptplus2026", Group: "cpa", Label: "Management Key", Type: "password"},
}
d := db.GetDB()
for _, cfg := range defaults {
var existing db.SystemConfig
res := d.Where("key = ?", cfg.Key).First(&existing)
if res.Error != nil {
d.Create(&cfg)
} else if existing.Value == "" && cfg.Value != "" {
d.Model(&existing).Updates(map[string]interface{}{
"value": cfg.Value,
"label": cfg.Label,
"type": cfg.Type,
})
}
}
}

View File

@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"gpt-plus/internal/db"
"github.com/gin-gonic/gin"
)
func GetDashboard(c *gin.Context) {
stats, err := db.GetDashboardStats(db.GetDB())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,40 @@
package handler
import (
"net/http"
"gpt-plus/internal/db"
"github.com/gin-gonic/gin"
)
func RegisterEmailRecordRoutes(api *gin.RouterGroup) {
api.GET("/email-records", ListEmailRecords)
api.GET("/email-records/stats", GetEmailRecordStats)
}
func ListEmailRecords(c *gin.Context) {
d := db.GetDB()
query := d.Model(&db.EmailRecord{}).Order("created_at DESC")
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
p := db.PaginationParams{
Page: intQuery(c, "page", 1),
Size: intQuery(c, "size", 20),
}
var records []db.EmailRecord
result, err := db.Paginate(query, p, &records)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func GetEmailRecordStats(c *gin.Context) {
c.JSON(http.StatusOK, db.GetEmailRecordStats(db.GetDB()))
}

View File

@@ -0,0 +1,518 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"net/http/httptest"
"os"
"testing"
"gpt-plus/internal/db"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func init() {
gin.SetMode(gin.TestMode)
}
func setupTestEnv(t *testing.T) *gorm.DB {
t.Helper()
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := d.AutoMigrate(
&db.SystemConfig{}, &db.EmailRecord{}, &db.Task{}, &db.TaskLog{},
&db.CardCode{}, &db.Card{}, &db.Account{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
db.DB = d
SetJWTSecret("test-secret-key")
os.Setenv("ADMIN_PASSWORD", "testpass")
if err := InitAdminPassword(); err != nil {
t.Fatalf("init password: %v", err)
}
// Reset rate limiter between tests
loginLimiter.mu.Lock()
loginLimiter.attempts = make(map[string][]time.Time)
loginLimiter.mu.Unlock()
return d
}
func jsonReq(method, path string, body interface{}) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
return req
}
func doRequest(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func loginAndGetCookie(t *testing.T, r *gin.Engine) *http.Cookie {
t.Helper()
req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"})
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("login failed: %d %s", w.Code, w.Body.String())
}
for _, c := range w.Result().Cookies() {
if c.Name == "token" {
return c
}
}
t.Fatal("no token cookie in login response")
return nil
}
func authedReq(method, path string, body interface{}, cookie *http.Cookie) *http.Request {
req := jsonReq(method, path, body)
req.AddCookie(cookie)
return req
}
// --- Auth tests ---
func TestLoginSuccess(t *testing.T) {
setupTestEnv(t)
r := SetupRouter(false)
req := jsonReq("POST", "/api/login", map[string]string{"password": "testpass"})
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["message"] != "登录成功" {
t.Fatalf("message = %q", resp["message"])
}
found := false
for _, c := range w.Result().Cookies() {
if c.Name == "token" && c.HttpOnly {
found = true
}
}
if !found {
t.Fatal("no httpOnly token cookie set")
}
}
func TestLoginWrongPassword(t *testing.T) {
setupTestEnv(t)
r := SetupRouter(false)
req := jsonReq("POST", "/api/login", map[string]string{"password": "wrong"})
w := doRequest(r, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
}
func TestLoginEmptyPassword(t *testing.T) {
setupTestEnv(t)
r := SetupRouter(false)
req := jsonReq("POST", "/api/login", map[string]string{})
w := doRequest(r, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestLogout(t *testing.T) {
setupTestEnv(t)
r := SetupRouter(false)
req := jsonReq("POST", "/api/logout", nil)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
for _, c := range w.Result().Cookies() {
if c.Name == "token" && c.MaxAge < 0 {
return
}
}
t.Fatal("token cookie not cleared")
}
func TestAuthMiddlewareRejectsUnauthenticated(t *testing.T) {
setupTestEnv(t)
r := SetupRouter(false)
req := jsonReq("GET", "/api/config", nil)
w := doRequest(r, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
}
func TestAuthMiddlewareAcceptsValidToken(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
req := authedReq("GET", "/api/auth/check", nil, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
}
// --- Config tests ---
func TestGetConfig(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
req := authedReq("GET", "/api/config", nil, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
var resp struct {
Groups map[string][]db.SystemConfig `json:"groups"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Groups) == 0 {
t.Fatal("expected non-empty config groups")
}
if _, ok := resp.Groups["proxy"]; !ok {
t.Fatal("expected proxy group")
}
}
func TestUpdateConfig(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
body := map[string]interface{}{
"items": []map[string]string{
{"key": "proxy.url", "value": "socks5://1.2.3.4:1080"},
},
}
req := authedReq("PUT", "/api/config", body, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
var cfg db.SystemConfig
db.GetDB().Where("key = ?", "proxy.url").First(&cfg)
if cfg.Value != "socks5://1.2.3.4:1080" {
t.Fatalf("config value = %q, want socks5://1.2.3.4:1080", cfg.Value)
}
}
// --- Card tests ---
func TestCardCRUD(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
// Add card
body := map[string]string{
"number": "4242424242424242", "cvc": "123",
"exp_month": "12", "exp_year": "2030", "country": "US",
}
req := authedReq("POST", "/api/cards", body, cookie)
w := doRequest(r, req)
if w.Code != http.StatusCreated {
t.Fatalf("add card: status = %d, body: %s", w.Code, w.Body.String())
}
// List cards
req = authedReq("GET", "/api/cards", nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("list cards: status = %d", w.Code)
}
var listResp struct {
Items []db.Card `json:"items"`
Total int64 `json:"total"`
}
json.Unmarshal(w.Body.Bytes(), &listResp)
if listResp.Total != 1 {
t.Fatalf("total = %d, want 1", listResp.Total)
}
if listResp.Items[0].NumberLast4 != "4242" {
t.Fatalf("last4 = %q, want 4242", listResp.Items[0].NumberLast4)
}
// Stats
req = authedReq("GET", "/api/cards/stats", nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("card stats: status = %d", w.Code)
}
// Delete
cardID := listResp.Items[0].ID
req = authedReq("DELETE", "/api/cards/"+itoa(cardID), nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("delete card: status = %d", w.Code)
}
}
func TestCardDuplicate(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
body := map[string]string{
"number": "5555555555554444", "cvc": "321",
"exp_month": "06", "exp_year": "2029", "country": "JP",
}
req := authedReq("POST", "/api/cards", body, cookie)
doRequest(r, req) // first
req = authedReq("POST", "/api/cards", body, cookie)
w := doRequest(r, req) // duplicate
if w.Code != http.StatusConflict {
t.Fatalf("duplicate card: status = %d, want 409", w.Code)
}
}
// --- Card Code tests ---
func TestCardCodeImport(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
body := map[string]interface{}{
"codes": []string{"CODE-001", "CODE-002", "CODE-003"},
}
req := authedReq("POST", "/api/card-codes/import", body, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("import: status = %d, body: %s", w.Code, w.Body.String())
}
var resp map[string]int
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["imported"] != 3 {
t.Fatalf("imported = %d, want 3", resp["imported"])
}
// Stats
req = authedReq("GET", "/api/card-codes/stats", nil, cookie)
w = doRequest(r, req)
var stats db.CardCodeStats
json.Unmarshal(w.Body.Bytes(), &stats)
if stats.Unused != 3 {
t.Fatalf("unused = %d, want 3", stats.Unused)
}
}
// --- Dashboard test ---
func TestDashboard(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
req := authedReq("GET", "/api/dashboard", nil, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("dashboard: status = %d, body: %s", w.Code, w.Body.String())
}
}
// --- Email record tests ---
func TestEmailRecordStats(t *testing.T) {
d := setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
d.Create(&db.EmailRecord{Email: "a@test.com", Status: "used"})
d.Create(&db.EmailRecord{Email: "b@test.com", Status: "used_failed"})
req := authedReq("GET", "/api/email-records/stats", nil, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var stats db.EmailRecordStats
json.Unmarshal(w.Body.Bytes(), &stats)
if stats.Total != 2 || stats.Used != 1 || stats.UsedFailed != 1 {
t.Fatalf("stats = %+v", stats)
}
}
// --- Account tests ---
func TestAccountListAndDetail(t *testing.T) {
d := setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
d.Create(&db.Account{Email: "plus@test.com", Plan: "plus", Status: "active", AccountID: "acc-1"})
owner := db.Account{Email: "team@test.com", Plan: "team_owner", Status: "active", AccountID: "acc-2"}
d.Create(&owner)
d.Create(&db.Account{Email: "m1@test.com", Plan: "team_member", Status: "active", ParentID: &owner.ID})
// List (should only show plus + team_owner)
req := authedReq("GET", "/api/accounts", nil, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var listResp struct {
Items []db.Account `json:"items"`
Total int64 `json:"total"`
}
json.Unmarshal(w.Body.Bytes(), &listResp)
if listResp.Total != 2 {
t.Fatalf("total = %d, want 2 (team_member hidden)", listResp.Total)
}
// Detail with sub-accounts
req = authedReq("GET", "/api/accounts/"+itoa(owner.ID), nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("detail status = %d", w.Code)
}
var detail db.Account
json.Unmarshal(w.Body.Bytes(), &detail)
if len(detail.SubAccounts) != 1 {
t.Fatalf("sub_accounts = %d, want 1", len(detail.SubAccounts))
}
}
func TestAccountUpdateNote(t *testing.T) {
d := setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
d.Create(&db.Account{Email: "note@test.com", Plan: "plus", Status: "active"})
req := authedReq("PUT", "/api/accounts/1/note", map[string]string{"note": "VIP"}, cookie)
w := doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var acct db.Account
d.First(&acct, 1)
if acct.Note != "VIP" {
t.Fatalf("note = %q, want VIP", acct.Note)
}
}
func TestAccountSearch(t *testing.T) {
d := setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
d.Create(&db.Account{Email: "alice@test.com", Plan: "plus", Status: "active"})
d.Create(&db.Account{Email: "bob@test.com", Plan: "plus", Status: "active"})
req := authedReq("GET", "/api/accounts?search=alice", nil, cookie)
w := doRequest(r, req)
var resp struct {
Total int64 `json:"total"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("search total = %d, want 1", resp.Total)
}
}
// --- Task tests ---
func TestTaskCreateAndList(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
body := map[string]interface{}{"type": "plus", "count": 5}
req := authedReq("POST", "/api/tasks", body, cookie)
w := doRequest(r, req)
if w.Code != http.StatusCreated {
t.Fatalf("create task: status = %d, body: %s", w.Code, w.Body.String())
}
var created db.Task
json.Unmarshal(w.Body.Bytes(), &created)
if created.ID == "" || created.Status != "pending" || created.TotalCount != 5 {
t.Fatalf("created = %+v", created)
}
// List
req = authedReq("GET", "/api/tasks", nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("list: status = %d", w.Code)
}
// Delete
req = authedReq("DELETE", "/api/tasks/"+created.ID, nil, cookie)
w = doRequest(r, req)
if w.Code != http.StatusOK {
t.Fatalf("delete: status = %d, body: %s", w.Code, w.Body.String())
}
}
func TestTaskCreateInvalidType(t *testing.T) {
setupTestEnv(t)
SeedDefaultConfigs()
r := SetupRouter(false)
cookie := loginAndGetCookie(t, r)
body := map[string]interface{}{"type": "invalid", "count": 1}
req := authedReq("POST", "/api/tasks", body, cookie)
w := doRequest(r, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func itoa(n uint) string {
return fmt.Sprintf("%d", n)
}

View File

@@ -0,0 +1,84 @@
package handler
import (
"net/http"
"os"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret []byte
func SetJWTSecret(secret string) {
jwtSecret = []byte(secret)
}
func GenerateToken() (string, error) {
claims := jwt.MapClaims{
"role": "admin",
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenStr, err := c.Cookie("token")
if err != nil || tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
return
}
c.Next()
}
}
// Rate limiter for login endpoint: 5 attempts per minute per IP
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
}
var loginLimiter = &rateLimiter{attempts: make(map[string][]time.Time)}
func LoginRateLimitMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
loginLimiter.mu.Lock()
defer loginLimiter.mu.Unlock()
now := time.Now()
window := now.Add(-1 * time.Minute)
// Prune old entries
valid := make([]time.Time, 0)
for _, t := range loginLimiter.attempts[ip] {
if t.After(window) {
valid = append(valid, t)
}
}
loginLimiter.attempts[ip] = valid
if len(valid) >= 5 {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "登录尝试过于频繁,请稍后再试"})
return
}
loginLimiter.attempts[ip] = append(loginLimiter.attempts[ip], now)
c.Next()
}
}
func IsSecure() bool {
return os.Getenv("GIN_MODE") == "release"
}

View File

@@ -0,0 +1,42 @@
package handler
import (
"github.com/gin-gonic/gin"
)
func SetupRouter(devMode bool) *gin.Engine {
r := gin.Default()
// Public routes
pub := r.Group("/api")
pub.POST("/login", LoginRateLimitMiddleware(), Login)
pub.POST("/logout", Logout)
// Protected routes
api := r.Group("/api")
api.Use(AuthMiddleware())
{
api.GET("/auth/check", CheckAuth)
// Config
api.GET("/config", GetConfig)
api.PUT("/config", UpdateConfig)
// Dashboard
api.GET("/dashboard", GetDashboard)
// Cards + Card Codes
RegisterCardRoutes(api)
// Email Records
RegisterEmailRecordRoutes(api)
// Tasks
RegisterTaskRoutes(api)
// Accounts
RegisterAccountRoutes(api)
}
return r
}

201
internal/handler/task.go Normal file
View File

@@ -0,0 +1,201 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"gpt-plus/internal/db"
"gpt-plus/internal/task"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var taskManager *task.TaskManager
func SetTaskManager(tm *task.TaskManager) {
taskManager = tm
}
func RegisterTaskRoutes(api *gin.RouterGroup) {
api.POST("/tasks", CreateTask)
api.GET("/tasks", ListTasks)
api.GET("/tasks/:id", GetTask)
api.GET("/tasks/:id/logs", GetTaskLogs)
api.POST("/tasks/:id/start", StartTask)
api.POST("/tasks/:id/stop", StopTask)
api.POST("/tasks/:id/force-stop", ForceStopTask)
api.DELETE("/tasks/:id", DeleteTask)
}
type createTaskRequest struct {
Type string `json:"type" binding:"required"`
Count int `json:"count" binding:"required,min=1"`
}
func CreateTask(c *gin.Context) {
var req createTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Type != "plus" && req.Type != "team" && req.Type != "both" {
c.JSON(http.StatusBadRequest, gin.H{"error": "类型必须为 plus, team 或 both"})
return
}
// Snapshot current config
var configs []db.SystemConfig
db.GetDB().Find(&configs)
cfgJSON, _ := json.Marshal(configs)
t := &db.Task{
ID: uuid.New().String(),
Type: req.Type,
TotalCount: req.Count,
Status: "pending",
Config: string(cfgJSON),
}
if err := db.GetDB().Create(t).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, t)
}
func ListTasks(c *gin.Context) {
d := db.GetDB()
query := d.Model(&db.Task{}).Order("created_at DESC")
if status := c.Query("status"); status != "" {
statuses := splitComma(status)
query = query.Where("status IN ?", statuses)
}
p := db.PaginationParams{Page: intQuery(c, "page", 1), Size: intQuery(c, "size", 20)}
var tasks []db.Task
result, err := db.Paginate(query, p, &tasks)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func GetTask(c *gin.Context) {
var t db.Task
if err := db.GetDB().First(&t, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
c.JSON(http.StatusOK, t)
}
func GetTaskLogs(c *gin.Context) {
taskID := c.Param("id")
sinceID := intQuery(c, "since_id", 0)
limit := intQuery(c, "limit", 50)
if limit > 200 {
limit = 200
}
var logs []db.TaskLog
query := db.GetDB().Where("task_id = ?", taskID)
if sinceID > 0 {
query = query.Where("id > ?", sinceID)
}
query.Order("id ASC").Limit(limit).Find(&logs)
c.JSON(http.StatusOK, gin.H{"items": logs})
}
func StartTask(c *gin.Context) {
if taskManager == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"})
return
}
if err := taskManager.Start(c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "任务已启动"})
}
func StopTask(c *gin.Context) {
if taskManager == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"})
return
}
if err := taskManager.Stop(c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "正在停止..."})
}
func ForceStopTask(c *gin.Context) {
if taskManager == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "任务管理器未初始化"})
return
}
if err := taskManager.ForceStop(c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已强制取消"})
}
func DeleteTask(c *gin.Context) {
taskID := c.Param("id")
var t db.Task
if err := db.GetDB().First(&t, "id = ?", taskID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
if t.Status == "running" || t.Status == "stopping" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法删除运行中的任务"})
return
}
db.GetDB().Where("task_id = ?", taskID).Delete(&db.TaskLog{})
db.GetDB().Delete(&t)
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
func splitComma(s string) []string {
var result []string
for _, v := range splitStr(s, ",") {
v = trimStr(v)
if v != "" {
result = append(result, v)
}
}
return result
}
func splitStr(s, sep string) []string {
result := []string{}
start := 0
for i := 0; i <= len(s)-len(sep); i++ {
if s[i:i+len(sep)] == sep {
result = append(result, s[start:i])
start = i + len(sep)
}
}
result = append(result, s[start:])
return result
}
func trimStr(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
// Ensure time import is used
var _ = time.Now

View File

@@ -0,0 +1,418 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gpt-plus/config"
"gpt-plus/internal/db"
"gpt-plus/pkg/auth"
"gpt-plus/pkg/chatgpt"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/proxy"
"gorm.io/gorm"
)
func getProxyClient(d *gorm.DB) (*httpclient.Client, error) {
var b2Enabled db.SystemConfig
if d.Where("key = ?", "proxy.b2proxy.enabled").First(&b2Enabled).Error == nil && b2Enabled.Value == "true" {
var apiBase, zone, proto db.SystemConfig
d.Where("key = ?", "proxy.b2proxy.api_base").First(&apiBase)
d.Where("key = ?", "proxy.b2proxy.zone").First(&zone)
d.Where("key = ?", "proxy.b2proxy.proto").First(&proto)
var country db.SystemConfig
d.Where("key = ?", "card.default_country").First(&country)
countryCode := country.Value
if countryCode == "" {
countryCode = "US"
}
sessTime := 5
var sessTimeCfg db.SystemConfig
if d.Where("key = ?", "proxy.b2proxy.sess_time").First(&sessTimeCfg).Error == nil {
fmt.Sscanf(sessTimeCfg.Value, "%d", &sessTime)
}
b2Cfg := config.B2ProxyConfig{
Enabled: true, APIBase: apiBase.Value,
Zone: zone.Value, Proto: proto.Value,
PType: 1, SessTime: sessTime,
}
proxyURL, err := proxy.FetchB2Proxy(b2Cfg, countryCode)
if err != nil {
return nil, fmt.Errorf("fetch B2Proxy: %w", err)
}
return httpclient.NewClient(proxyURL)
}
var proxyCfg db.SystemConfig
if d.Where("key = ?", "proxy.url").First(&proxyCfg).Error == nil && proxyCfg.Value != "" {
return httpclient.NewClient(proxyCfg.Value)
}
return httpclient.NewClient("")
}
// parseErrorCode extracts the "code" field from a JSON error response body.
func parseErrorCode(body []byte) string {
var errResp struct {
Detail struct {
Code string `json:"code"`
} `json:"detail"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Detail.Code != "" {
return errResp.Detail.Code
}
// Fallback: try top-level code
var simple struct {
Code string `json:"code"`
}
if json.Unmarshal(body, &simple) == nil && simple.Code != "" {
return simple.Code
}
return ""
}
// refreshAccountToken attempts to refresh the access token via Codex OAuth.
// Returns true if refresh succeeded and DB was updated.
func refreshAccountToken(d *gorm.DB, acct *db.Account) bool {
client, err := getProxyClient(d)
if err != nil {
log.Printf("[token-refresh] %s: proxy failed: %v", acct.Email, err)
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// team_member 不传 workspace_id用默认 personal workspace 刷新
workspaceID := ""
if acct.Plan == "team_owner" {
workspaceID = acct.TeamWorkspaceID
}
tokens, err := auth.ObtainCodexTokens(ctx, client, acct.DeviceID, workspaceID)
if err != nil {
log.Printf("[token-refresh] %s: ObtainCodexTokens failed: %v", acct.Email, err)
return false
}
acct.AccessToken = tokens.AccessToken
if tokens.RefreshToken != "" {
acct.RefreshToken = tokens.RefreshToken
}
if tokens.IDToken != "" {
acct.IDToken = tokens.IDToken
}
if tokens.ChatGPTAccountID != "" {
acct.AccountID = tokens.ChatGPTAccountID
}
d.Save(acct)
log.Printf("[token-refresh] %s: token refreshed successfully", acct.Email)
return true
}
type AccountCheckResult struct {
ID uint `json:"id"`
Email string `json:"email"`
Status string `json:"status"`
Plan string `json:"plan"`
Message string `json:"message"`
}
func CheckAccountStatuses(d *gorm.DB, ids []uint) []AccountCheckResult {
var results []AccountCheckResult
for _, id := range ids {
var acct db.Account
if d.First(&acct, id).Error != nil {
results = append(results, AccountCheckResult{ID: id, Status: "error", Message: "账号不存在"})
continue
}
r := checkSingleAccount(d, &acct)
results = append(results, r)
}
return results
}
func checkSingleAccount(d *gorm.DB, acct *db.Account) AccountCheckResult {
r := AccountCheckResult{ID: acct.ID, Email: acct.Email}
now := time.Now()
acct.StatusCheckedAt = &now
client, err := getProxyClient(d)
if err != nil {
r.Status = "error"
r.Message = "代理连接失败: " + err.Error()
d.Save(acct)
return r
}
accounts, err := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID)
if err != nil {
errMsg := err.Error()
// Try to detect specific error codes from response body
if strings.Contains(errMsg, "401") {
// Could be token_invalidated or account_deactivated — try refresh
log.Printf("[account-check] %s: got 401, attempting token refresh...", acct.Email)
if refreshAccountToken(d, acct) {
// Retry with new token
accounts2, err2 := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID)
if err2 == nil {
return buildCheckSuccess(d, acct, accounts2)
}
log.Printf("[account-check] %s: retry after refresh still failed: %v", acct.Email, err2)
}
acct.Status = "banned"
r.Status = "banned"
r.Message = "令牌无效且刷新失败,可能已封禁"
} else if strings.Contains(errMsg, "403") {
acct.Status = "banned"
r.Status = "banned"
r.Message = "账号已封禁 (403)"
} else {
acct.Status = "unknown"
r.Status = "unknown"
r.Message = errMsg
}
d.Save(acct)
return r
}
return buildCheckSuccess(d, acct, accounts)
}
func buildCheckSuccess(d *gorm.DB, acct *db.Account, accounts []*chatgpt.AccountInfo) AccountCheckResult {
r := AccountCheckResult{ID: acct.ID, Email: acct.Email}
var planParts []string
for _, info := range accounts {
planParts = append(planParts, fmt.Sprintf("%s(%s)", info.PlanType, info.Structure))
}
target := selectMembershipAccount(acct, accounts)
if target == nil {
acct.Status = "unknown"
r.Status = "unknown"
r.Message = "membership check returned no matching account"
d.Save(acct)
return r
}
resolvedStatus := normalizeMembershipStatus(target.PlanType)
if resolvedStatus == "" {
resolvedStatus = "unknown"
}
acct.Status = resolvedStatus
r.Status = resolvedStatus
r.Plan = resolvedStatus
r.Message = fmt.Sprintf("membership=%s (%d accounts: %s)", resolvedStatus, len(accounts), strings.Join(planParts, ", "))
if target.AccountID != "" && target.AccountID != acct.AccountID {
acct.AccountID = target.AccountID
}
if target.Structure == "workspace" && target.AccountID != "" {
acct.TeamWorkspaceID = target.AccountID
}
d.Save(acct)
log.Printf("[account-check] %s: %s", acct.Email, r.Message)
return r
}
func normalizeMembershipStatus(planType string) string {
switch planType {
case "free", "plus", "team":
return planType
default:
return ""
}
}
func selectMembershipAccount(acct *db.Account, accounts []*chatgpt.AccountInfo) *chatgpt.AccountInfo {
if len(accounts) == 0 {
return nil
}
if acct.Plan == "team_owner" || acct.Plan == "team_member" {
if acct.TeamWorkspaceID != "" {
for _, info := range accounts {
if info.AccountID == acct.TeamWorkspaceID {
return info
}
}
}
for _, info := range accounts {
if info.Structure == "workspace" && info.PlanType == "team" {
return info
}
}
for _, info := range accounts {
if info.Structure == "workspace" {
return info
}
}
}
if acct.AccountID != "" {
for _, info := range accounts {
if info.AccountID == acct.AccountID && info.Structure == "personal" {
return info
}
}
}
for _, info := range accounts {
if info.Structure == "personal" {
return info
}
}
if acct.AccountID != "" {
for _, info := range accounts {
if info.AccountID == acct.AccountID {
return info
}
}
}
return accounts[0]
}
// --- Model Test ---
type ModelTestResult struct {
ID uint `json:"id"`
Email string `json:"email"`
Model string `json:"model"`
Success bool `json:"success"`
Message string `json:"message"`
Output string `json:"output,omitempty"`
}
func TestModelAvailability(d *gorm.DB, accountID uint, modelID string) *ModelTestResult {
var acct db.Account
if d.First(&acct, accountID).Error != nil {
return &ModelTestResult{ID: accountID, Model: modelID, Message: "账号不存在"}
}
if modelID == "" {
modelID = "gpt-4o"
}
result := doModelTest(d, &acct, modelID)
// If token_invalidated, auto-refresh and retry once
if !result.Success && strings.Contains(result.Message, "令牌过期") {
log.Printf("[model-test] %s: token expired, refreshing...", acct.Email)
if refreshAccountToken(d, &acct) {
result = doModelTest(d, &acct, modelID)
if result.Success {
result.Message += " (令牌已自动刷新)"
}
}
}
return result
}
func doModelTest(d *gorm.DB, acct *db.Account, modelID string) *ModelTestResult {
client, err := getProxyClient(d)
if err != nil {
return &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID, Message: "代理连接失败: " + err.Error()}
}
result := &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID}
apiURL := "https://chatgpt.com/backend-api/codex/responses"
payload := map[string]interface{}{
"model": modelID,
"input": []map[string]interface{}{
{
"role": "user",
"content": []map[string]interface{}{
{"type": "input_text", "text": "hi"},
},
},
},
"stream": false,
"store": false,
}
headers := map[string]string{
"Authorization": "Bearer " + acct.AccessToken,
"Content-Type": "application/json",
"Accept": "*/*",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"Origin": "https://chatgpt.com",
"Referer": "https://chatgpt.com/",
"oai-language": "en-US",
}
if acct.AccountID != "" {
headers["chatgpt-account-id"] = acct.AccountID
}
if acct.DeviceID != "" {
headers["oai-device-id"] = acct.DeviceID
}
resp, err := client.PostJSON(apiURL, payload, headers)
if err != nil {
result.Message = fmt.Sprintf("请求失败: %v", err)
return result
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
result.Success = true
result.Message = fmt.Sprintf("模型 %s 可用", modelID)
var respData map[string]interface{}
if json.Unmarshal(body, &respData) == nil {
if output, ok := respData["output_text"]; ok {
result.Output = fmt.Sprintf("%v", output)
}
}
case http.StatusUnauthorized:
code := parseErrorCode(body)
switch code {
case "token_invalidated":
result.Message = "令牌过期 (token_invalidated)"
case "account_deactivated":
result.Message = "账号已封禁 (account_deactivated)"
acct.Status = "banned"
d.Save(acct)
default:
result.Message = fmt.Sprintf("认证失败 (401, code=%s)", code)
}
case http.StatusForbidden:
result.Message = "账号被封禁 (403)"
acct.Status = "banned"
d.Save(acct)
case http.StatusNotFound:
result.Message = fmt.Sprintf("模型 %s 不存在 (404)", modelID)
case http.StatusTooManyRequests:
result.Message = "请求限流 (429),请稍后再试"
default:
errMsg := string(body)
if len(errMsg) > 300 {
errMsg = errMsg[:300]
}
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errMsg)
}
log.Printf("[model-test] %s model=%s status=%d success=%v msg=%s", acct.Email, modelID, resp.StatusCode, result.Success, result.Message)
return result
}

125
internal/service/cpa_svc.go Normal file
View File

@@ -0,0 +1,125 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"gpt-plus/internal/db"
"gorm.io/gorm"
)
// getConfigValue reads a config value from the database, auto-decrypting password fields.
func getConfigValue(d *gorm.DB, key string) (string, error) {
var cfg db.SystemConfig
if err := d.Where("key = ?", key).First(&cfg).Error; err != nil {
return "", err
}
if cfg.Type == "password" && cfg.Value != "" {
decrypted, err := db.Decrypt(cfg.Value)
if err != nil {
// Fallback to raw value (may be plaintext from seed)
return cfg.Value, nil
}
return decrypted, nil
}
return cfg.Value, nil
}
// TransferResult holds the result for a single account transfer.
type TransferResult struct {
ID uint `json:"id"`
Email string `json:"email"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// TransferAccountToCPA builds an auth file for the account and uploads it to CPA.
func TransferAccountToCPA(d *gorm.DB, accountID uint) TransferResult {
// Get account first (for email in result)
var acct db.Account
if err := d.First(&acct, accountID).Error; err != nil {
return TransferResult{ID: accountID, Error: "账号不存在"}
}
result := TransferResult{ID: acct.ID, Email: acct.Email}
// Get CPA config
baseURL, err := getConfigValue(d, "cpa.base_url")
if err != nil || baseURL == "" {
result.Error = "CPA 地址未配置"
return result
}
managementKey, err := getConfigValue(d, "cpa.management_key")
if err != nil || managementKey == "" {
result.Error = "CPA Management Key 未配置"
return result
}
// Build auth file
auth := buildAuthFile(&acct)
jsonData, err := json.MarshalIndent(auth, "", " ")
if err != nil {
result.Error = fmt.Sprintf("序列化失败: %v", err)
return result
}
// If team_owner, also transfer sub-accounts
if acct.Plan == "team_owner" {
var subs []db.Account
d.Where("parent_id = ?", acct.ID).Find(&subs)
for _, sub := range subs {
subAuth := buildAuthFile(&sub)
subData, _ := json.MarshalIndent(subAuth, "", " ")
if err := uploadAuthFile(baseURL, managementKey, sub.Email+".auth.json", subData); err != nil {
result.Error = fmt.Sprintf("子号 %s 上传失败: %v", sub.Email, err)
return result
}
}
}
// Upload main account
if err := uploadAuthFile(baseURL, managementKey, acct.Email+".auth.json", jsonData); err != nil {
result.Error = fmt.Sprintf("上传失败: %v", err)
return result
}
result.OK = true
return result
}
// uploadAuthFile uploads a single auth JSON file to CPA via multipart form.
func uploadAuthFile(baseURL, managementKey, filename string, data []byte) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
return fmt.Errorf("创建表单失败: %w", err)
}
part.Write(data)
writer.Close()
req, err := http.NewRequest("POST", baseURL+"/v0/management/auth-files", &body)
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+managementKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("CPA 返回 %d: %s", resp.StatusCode, string(respBody))
}
return nil
}

View File

@@ -0,0 +1,121 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"time"
"gpt-plus/internal/db"
"gorm.io/gorm"
)
type authFile struct {
OpenAIAPIKey string `json:"OPENAI_API_KEY"`
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
LastRefresh string `json:"last_refresh"`
RefreshToken string `json:"refresh_token"`
Tokens authTokens `json:"tokens"`
}
type authTokens struct {
AccessToken string `json:"access_token"`
AccountID string `json:"account_id"`
IDToken string `json:"id_token"`
LastRefresh string `json:"last_refresh"`
RefreshToken string `json:"refresh_token"`
}
func buildAuthFile(acct *db.Account) *authFile {
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
token := acct.AccessToken
if acct.WorkspaceToken != "" {
token = acct.WorkspaceToken
}
accountID := acct.AccountID
if acct.TeamWorkspaceID != "" {
accountID = acct.TeamWorkspaceID
}
return &authFile{
AccessToken: token,
IDToken: acct.IDToken,
LastRefresh: now,
RefreshToken: acct.RefreshToken,
Tokens: authTokens{
AccessToken: token,
AccountID: accountID,
IDToken: acct.IDToken,
LastRefresh: now,
RefreshToken: acct.RefreshToken,
},
}
}
// ExportAccounts exports accounts as auth.json files.
// If multiple files, returns a zip archive.
func ExportAccounts(d *gorm.DB, ids []uint, note string) ([]byte, string, error) {
var accounts []db.Account
d.Where("id IN ?", ids).Find(&accounts)
if len(accounts) == 0 {
return nil, "", fmt.Errorf("未找到账号")
}
// Collect all files to export
type exportFile struct {
Name string
Data []byte
}
var files []exportFile
for _, acct := range accounts {
auth := buildAuthFile(&acct)
data, _ := json.MarshalIndent(auth, "", " ")
files = append(files, exportFile{
Name: acct.Email + ".auth.json",
Data: data,
})
// If team_owner, also export sub-accounts
if acct.Plan == "team_owner" {
var subs []db.Account
d.Where("parent_id = ?", acct.ID).Find(&subs)
for _, sub := range subs {
subAuth := buildAuthFile(&sub)
subData, _ := json.MarshalIndent(subAuth, "", " ")
files = append(files, exportFile{
Name: sub.Email + ".auth.json",
Data: subData,
})
}
}
}
// Single file — return directly
if len(files) == 1 {
return files[0].Data, files[0].Name, nil
}
// Multiple files — zip archive
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range files {
w, err := zw.Create(f.Name)
if err != nil {
return nil, "", err
}
w.Write(f.Data)
}
zw.Close()
ts := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("export_%s_%s.zip", note, ts)
if note == "" {
filename = fmt.Sprintf("export_%s.zip", ts)
}
return buf.Bytes(), filename, nil
}

View File

@@ -0,0 +1,168 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"strings"
"testing"
"gpt-plus/internal/db"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupExportTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open db: %v", err)
}
d.AutoMigrate(&db.Account{})
return d
}
func TestExportSinglePlusAccount(t *testing.T) {
d := setupExportTestDB(t)
d.Create(&db.Account{
Email: "plus@test.com", Plan: "plus", Status: "active",
AccessToken: "at-123", RefreshToken: "rt-456", IDToken: "id-789",
AccountID: "acc-001",
})
data, filename, err := ExportAccounts(d, []uint{1}, "")
if err != nil {
t.Fatalf("export: %v", err)
}
if !strings.HasSuffix(filename, ".auth.json") {
t.Fatalf("filename = %q, want *.auth.json", filename)
}
var auth authFile
if err := json.Unmarshal(data, &auth); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if auth.AccessToken != "at-123" {
t.Fatalf("access_token = %q", auth.AccessToken)
}
if auth.Tokens.AccountID != "acc-001" {
t.Fatalf("tokens.account_id = %q", auth.Tokens.AccountID)
}
}
func TestExportTeamWithSubAccounts(t *testing.T) {
d := setupExportTestDB(t)
owner := db.Account{
Email: "owner@test.com", Plan: "team_owner", Status: "active",
AccessToken: "owner-at", RefreshToken: "owner-rt",
AccountID: "team-acc", TeamWorkspaceID: "ws-123", WorkspaceToken: "ws-tok",
}
d.Create(&owner)
d.Create(&db.Account{
Email: "member@test.com", Plan: "team_member", Status: "active",
ParentID: &owner.ID, AccessToken: "mem-at", RefreshToken: "mem-rt",
})
data, filename, err := ExportAccounts(d, []uint{owner.ID}, "test")
if err != nil {
t.Fatalf("export: %v", err)
}
if !strings.HasSuffix(filename, ".zip") {
t.Fatalf("filename = %q, want *.zip", filename)
}
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
if len(r.File) != 2 {
t.Fatalf("zip has %d files, want 2", len(r.File))
}
names := make(map[string]bool)
for _, f := range r.File {
names[f.Name] = true
}
if !names["owner@test.com.auth.json"] {
t.Fatal("missing owner auth file")
}
if !names["member@test.com.auth.json"] {
t.Fatal("missing member auth file")
}
}
func TestExportTeamOwnerUsesWorkspaceToken(t *testing.T) {
d := setupExportTestDB(t)
d.Create(&db.Account{
Email: "team@test.com", Plan: "team_owner", Status: "active",
AccessToken: "normal-at", WorkspaceToken: "ws-at",
TeamWorkspaceID: "team-ws-id", AccountID: "personal-acc",
})
data, _, err := ExportAccounts(d, []uint{1}, "")
if err != nil {
t.Fatalf("export: %v", err)
}
var auth authFile
json.Unmarshal(data, &auth)
if auth.AccessToken != "ws-at" {
t.Fatalf("should use workspace token, got %q", auth.AccessToken)
}
if auth.Tokens.AccountID != "team-ws-id" {
t.Fatalf("should use team workspace ID, got %q", auth.Tokens.AccountID)
}
}
func TestExportMultiplePlusAccounts(t *testing.T) {
d := setupExportTestDB(t)
d.Create(&db.Account{Email: "a@test.com", Plan: "plus", AccessToken: "at-a"})
d.Create(&db.Account{Email: "b@test.com", Plan: "plus", AccessToken: "at-b"})
data, filename, err := ExportAccounts(d, []uint{1, 2}, "batch")
if err != nil {
t.Fatalf("export: %v", err)
}
if !strings.HasSuffix(filename, ".zip") {
t.Fatalf("filename = %q, want *.zip for multiple", filename)
}
if !strings.Contains(filename, "batch") {
t.Fatalf("filename should contain note, got %q", filename)
}
r, _ := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if len(r.File) != 2 {
t.Fatalf("zip has %d files, want 2", len(r.File))
}
}
func TestExportNotFound(t *testing.T) {
d := setupExportTestDB(t)
_, _, err := ExportAccounts(d, []uint{999}, "")
if err == nil {
t.Fatal("expected error for nonexistent account")
}
}
func TestBuildAuthFileFallbacks(t *testing.T) {
acct := &db.Account{
Email: "basic@test.com", AccessToken: "at-1", RefreshToken: "rt-1",
IDToken: "id-1", AccountID: "acc-1",
}
auth := buildAuthFile(acct)
if auth.AccessToken != "at-1" {
t.Fatalf("access_token = %q", auth.AccessToken)
}
if auth.Tokens.AccountID != "acc-1" {
t.Fatalf("tokens.account_id = %q", auth.Tokens.AccountID)
}
if auth.LastRefresh == "" {
t.Fatal("last_refresh should be set")
}
}

97
internal/task/manager.go Normal file
View File

@@ -0,0 +1,97 @@
package task
import (
"fmt"
"log"
"sync"
"time"
"gpt-plus/internal/db"
"gorm.io/gorm"
)
// TaskManager controls the lifecycle of task execution.
// Only one task may run at a time (single-task serial constraint).
type TaskManager struct {
mu sync.Mutex
current *TaskRunner
gormDB *gorm.DB
}
func NewTaskManager(d *gorm.DB) *TaskManager {
return &TaskManager{gormDB: d}
}
// Init marks any leftover running/stopping tasks as interrupted on startup.
func (m *TaskManager) Init() {
m.gormDB.Model(&db.Task{}).
Where("status IN ?", []string{StatusRunning, StatusStopping}).
Updates(map[string]interface{}{
"status": StatusInterrupted,
"stopped_at": time.Now(),
})
log.Println("[task-manager] init: marked leftover tasks as interrupted")
}
func (m *TaskManager) Start(taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil && m.current.IsRunning() {
return fmt.Errorf("已有任务正在运行 (ID: %s)", m.current.taskID)
}
var t db.Task
if err := m.gormDB.First(&t, "id = ?", taskID).Error; err != nil {
return fmt.Errorf("任务不存在: %w", err)
}
if t.Status != StatusPending && t.Status != StatusStopped && t.Status != StatusInterrupted {
return fmt.Errorf("任务状态不允许启动: %s", t.Status)
}
runner, err := NewTaskRunner(taskID, m.gormDB)
if err != nil {
return fmt.Errorf("创建任务运行器失败: %w", err)
}
m.current = runner
go func() {
runner.Run()
m.mu.Lock()
if m.current == runner {
m.current = nil
}
m.mu.Unlock()
}()
return nil
}
func (m *TaskManager) Stop(taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current == nil || m.current.taskID != taskID {
return fmt.Errorf("该任务未在运行")
}
m.current.GracefulStop()
return nil
}
func (m *TaskManager) ForceStop(taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current == nil || m.current.taskID != taskID {
return fmt.Errorf("该任务未在运行")
}
m.current.ForceStop()
return nil
}
func (m *TaskManager) IsRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.current != nil && m.current.IsRunning()
}

View File

@@ -0,0 +1,144 @@
package task
import (
"testing"
"time"
"gpt-plus/internal/db"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupTaskTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open db: %v", err)
}
d.AutoMigrate(&db.SystemConfig{}, &db.EmailRecord{}, &db.Task{}, &db.TaskLog{},
&db.CardCode{}, &db.Card{}, &db.Account{})
db.DB = d
return d
}
func TestTaskManagerInit(t *testing.T) {
d := setupTaskTestDB(t)
// Create a "leftover" running task
d.Create(&db.Task{ID: "left-1", Type: "plus", Status: StatusRunning, TotalCount: 10})
d.Create(&db.Task{ID: "left-2", Type: "team", Status: StatusStopping, TotalCount: 5})
tm := NewTaskManager(d)
tm.Init()
var t1, t2 db.Task
d.First(&t1, "id = ?", "left-1")
d.First(&t2, "id = ?", "left-2")
if t1.Status != StatusInterrupted {
t.Fatalf("left-1 status = %q, want interrupted", t1.Status)
}
if t2.Status != StatusInterrupted {
t.Fatalf("left-2 status = %q, want interrupted", t2.Status)
}
if t1.StoppedAt == nil {
t.Fatal("left-1 stopped_at should be set")
}
}
func TestTaskManagerStartNonexistent(t *testing.T) {
d := setupTaskTestDB(t)
tm := NewTaskManager(d)
err := tm.Start("nonexistent-id")
if err == nil {
t.Fatal("expected error for nonexistent task")
}
}
func TestTaskManagerStartWrongStatus(t *testing.T) {
d := setupTaskTestDB(t)
d.Create(&db.Task{ID: "completed-1", Type: "plus", Status: StatusCompleted, TotalCount: 10})
tm := NewTaskManager(d)
err := tm.Start("completed-1")
if err == nil {
t.Fatal("expected error for completed task")
}
}
func TestTaskManagerStopNoRunning(t *testing.T) {
d := setupTaskTestDB(t)
tm := NewTaskManager(d)
err := tm.Stop("any-id")
if err == nil {
t.Fatal("expected error when no task running")
}
}
func TestTaskManagerForceStopNoRunning(t *testing.T) {
d := setupTaskTestDB(t)
tm := NewTaskManager(d)
err := tm.ForceStop("any-id")
if err == nil {
t.Fatal("expected error when no task running")
}
}
func TestTaskManagerIsRunning(t *testing.T) {
d := setupTaskTestDB(t)
tm := NewTaskManager(d)
if tm.IsRunning() {
t.Fatal("should not be running initially")
}
}
func TestTaskManagerStopWrongID(t *testing.T) {
d := setupTaskTestDB(t)
tm := NewTaskManager(d)
// Simulate a running task by setting current directly
tm.current = &TaskRunner{taskID: "real-id"}
tm.current.running.Store(true)
err := tm.Stop("wrong-id")
if err == nil {
t.Fatal("expected error for wrong task ID")
}
}
func TestTaskManagerInitDoesNotAffectPending(t *testing.T) {
d := setupTaskTestDB(t)
d.Create(&db.Task{ID: "pending-1", Type: "plus", Status: StatusPending, TotalCount: 5})
tm := NewTaskManager(d)
tm.Init()
var task db.Task
d.First(&task, "id = ?", "pending-1")
if task.Status != StatusPending {
t.Fatalf("pending task should stay pending, got %q", task.Status)
}
}
func TestTaskManagerInitDoesNotAffectCompleted(t *testing.T) {
d := setupTaskTestDB(t)
stopped := time.Now()
d.Create(&db.Task{ID: "done-1", Type: "plus", Status: StatusCompleted, TotalCount: 10, StoppedAt: &stopped})
tm := NewTaskManager(d)
tm.Init()
var task db.Task
d.First(&task, "id = ?", "done-1")
if task.Status != StatusCompleted {
t.Fatalf("completed task should stay completed, got %q", task.Status)
}
}

229
internal/task/membership.go Normal file
View File

@@ -0,0 +1,229 @@
package task
import (
"context"
"fmt"
"time"
"gpt-plus/pkg/auth"
"gpt-plus/pkg/chatgpt"
)
const (
finalMembershipPolls = 5
finalMembershipPollDelay = 2 * time.Second
)
type finalMembershipState struct {
Personal *chatgpt.AccountInfo
Workspace *chatgpt.AccountInfo
}
func (s *finalMembershipState) plusActive() bool {
return plusMembershipActive(s.Personal)
}
func (s *finalMembershipState) teamActive() bool {
return teamMembershipActive(s.Workspace)
}
func (s *finalMembershipState) satisfied(taskType string) bool {
switch taskType {
case TaskTypePlus:
return s.plusActive()
case TaskTypeTeam:
return s.teamActive()
case TaskTypeBoth:
return s.plusActive() && s.teamActive()
default:
return false
}
}
func (s *finalMembershipState) resultPlanForTask(taskType string) string {
if s != nil && s.satisfied(taskType) {
return taskType
}
return s.actualPlan()
}
func (s *finalMembershipState) actualPlan() string {
switch {
case s == nil:
return "unknown"
case s.plusActive() && s.teamActive():
return TaskTypeBoth
case s.teamActive():
return "team"
case s.plusActive():
return "plus"
case s.Personal != nil && s.Personal.PlanType != "":
return s.Personal.PlanType
case s.Workspace != nil && s.Workspace.PlanType != "":
return s.Workspace.PlanType
default:
return "unknown"
}
}
func (s *finalMembershipState) describe() string {
return fmt.Sprintf("personal=%s workspace=%s", describeMembership(s.Personal), describeMembership(s.Workspace))
}
func plusMembershipActive(info *chatgpt.AccountInfo) bool {
return info != nil &&
info.Structure == "personal" &&
info.PlanType == "plus" &&
info.HasActiveSubscription &&
info.SubscriptionID != ""
}
func teamMembershipActive(info *chatgpt.AccountInfo) bool {
return info != nil &&
info.Structure == "workspace" &&
info.PlanType == "team"
}
func describeMembership(info *chatgpt.AccountInfo) string {
if info == nil {
return "none"
}
return fmt.Sprintf("%s/%s(active=%v sub=%t id=%s)",
info.Structure, info.PlanType, info.HasActiveSubscription, info.SubscriptionID != "", info.AccountID)
}
func selectPersonalMembership(accounts []*chatgpt.AccountInfo) *chatgpt.AccountInfo {
for _, acct := range accounts {
if acct.Structure == "personal" {
return acct
}
}
return nil
}
func selectWorkspaceMembership(accounts []*chatgpt.AccountInfo, preferredID string) *chatgpt.AccountInfo {
if preferredID != "" {
for _, acct := range accounts {
if acct.Structure == "workspace" && acct.AccountID == preferredID {
return acct
}
}
}
for _, acct := range accounts {
if acct.Structure == "workspace" && acct.PlanType == "team" {
return acct
}
}
for _, acct := range accounts {
if acct.Structure == "workspace" {
return acct
}
}
return nil
}
func (r *TaskRunner) verifyTaskMembership(
ctx context.Context,
taskType string,
session *chatgpt.Session,
teamAccountID string,
statusFn chatgpt.StatusFunc,
) (*finalMembershipState, error) {
var lastState *finalMembershipState
var lastErr error
for attempt := 1; attempt <= finalMembershipPolls; attempt++ {
if err := refreshVerificationTokens(ctx, session); err != nil && statusFn != nil {
statusFn(" -> Final token refresh %d/%d failed: %v", attempt, finalMembershipPolls, err)
}
accounts, err := chatgpt.CheckAccountFull(session.Client, session.AccessToken, session.DeviceID)
if err != nil {
lastErr = fmt.Errorf("accounts/check failed: %w", err)
} else {
lastState = &finalMembershipState{
Personal: selectPersonalMembership(accounts),
Workspace: selectWorkspaceMembership(accounts, teamAccountID),
}
}
if (taskType == TaskTypeTeam || taskType == TaskTypeBoth) &&
(lastState == nil || !lastState.teamActive()) &&
teamAccountID != "" {
workspaceToken, wsErr := chatgpt.GetWorkspaceAccessToken(session.Client, teamAccountID)
if wsErr == nil {
if wsAccounts, wsCheckErr := chatgpt.CheckAccountFull(session.Client, workspaceToken, session.DeviceID); wsCheckErr == nil {
if lastState == nil {
lastState = &finalMembershipState{}
}
lastState.Workspace = selectWorkspaceMembership(wsAccounts, teamAccountID)
} else if lastErr == nil {
lastErr = fmt.Errorf("workspace accounts/check failed: %w", wsCheckErr)
}
} else if lastErr == nil {
lastErr = fmt.Errorf("workspace token refresh failed: %w", wsErr)
}
}
if lastState != nil && statusFn != nil {
statusFn(" -> Final membership %d/%d: %s", attempt, finalMembershipPolls, lastState.describe())
}
if lastState != nil && lastState.satisfied(taskType) {
return lastState, nil
}
if lastState != nil {
lastErr = fmt.Errorf("membership mismatch for %s: %s", taskType, lastState.describe())
}
if attempt < finalMembershipPolls {
if err := sleepWithContext(ctx, finalMembershipPollDelay); err != nil {
return lastState, err
}
}
}
if lastState != nil {
return lastState, fmt.Errorf("final membership mismatch for %s: %s", taskType, lastState.describe())
}
if lastErr != nil {
return nil, fmt.Errorf("final membership check failed for %s: %w", taskType, lastErr)
}
return nil, fmt.Errorf("final membership check failed for %s", taskType)
}
func refreshVerificationTokens(ctx context.Context, session *chatgpt.Session) error {
if err := session.RefreshSession(); err == nil {
return nil
} else {
tokens, tokenErr := auth.ObtainCodexTokens(ctx, session.Client, session.DeviceID, "")
if tokenErr != nil {
return fmt.Errorf("session refresh failed: %v; codex refresh failed: %w", err, tokenErr)
}
if tokens.AccessToken == "" {
return fmt.Errorf("codex refresh returned empty access token")
}
session.AccessToken = tokens.AccessToken
if tokens.RefreshToken != "" {
session.RefreshToken = tokens.RefreshToken
}
if tokens.IDToken != "" {
session.IDToken = tokens.IDToken
}
if tokens.ChatGPTAccountID != "" {
session.AccountID = tokens.ChatGPTAccountID
}
return nil
}
}
func sleepWithContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

View File

@@ -0,0 +1,125 @@
package task
import (
"testing"
"gpt-plus/internal/db"
"gpt-plus/pkg/chatgpt"
)
func TestFinalMembershipStateSatisfiedByTaskType(t *testing.T) {
state := &finalMembershipState{
Personal: &chatgpt.AccountInfo{
AccountID: "personal-1",
Structure: "personal",
PlanType: "plus",
HasActiveSubscription: true,
SubscriptionID: "sub_123",
},
Workspace: &chatgpt.AccountInfo{
AccountID: "workspace-1",
Structure: "workspace",
PlanType: "team",
},
}
if !state.satisfied(TaskTypePlus) {
t.Fatal("expected plus task to be satisfied")
}
if !state.satisfied(TaskTypeTeam) {
t.Fatal("expected team task to be satisfied")
}
if !state.satisfied(TaskTypeBoth) {
t.Fatal("expected both task to be satisfied")
}
if got := state.resultPlanForTask(TaskTypePlus); got != TaskTypePlus {
t.Fatalf("plus resultPlanForTask = %q, want %q", got, TaskTypePlus)
}
if got := state.resultPlanForTask(TaskTypeTeam); got != TaskTypeTeam {
t.Fatalf("team resultPlanForTask = %q, want %q", got, TaskTypeTeam)
}
if got := state.resultPlanForTask(TaskTypeBoth); got != TaskTypeBoth {
t.Fatalf("both resultPlanForTask = %q, want %q", got, TaskTypeBoth)
}
}
func TestFinalMembershipStateFallbackToActualPlan(t *testing.T) {
state := &finalMembershipState{
Personal: &chatgpt.AccountInfo{
AccountID: "personal-1",
Structure: "personal",
PlanType: "free",
},
}
if state.satisfied(TaskTypePlus) {
t.Fatal("free personal account should not satisfy plus task")
}
if got := state.resultPlanForTask(TaskTypePlus); got != "free" {
t.Fatalf("resultPlanForTask = %q, want free", got)
}
}
func TestAccountStatusForResult(t *testing.T) {
cases := []struct {
name string
result *chatgpt.AccountResult
want string
}{
{name: "nil", result: nil, want: "active"},
{name: "free", result: &chatgpt.AccountResult{PlanType: "free"}, want: "free"},
{name: "plus", result: &chatgpt.AccountResult{PlanType: "plus"}, want: "plus"},
{name: "team", result: &chatgpt.AccountResult{PlanType: "team"}, want: "team"},
{name: "unknown", result: &chatgpt.AccountResult{PlanType: "mystery"}, want: "active"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := accountStatusForResult(tc.result); got != tc.want {
t.Fatalf("accountStatusForResult() = %q, want %q", got, tc.want)
}
})
}
}
func TestSaveAccountToDBUsesRealMembershipStatus(t *testing.T) {
d := setupTaskTestDB(t)
runner := &TaskRunner{taskID: "task-1", gormDB: d}
runner.saveAccountToDB(&chatgpt.AccountResult{
Email: "free@test.com",
Password: "pw",
PlanType: "free",
}, "plus", nil, "task-1")
var acct db.Account
if err := d.First(&acct, "email = ?", "free@test.com").Error; err != nil {
t.Fatalf("load account: %v", err)
}
if acct.Plan != "plus" {
t.Fatalf("plan = %q, want plus", acct.Plan)
}
if acct.Status != "free" {
t.Fatalf("status = %q, want free", acct.Status)
}
}
func TestLogResultStoresUsefulMessage(t *testing.T) {
d := setupTaskTestDB(t)
taskRow := &db.Task{ID: "task-2", Type: "plus", Status: StatusRunning, TotalCount: 1}
if err := d.Create(taskRow).Error; err != nil {
t.Fatalf("create task: %v", err)
}
runner := &TaskRunner{taskID: "task-2", gormDB: d}
runner.logResult(taskRow, 1, "a@test.com", LogStatusFailed, "free", "final membership mismatch", 3)
var logRow db.TaskLog
if err := d.First(&logRow, "task_id = ?", "task-2").Error; err != nil {
t.Fatalf("load task log: %v", err)
}
if logRow.Message != "final membership mismatch" {
t.Fatalf("message = %q, want final membership mismatch", logRow.Message)
}
}

793
internal/task/runner.go Normal file
View File

@@ -0,0 +1,793 @@
package task
import (
"context"
"crypto/rand"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"sync/atomic"
"time"
"gpt-plus/config"
"gpt-plus/internal/db"
"gpt-plus/pkg/auth"
"gpt-plus/pkg/captcha"
"gpt-plus/pkg/chatgpt"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/provider/card"
"gpt-plus/pkg/provider/email"
"gpt-plus/pkg/proxy"
"gpt-plus/pkg/storage"
"gpt-plus/pkg/stripe"
"gorm.io/gorm"
)
type TaskRunner struct {
taskID string
gormDB *gorm.DB
ctx context.Context
cancel context.CancelFunc
stopping atomic.Bool
running atomic.Bool
}
func NewTaskRunner(taskID string, d *gorm.DB) (*TaskRunner, error) {
ctx, cancel := context.WithCancel(context.Background())
return &TaskRunner{
taskID: taskID,
gormDB: d,
ctx: ctx,
cancel: cancel,
}, nil
}
func (r *TaskRunner) IsRunning() bool {
return r.running.Load()
}
func (r *TaskRunner) GracefulStop() {
r.stopping.Store(true)
r.gormDB.Model(&db.Task{}).Where("id = ?", r.taskID).Update("status", StatusStopping)
log.Printf("[task-runner] %s: graceful stop requested", r.taskID)
}
func (r *TaskRunner) ForceStop() {
r.stopping.Store(true)
r.cancel()
log.Printf("[task-runner] %s: force stop (context cancelled)", r.taskID)
}
func (r *TaskRunner) Run() {
r.running.Store(true)
defer r.running.Store(false)
var t db.Task
if err := r.gormDB.First(&t, "id = ?", r.taskID).Error; err != nil {
log.Printf("[task-runner] %s: load task failed: %v", r.taskID, err)
return
}
// Load config from DB
cfg, err := config.LoadFromDB(r.gormDB)
if err != nil {
log.Printf("[task-runner] %s: load config failed: %v", r.taskID, err)
r.failTask(&t, "配置加载失败: "+err.Error())
return
}
// Override task-specific settings based on task type
switch t.Type {
case TaskTypePlus:
cfg.Team.Enabled = false
case TaskTypeTeam:
cfg.Team.Enabled = true
case TaskTypeBoth:
cfg.Team.Enabled = true
}
// Mark task as running
now := time.Now()
t.Status = StatusRunning
t.StartedAt = &now
r.gormDB.Save(&t)
// Wire locale
if cfg.Account.Locale != "" {
lang := cfg.Account.Locale
acceptLang := lang + ",en;q=0.9"
if lang == "en-US" {
acceptLang = "en-US,en;q=0.9"
}
auth.SetLocale(lang, acceptLang)
chatgpt.SetDefaultLanguage(lang)
}
// Initialize components
proxyHasSession := strings.Contains(cfg.Proxy.URL, "{SESSION}")
b2proxyEnabled := cfg.Proxy.B2Proxy.Enabled
var cardCountry string
if b2proxyEnabled {
cardCountry = cfg.Card.ExpectedCountry()
}
var client *httpclient.Client
if !b2proxyEnabled && !proxyHasSession {
client, err = httpclient.NewClient(cfg.Proxy.URL)
if err != nil {
r.failTask(&t, "创建 HTTP 客户端失败: "+err.Error())
return
}
}
// Email provider
emailProv := email.NewMailGateway(
cfg.Email.MailGateway.BaseURL,
cfg.Email.MailGateway.APIKey,
cfg.Email.MailGateway.Provider,
)
// Card provider — always use DB mode
cardProv := card.NewDBCardProvider(card.DBCardProviderConfig{
DB: r.gormDB,
DefaultName: cfg.Card.API.DefaultName,
DefaultCountry: cfg.Card.API.DefaultCountry,
DefaultCurrency: cfg.Card.API.DefaultCurrency,
DefaultAddress: cfg.Card.API.DefaultAddress,
DefaultCity: cfg.Card.API.DefaultCity,
DefaultState: cfg.Card.API.DefaultState,
DefaultPostalCode: cfg.Card.API.DefaultPostalCode,
APIBaseURL: cfg.Card.API.BaseURL,
})
// Captcha solver
var solver *captcha.Solver
if cfg.Captcha.APIKey != "" {
captchaProxy := cfg.Captcha.Proxy
if captchaProxy == "" {
captchaProxy = cfg.Proxy.URL
}
solver = captcha.NewSolver(cfg.Captcha.APIKey, captchaProxy)
}
// Browser fingerprint pool
var fpPool *stripe.BrowserFingerprintPool
if cfg.Stripe.FingerprintDir != "" {
fpPool, _ = stripe.NewBrowserFingerprintPool(cfg.Stripe.FingerprintDir, "en")
}
// Stripe constants
stripe.SetFallbackConstants(cfg.Stripe.BuildHash, cfg.Stripe.TagVersion)
stripe.FetchStripeConstants()
log.Printf("[task-runner] %s: starting batch of %d", r.taskID, t.TotalCount)
// Main loop
for i := 1; i <= t.TotalCount; i++ {
if r.stopping.Load() {
break
}
if r.ctx.Err() != nil {
break
}
log.Printf("[task-runner] %s: iteration %d/%d", r.taskID, i, t.TotalCount)
// Per-iteration client (B2Proxy / session template)
iterClient := client
if b2proxyEnabled {
r.logStep(i, "", "正在连接 B2Proxy...")
iterClient = r.getB2ProxyClient(cfg, cardCountry, i, solver)
if iterClient == nil {
r.logResult(&t, i, "", LogStatusFailed, "", "B2Proxy 连接失败", 0)
continue
}
r.logStep(i, "", "B2Proxy 连接成功")
} else if proxyHasSession {
r.logStep(i, "", "正在连接代理...")
iterClient = r.getSessionClient(cfg, i, solver)
if iterClient == nil {
r.logResult(&t, i, "", LogStatusFailed, "", "代理连接失败", 0)
continue
}
r.logStep(i, "", "代理连接成功")
}
start := time.Now()
result := r.runOnce(r.ctx, i, t.Type, iterClient, emailProv, cardProv, solver, cfg, fpPool)
dur := int(time.Since(start).Seconds())
status := LogStatusSuccess
errMsg := ""
if result.Error != nil {
status = LogStatusFailed
errMsg = result.Error.Error()
}
r.logResult(&t, i, result.Email, status, result.Plan, errMsg, dur)
if i < t.TotalCount {
time.Sleep(3 * time.Second)
}
}
// Finalize task status
stopped := time.Now()
t.StoppedAt = &stopped
if r.stopping.Load() || r.ctx.Err() != nil {
t.Status = StatusStopped
} else {
t.Status = StatusCompleted
}
r.gormDB.Save(&t)
log.Printf("[task-runner] %s: finished (status=%s, success=%d, fail=%d)", r.taskID, t.Status, t.SuccessCount, t.FailCount)
}
type iterResult struct {
Email string
Plan string
Error error
}
func (r *TaskRunner) runOnce(ctx context.Context, index int, taskType string, client *httpclient.Client,
emailProv email.EmailProvider, cardProv card.CardProvider, solver *captcha.Solver,
cfg *config.Config, fpPool *stripe.BrowserFingerprintPool) iterResult {
result := iterResult{Plan: "failed"}
password := generatePassword(cfg.Account.PasswordLength)
// Step: Register
r.logStep(index, "", "正在注册账号...")
regResult, err := auth.Register(ctx, client, emailProv, password)
if err != nil {
result.Error = fmt.Errorf("注册失败: %w", err)
return result
}
result.Email = regResult.Email
r.logStep(index, regResult.Email, "注册成功: "+regResult.Email)
r.saveEmailRecord(regResult.Email, "in_use", "owner")
// Step: Login
sentinel := auth.NewSentinelGeneratorWithDeviceID(client, regResult.DeviceID)
var loginResult *auth.LoginResult
if regResult.Tokens != nil {
loginResult = regResult.Tokens
r.logStep(index, regResult.Email, "登录成功 (注册时已获取令牌)")
} else {
r.logStep(index, regResult.Email, "正在登录...")
loginResult, err = auth.Login(ctx, client, regResult.Email, password,
regResult.DeviceID, sentinel, regResult.MailboxID, emailProv)
if err != nil {
result.Error = fmt.Errorf("登录失败: %w", err)
r.updateEmailRecord(regResult.Email, "used_failed")
return result
}
r.logStep(index, regResult.Email, "登录成功")
}
session := chatgpt.NewSession(client, loginResult.AccessToken, loginResult.RefreshToken,
loginResult.IDToken, regResult.DeviceID, loginResult.ChatGPTAccountID, loginResult.ChatGPTUserID)
// Step: Activate Plus
r.logStep(index, regResult.Email, "正在开通 Plus...")
var browserFP *stripe.BrowserFingerprint
if fpPool != nil {
browserFP = fpPool.Get()
}
statusFn := func(f string, a ...interface{}) {
msg := fmt.Sprintf(f, a...)
log.Printf("[task-runner] %s #%d: %s", r.taskID, index, msg)
r.logStep(index, regResult.Email, msg)
}
plusResult, plusErr := chatgpt.ActivatePlus(ctx, session, sentinel, cardProv, cfg.Stripe, regResult.Email, solver, statusFn, browserFP)
plusSkipped := false
if plusErr != nil {
plusSkipped = true
if errors.Is(plusErr, chatgpt.ErrPlusNotEligible) || errors.Is(plusErr, chatgpt.ErrCaptchaRequired) ||
errors.Is(plusErr, stripe.ErrCardDeclined) || strings.Contains(plusErr.Error(), "challenge") {
r.logStep(index, regResult.Email, "Plus 跳过: "+plusErr.Error())
log.Printf("[task-runner] %s #%d: Plus skipped: %v", r.taskID, index, plusErr)
} else {
r.logStep(index, regResult.Email, "Plus 失败: "+plusErr.Error())
log.Printf("[task-runner] %s #%d: Plus failed: %v", r.taskID, index, plusErr)
}
}
buildAccountResult := func(fp *stripe.Fingerprint) *chatgpt.AccountResult {
return &chatgpt.AccountResult{
Email: regResult.Email, Password: password,
AccessToken: session.AccessToken, RefreshToken: session.RefreshToken,
IDToken: session.IDToken, ChatGPTAccountID: session.AccountID,
ChatGPTUserID: session.UserID, GUID: fp.GUID, MUID: fp.MUID, SID: fp.SID,
Proxy: cfg.Proxy.URL, CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
var fingerprint *stripe.Fingerprint
if !plusSkipped {
r.logStep(index, regResult.Email, "Plus 开通成功: "+plusResult.PlanType)
result.Plan = plusResult.PlanType
fingerprint = &stripe.Fingerprint{GUID: plusResult.GUID, MUID: plusResult.MUID, SID: plusResult.SID}
session.RefreshSession()
if session.AccountID == "" {
if acctInfo, acctErr := chatgpt.CheckAccount(session.Client, session.AccessToken, session.DeviceID); acctErr == nil {
session.AccountID = acctInfo.AccountID
}
}
r.logStep(index, regResult.Email, "正在获取 Plus OAuth 授权...")
plusCodexTokens, plusCodexErr := auth.ObtainCodexTokens(ctx, client, regResult.DeviceID, "")
if plusCodexErr == nil {
session.AccessToken = plusCodexTokens.AccessToken
session.RefreshToken = plusCodexTokens.RefreshToken
if plusCodexTokens.IDToken != "" {
session.IDToken = plusCodexTokens.IDToken
}
if plusCodexTokens.ChatGPTAccountID != "" {
session.AccountID = plusCodexTokens.ChatGPTAccountID
}
r.logStep(index, regResult.Email, "Plus OAuth 授权成功")
} else {
r.logStep(index, regResult.Email, "Plus OAuth 授权失败 (非致命)")
}
plusAccount := buildAccountResult(fingerprint)
plusAccount.PlanType = plusResult.PlanType
plusAccount.StripeSessionID = plusResult.StripeSessionID
storage.SavePlusAuthFile(cfg.Output.Dir, plusAccount)
r.saveAccountToDB(plusAccount, "plus", nil, r.taskID)
r.updateEmailRecord(regResult.Email, "used")
r.logStep(index, regResult.Email, "Plus 账号已保存")
} else {
sc := stripe.FetchStripeConstants()
if browserFP != nil {
fingerprint, _ = stripe.GetFingerprint(session.Client, chatgpt.GetDefaultUA(), "chatgpt.com", sc.TagVersion, browserFP)
} else {
fingerprint, _ = stripe.GetFingerprintAuto(ctx, session.Client, chatgpt.GetDefaultUA(), "chatgpt.com", sc.TagVersion)
}
if fingerprint == nil {
fingerprint = &stripe.Fingerprint{}
}
}
// Team activation
var teamResult *chatgpt.TeamResult
var teamErr error
if cfg.Team.Enabled {
r.logStep(index, regResult.Email, "正在开通 Team...")
teamStatusFn := func(f string, a ...interface{}) {
msg := fmt.Sprintf(f, a...)
log.Printf("[task-runner] %s #%d: %s", r.taskID, index, msg)
r.logStep(index, regResult.Email, msg)
}
teamResult, teamErr = chatgpt.ActivateTeam(ctx, session, sentinel, cardProv,
cfg.Stripe, cfg.Team, fingerprint, regResult.Email, solver,
emailProv, regResult.MailboxID, teamStatusFn)
if teamErr != nil {
r.logStep(index, regResult.Email, "Team 失败: "+teamErr.Error())
log.Printf("[task-runner] %s #%d: Team failed: %v", r.taskID, index, teamErr)
}
if teamResult != nil {
r.logStep(index, regResult.Email, "Team 开通成功: "+teamResult.TeamAccountID)
if result.Plan == "failed" {
result.Plan = "team"
}
savedAccessToken := session.AccessToken
r.logStep(index, regResult.Email, "正在获取 Team OAuth 授权...")
codexTokens, codexErr := auth.ObtainCodexTokens(ctx, client, regResult.DeviceID, teamResult.TeamAccountID)
var wsAccessToken string
if codexErr != nil {
wsAccessToken, _ = chatgpt.GetWorkspaceAccessToken(client, teamResult.TeamAccountID)
if wsAccessToken == "" {
wsAccessToken = savedAccessToken
}
} else {
wsAccessToken = codexTokens.AccessToken
}
teamAccount := buildAccountResult(fingerprint)
teamAccount.AccessToken = wsAccessToken
teamAccount.ChatGPTAccountID = teamResult.TeamAccountID
teamAccount.TeamAccountID = teamResult.TeamAccountID
teamAccount.WorkspaceToken = wsAccessToken
if codexTokens != nil {
teamAccount.RefreshToken = codexTokens.RefreshToken
if codexTokens.IDToken != "" {
teamAccount.IDToken = codexTokens.IDToken
}
}
teamAccount.PlanType = "team"
storage.SaveTeamAuthFile(cfg.Output.Dir, teamAccount)
parentID := r.saveAccountToDB(teamAccount, "team_owner", nil, r.taskID)
r.updateEmailRecord(regResult.Email, "used")
r.logStep(index, regResult.Email, "Team 账号已保存")
if cfg.Team.InviteCount > 0 && teamResult.TeamAccountID != "" {
r.logStep(index, regResult.Email, fmt.Sprintf("正在邀请 %d 个成员...", cfg.Team.InviteCount))
r.inviteMembers(ctx, cfg, client, emailProv, session, regResult, teamResult, fingerprint, parentID)
r.logStep(index, regResult.Email, "成员邀请完成")
}
}
}
if plusResult == nil && teamResult == nil && taskType == TaskTypePlus {
result.Plan = "free"
freeAccount := buildAccountResult(fingerprint)
freeAccount.PlanType = "free"
storage.SaveFreeAuthFile(cfg.Output.Dir, freeAccount)
r.saveAccountToDB(freeAccount, "plus", nil, r.taskID)
}
membershipState, verifyErr := r.verifyTaskMembership(ctx, taskType, session, teamAccountIDFromResult(teamResult), statusFn)
if membershipState != nil {
result.Plan = membershipState.resultPlanForTask(taskType)
}
if verifyErr != nil {
reasons := []string{verifyErr.Error()}
if plusErr != nil {
reasons = append(reasons, "plus="+plusErr.Error())
}
if teamErr != nil {
reasons = append(reasons, "team="+teamErr.Error())
}
result.Error = fmt.Errorf("最终会员校验失败: %s", strings.Join(reasons, "; "))
r.updateEmailRecord(regResult.Email, "used_failed")
return result
}
r.updateEmailRecord(regResult.Email, "used")
return result
}
func (r *TaskRunner) inviteMembers(ctx context.Context, cfg *config.Config,
client *httpclient.Client, emailProv email.EmailProvider,
session *chatgpt.Session, regResult *auth.RegisterResult,
teamResult *chatgpt.TeamResult, fingerprint *stripe.Fingerprint, parentID *uint) {
invWsToken, invWsErr := chatgpt.GetWorkspaceAccessToken(client, teamResult.TeamAccountID)
if invWsErr != nil {
invWsToken = session.AccessToken
}
if invWsToken == "" {
return
}
for invIdx := 1; invIdx <= cfg.Team.InviteCount; invIdx++ {
if r.stopping.Load() || r.ctx.Err() != nil {
break
}
r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d/%d: 创建代理客户端...", invIdx, cfg.Team.InviteCount))
// Use B2Proxy for member too, not just cfg.Proxy.URL (which may be empty in B2Proxy mode)
var memberClient *httpclient.Client
var memberClientErr error
if cfg.Proxy.B2Proxy.Enabled {
cardCountry := cfg.Card.ExpectedCountry()
for try := 1; try <= 3; try++ {
proxyURL, fetchErr := proxy.FetchB2Proxy(cfg.Proxy.B2Proxy, cardCountry)
if fetchErr != nil {
continue
}
memberClient, memberClientErr = httpclient.NewClient(proxyURL)
if memberClientErr == nil {
break
}
}
} else if strings.Contains(cfg.Proxy.URL, "{SESSION}") {
memberProxyURL := strings.ReplaceAll(cfg.Proxy.URL, "{SESSION}", fmt.Sprintf("inv%s%d", randomSessionID(), invIdx))
memberClient, memberClientErr = httpclient.NewClient(memberProxyURL)
} else {
memberClient, memberClientErr = httpclient.NewClient(cfg.Proxy.URL)
}
_ = memberClientErr
if memberClient == nil {
r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 代理连接失败,跳过", invIdx))
continue
}
r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 注册中...", invIdx))
memberPassword := auth.GenerateRandomPassword(cfg.Account.PasswordLength)
memberReg, err := auth.Register(ctx, memberClient, emailProv, memberPassword)
if err != nil {
r.logStep(0, regResult.Email, fmt.Sprintf("成员 %d: 注册失败: %v", invIdx, err))
continue
}
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 注册成功: %s", invIdx, memberReg.Email))
r.saveEmailRecord(memberReg.Email, "in_use", "member")
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 发送邀请...", invIdx))
if err := chatgpt.InviteToTeam(client, invWsToken, teamResult.TeamAccountID, session.DeviceID, memberReg.Email); err != nil {
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 邀请失败: %v", invIdx, err))
r.updateEmailRecord(memberReg.Email, "used_failed")
continue
}
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 邀请成功,等待传播...", invIdx))
time.Sleep(3 * time.Second)
var memberLoginResult *auth.LoginResult
if memberReg.Tokens != nil {
memberLoginResult = memberReg.Tokens
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 使用注册令牌", invIdx))
} else {
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录中...", invIdx))
memberSentinel := auth.NewSentinelGeneratorWithDeviceID(memberClient, memberReg.DeviceID)
memberLoginResult, err = auth.Login(ctx, memberClient, memberReg.Email, memberPassword,
memberReg.DeviceID, memberSentinel, memberReg.MailboxID, emailProv)
if err != nil {
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录失败: %v", invIdx, err))
r.updateEmailRecord(memberReg.Email, "used_failed")
continue
}
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 登录成功", invIdx))
}
var memberWsToken, memberRefreshToken, memberIDToken, memberAccountID string
memberWsToken = memberLoginResult.AccessToken
memberRefreshToken = memberLoginResult.RefreshToken
memberIDToken = memberLoginResult.IDToken
memberAccountID = memberLoginResult.ChatGPTAccountID
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 获取 Team OAuth 授权...", invIdx))
for codexTry := 1; codexTry <= 3; codexTry++ {
memberCodex, err := auth.ObtainCodexTokens(ctx, memberClient, memberReg.DeviceID, teamResult.TeamAccountID)
if err != nil {
if codexTry < 3 {
time.Sleep(3 * time.Second)
}
continue
}
if memberCodex.ChatGPTAccountID == teamResult.TeamAccountID {
memberWsToken = memberCodex.AccessToken
memberRefreshToken = memberCodex.RefreshToken
if memberCodex.IDToken != "" {
memberIDToken = memberCodex.IDToken
}
memberAccountID = memberCodex.ChatGPTAccountID
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: Team OAuth 成功", invIdx))
break
}
if codexTry < 3 {
time.Sleep(3 * time.Second)
}
}
memberAuth := &chatgpt.AccountResult{
Email: memberReg.Email, Password: memberPassword,
AccessToken: memberWsToken, RefreshToken: memberRefreshToken,
IDToken: memberIDToken, ChatGPTAccountID: memberAccountID,
ChatGPTUserID: memberLoginResult.ChatGPTUserID,
TeamAccountID: teamResult.TeamAccountID, WorkspaceToken: memberWsToken,
PlanType: "team", Proxy: cfg.Proxy.URL,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
storage.SaveTeamAuthFile(cfg.Output.Dir, memberAuth)
r.saveAccountToDB(memberAuth, "team_member", parentID, r.taskID)
r.updateEmailRecord(memberReg.Email, "used_member")
r.logStep(0, memberReg.Email, fmt.Sprintf("成员 %d: 已保存 (account=%s)", invIdx, memberAccountID))
}
}
func (r *TaskRunner) logStep(index int, email, message string) {
r.gormDB.Create(&db.TaskLog{
TaskID: r.taskID,
Index: index,
Email: email,
Status: "step",
Message: message,
})
}
func (r *TaskRunner) logResult(t *db.Task, index int, email, status, plan, errMsg string, duration int) {
message := status
if errMsg != "" {
message = errMsg
} else if plan != "" {
message = "membership verified: " + plan
}
logEntry := &db.TaskLog{
TaskID: r.taskID,
Index: index,
Email: email,
Status: status,
Plan: plan,
Error: errMsg,
Duration: duration,
Message: message,
}
r.gormDB.Create(logEntry)
t.DoneCount++
if status == LogStatusSuccess {
t.SuccessCount++
} else {
t.FailCount++
}
r.gormDB.Model(t).Updates(map[string]interface{}{
"done_count": t.DoneCount,
"success_count": t.SuccessCount,
"fail_count": t.FailCount,
})
}
func (r *TaskRunner) failTask(t *db.Task, errMsg string) {
now := time.Now()
t.Status = StatusStopped
t.StoppedAt = &now
r.gormDB.Save(t)
log.Printf("[task-runner] %s: task failed: %s", r.taskID, errMsg)
}
func (r *TaskRunner) saveEmailRecord(email, status, role string) {
rec := &db.EmailRecord{
Email: email,
Status: status,
UsedForRole: role,
TaskID: r.taskID,
}
r.gormDB.Create(rec)
}
func (r *TaskRunner) updateEmailRecord(email, status string) {
r.gormDB.Model(&db.EmailRecord{}).Where("email = ?", email).Update("status", status)
}
func teamAccountIDFromResult(result *chatgpt.TeamResult) string {
if result == nil {
return ""
}
return result.TeamAccountID
}
func accountStatusForResult(result *chatgpt.AccountResult) string {
if result == nil {
return "active"
}
switch result.PlanType {
case "free", "plus", "team":
return result.PlanType
default:
return "active"
}
}
func (r *TaskRunner) saveAccountToDB(result *chatgpt.AccountResult, plan string, parentID *uint, taskID string) *uint {
// Check if account already exists (same email)
var existing db.Account
if r.gormDB.Where("email = ?", result.Email).First(&existing).Error == nil {
// Update existing record (e.g. Plus account now also has Team)
updates := map[string]interface{}{
"plan": plan,
"access_token": result.AccessToken,
"refresh_token": result.RefreshToken,
"id_token": result.IDToken,
"account_id": result.ChatGPTAccountID,
"user_id": result.ChatGPTUserID,
"team_workspace_id": result.TeamAccountID,
"workspace_token": result.WorkspaceToken,
"status": accountStatusForResult(result),
}
if parentID != nil {
updates["parent_id"] = *parentID
}
r.gormDB.Model(&existing).Updates(updates)
return &existing.ID
}
acct := &db.Account{
TaskID: taskID,
Email: result.Email,
Password: result.Password,
Plan: plan,
ParentID: parentID,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
IDToken: result.IDToken,
AccountID: result.ChatGPTAccountID,
UserID: result.ChatGPTUserID,
TeamWorkspaceID: result.TeamAccountID,
WorkspaceToken: result.WorkspaceToken,
Status: accountStatusForResult(result),
}
r.gormDB.Create(acct)
return &acct.ID
}
func (r *TaskRunner) getB2ProxyClient(cfg *config.Config, cardCountry string, index int, solver *captcha.Solver) *httpclient.Client {
for try := 1; try <= 5; try++ {
proxyURL, err := proxy.FetchB2Proxy(cfg.Proxy.B2Proxy, cardCountry)
if err != nil {
time.Sleep(time.Second)
continue
}
c, err := httpclient.NewClient(proxyURL)
if err != nil {
continue
}
if testStripeConnectivity(c) {
if solver != nil {
solver.SetProxy(proxyURL)
}
return c
}
}
return nil
}
func (r *TaskRunner) getSessionClient(cfg *config.Config, index int, solver *captcha.Solver) *httpclient.Client {
for try := 1; try <= 5; try++ {
sessionID := randomSessionID()
proxyURL := strings.ReplaceAll(cfg.Proxy.URL, "{SESSION}", sessionID)
c, err := httpclient.NewClient(proxyURL)
if err != nil {
continue
}
if testStripeConnectivity(c) {
if solver != nil {
solver.SetProxy(proxyURL)
}
return c
}
}
return nil
}
func testStripeConnectivity(client *httpclient.Client) bool {
req, err := http.NewRequest("GET", "https://m.stripe.com/6", nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := client.Do(req)
if err != nil {
return false
}
resp.Body.Close()
req2, _ := http.NewRequest("GET", "https://api.stripe.com", nil)
req2.Header.Set("User-Agent", "Mozilla/5.0")
resp2, err := client.Do(req2)
if err != nil {
return false
}
resp2.Body.Close()
return true
}
func generatePassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%"
b := make([]byte, length)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
b[i] = charset[i%len(charset)]
continue
}
b[i] = charset[n.Int64()]
}
return string(b)
}
func randomSessionID() string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
b[i] = chars[n.Int64()]
}
return string(b)
}

18
internal/task/types.go Normal file
View File

@@ -0,0 +1,18 @@
package task
const (
StatusPending = "pending"
StatusRunning = "running"
StatusStopping = "stopping"
StatusStopped = "stopped"
StatusInterrupted = "interrupted"
StatusCompleted = "completed"
LogStatusSuccess = "success"
LogStatusFailed = "failed"
LogStatusSkipped = "skipped"
TaskTypePlus = "plus"
TaskTypeTeam = "team"
TaskTypeBoth = "both"
)