Merge pull request #685 from touwaeriol/pr/admin-create-and-redeem-docs

feat(admin): add create-and-redeem endpoint for payment integrations
This commit is contained in:
Wesley Liddick
2026-03-01 18:24:15 +08:00
committed by GitHub
12 changed files with 534 additions and 61 deletions

View File

@@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
opsRepository := repository.NewOpsRepository(db)
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)

View File

@@ -19,7 +19,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
userHandler := NewUserHandler(adminSvc, nil)
groupHandler := NewGroupHandler(adminSvc)
proxyHandler := NewProxyHandler(adminSvc)
redeemHandler := NewRedeemHandler(adminSvc)
redeemHandler := NewRedeemHandler(adminSvc, nil)
router.GET("/api/v1/admin/users", userHandler.List)
router.GET("/api/v1/admin/users/:id", userHandler.GetByID)

View File

@@ -4,11 +4,13 @@ import (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -17,13 +19,15 @@ import (
// RedeemHandler handles admin redeem code management
type RedeemHandler struct {
adminService service.AdminService
adminService service.AdminService
redeemService *service.RedeemService
}
// NewRedeemHandler creates a new admin redeem handler
func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler {
return &RedeemHandler{
adminService: adminService,
adminService: adminService,
redeemService: redeemService,
}
}
@@ -36,6 +40,15 @@ type GenerateRedeemCodesRequest struct {
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用默认30天最大100年
}
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
type CreateAndRedeemCodeRequest struct {
Code string `json:"code" binding:"required,min=3,max=128"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
Value float64 `json:"value" binding:"required,gt=0"`
UserID int64 `json:"user_id" binding:"required,gt=0"`
Notes string `json:"notes"`
}
// List handles listing all redeem codes with pagination
// GET /api/v1/admin/redeem-codes
func (h *RedeemHandler) List(c *gin.Context) {
@@ -109,6 +122,81 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
})
}
// CreateAndRedeem creates a fixed redeem code and redeems it for a target user in one step.
// POST /api/v1/admin/redeem-codes/create-and-redeem
func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
if h.redeemService == nil {
response.InternalError(c, "redeem service not configured")
return
}
var req CreateAndRedeemCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
req.Code = strings.TrimSpace(req.Code)
executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
existing, err := h.redeemService.GetByCode(ctx, req.Code)
if err == nil {
return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID)
}
if !errors.Is(err, service.ErrRedeemCodeNotFound) {
return nil, err
}
createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{
Code: req.Code,
Type: req.Type,
Value: req.Value,
Status: service.StatusUnused,
Notes: req.Notes,
})
if createErr != nil {
// Unique code race: if code now exists, use idempotent semantics by used_by.
existingAfterCreateErr, getErr := h.redeemService.GetByCode(ctx, req.Code)
if getErr == nil {
return h.resolveCreateAndRedeemExisting(ctx, existingAfterCreateErr, req.UserID)
}
return nil, createErr
}
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
if redeemErr != nil {
return nil, redeemErr
}
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
})
}
func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, existing *service.RedeemCode, userID int64) (any, error) {
if existing == nil {
return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code conflict")
}
// If previous run created the code but crashed before redeem, redeem it now.
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
if err == nil {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
}
if !errors.Is(err, service.ErrRedeemCodeUsed) {
return nil, err
}
latest, getErr := h.redeemService.GetByCode(ctx, existing.Code)
if getErr == nil {
existing = latest
}
}
if existing.UsedBy != nil && *existing.UsedBy == userID {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(existing)}, nil
}
return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code already used by another user")
}
// Delete handles deleting a redeem code
// DELETE /api/v1/admin/redeem-codes/:id
func (h *RedeemHandler) Delete(c *gin.Context) {

View File

@@ -351,6 +351,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
codes.GET("/stats", h.Admin.Redeem.GetStats)
codes.GET("/export", h.Admin.Redeem.Export)
codes.GET("/:id", h.Admin.Redeem.GetByID)
codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem)
codes.POST("/generate", h.Admin.Redeem.Generate)
codes.DELETE("/:id", h.Admin.Redeem.Delete)
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)

View File

@@ -174,6 +174,33 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
return codes, nil
}
// CreateCode creates a redeem code with caller-provided code value.
// It is primarily used by admin integrations that require an external order ID
// to be mapped to a deterministic redeem code.
func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error {
if code == nil {
return errors.New("redeem code is required")
}
code.Code = strings.TrimSpace(code.Code)
if code.Code == "" {
return errors.New("code is required")
}
if code.Type == "" {
code.Type = RedeemTypeBalance
}
if code.Type != RedeemTypeInvitation && code.Value <= 0 {
return errors.New("value must be greater than 0")
}
if code.Status == "" {
code.Status = StatusUnused
}
if err := s.redeemRepo.Create(ctx, code); err != nil {
return fmt.Errorf("create redeem code: %w", err)
}
return nil
}
// checkRedeemRateLimit 检查用户兑换错误次数是否超限
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
if s.cache == nil {