Initial sanitized code sync
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user