Initial sanitized code sync
This commit is contained in:
78
internal/db/crypto.go
Normal file
78
internal/db/crypto.go
Normal 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
101
internal/db/crypto_test.go
Normal 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
54
internal/db/db.go
Normal 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
123
internal/db/models.go
Normal 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
144
internal/db/query.go
Normal 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
128
internal/db/query_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
internal/db/testhelper_test.go
Normal file
28
internal/db/testhelper_test.go
Normal 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
171
internal/handler/account.go
Normal 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
61
internal/handler/auth.go
Normal 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
324
internal/handler/card.go
Normal 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
154
internal/handler/config.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
18
internal/handler/dashboard.go
Normal file
18
internal/handler/dashboard.go
Normal 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)
|
||||
}
|
||||
40
internal/handler/email_record.go
Normal file
40
internal/handler/email_record.go
Normal 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()))
|
||||
}
|
||||
518
internal/handler/handler_test.go
Normal file
518
internal/handler/handler_test.go
Normal 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)
|
||||
}
|
||||
84
internal/handler/middleware.go
Normal file
84
internal/handler/middleware.go
Normal 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"
|
||||
}
|
||||
42
internal/handler/router.go
Normal file
42
internal/handler/router.go
Normal 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
201
internal/handler/task.go
Normal 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
|
||||
418
internal/service/account_svc.go
Normal file
418
internal/service/account_svc.go
Normal 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
125
internal/service/cpa_svc.go
Normal 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
|
||||
}
|
||||
121
internal/service/export_svc.go
Normal file
121
internal/service/export_svc.go
Normal 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
|
||||
}
|
||||
168
internal/service/export_test.go
Normal file
168
internal/service/export_test.go
Normal 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
97
internal/task/manager.go
Normal 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()
|
||||
}
|
||||
144
internal/task/manager_test.go
Normal file
144
internal/task/manager_test.go
Normal 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
229
internal/task/membership.go
Normal 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
|
||||
}
|
||||
}
|
||||
125
internal/task/membership_test.go
Normal file
125
internal/task/membership_test.go
Normal 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
793
internal/task/runner.go
Normal 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
18
internal/task/types.go
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user