feat(redeem): 兑换码支持设置使用有效期

This commit is contained in:
wucm667
2026-05-19 15:53:28 +08:00
parent b006e36af9
commit e4aaf0af29
27 changed files with 676 additions and 38 deletions
@@ -8,6 +8,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -33,23 +34,51 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service.
// GenerateRedeemCodesRequest represents generate redeem codes request
type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
Value float64 `json:"value"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
Count int `json:"count" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
Value float64 `json:"value"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
ExpiresAt *time.Time `json:"expires_at"`
ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
}
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
// Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。
type CreateAndRedeemCodeRequest struct {
Code string `json:"code" binding:"required,min=3,max=128"`
Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容)
Value float64 `json:"value" binding:"required"`
UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"`
Code string `json:"code" binding:"required,min=3,max=128"`
Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容)
Value float64 `json:"value" binding:"required"`
UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"`
ExpiresAt *time.Time `json:"expires_at"`
ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
}
func resolveRedeemCodeExpiresAt(expiresAt *time.Time, expiresInDays *int) (*time.Time, error) {
if expiresAt != nil && expiresInDays != nil {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRY_CONFLICT", "expires_at and expires_in_days cannot both be set")
}
now := time.Now().UTC()
if expiresInDays != nil {
if *expiresInDays <= 0 {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_IN_DAYS_INVALID", "expires_in_days must be greater than zero")
}
expires := now.AddDate(0, 0, *expiresInDays)
return &expires, nil
}
if expiresAt == nil {
return nil, nil
}
expires := expiresAt.UTC()
if !expires.After(now) {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future")
}
return &expires, nil
}
// List handles listing all redeem codes with pagination
@@ -107,6 +136,12 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
if err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{
Count: req.Count,
@@ -114,6 +149,7 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
Value: req.Value,
GroupID: req.GroupID,
ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
})
if execErr != nil {
return nil, execErr
@@ -158,6 +194,12 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
}
}
expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
if err != nil {
response.ErrorFrom(c, err)
return
}
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 {
@@ -175,6 +217,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
Notes: req.Notes,
GroupID: req.GroupID,
ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
})
if createErr != nil {
// Unique code race: if code now exists, use idempotent semantics by used_by.
@@ -199,6 +242,9 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
}
// If previous run created the code but crashed before redeem, redeem it now.
if existing.IsExpired() {
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
if err == nil {
@@ -321,7 +367,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
writer := csv.NewWriter(&buf)
// Write header
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil {
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "expires_at", "created_at"}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
@@ -340,6 +386,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
if code.UsedAt != nil {
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
}
expiresAt := ""
if code.ExpiresAt != nil {
expiresAt = code.ExpiresAt.Format("2006-01-02 15:04:05")
}
if err := writer.Write([]string{
fmt.Sprintf("%d", code.ID),
code.Code,
@@ -349,6 +399,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
usedBy,
usedByEmail,
usedAt,
expiresAt,
code.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -139,3 +140,33 @@ func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) {
assert.NotEqual(t, http.StatusBadRequest, code,
"balance type should not require group_id or validity_days")
}
func TestResolveRedeemCodeExpiresAt_FromDays(t *testing.T) {
days := 3
expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
require.NoError(t, err)
require.NotNil(t, expiresAt)
require.WithinDuration(t, time.Now().UTC().AddDate(0, 0, days), *expiresAt, 2*time.Second)
}
func TestResolveRedeemCodeExpiresAt_RejectsPastAbsoluteTime(t *testing.T) {
past := time.Now().UTC().Add(-time.Minute)
expiresAt, err := resolveRedeemCodeExpiresAt(&past, nil)
require.Error(t, err)
require.Nil(t, expiresAt)
}
func TestResolveRedeemCodeExpiresAt_RejectsNonPositiveDays(t *testing.T) {
days := 0
expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
require.Error(t, err)
require.Nil(t, expiresAt)
}
func TestResolveRedeemCodeExpiresAt_RejectsConflictingInputs(t *testing.T) {
future := time.Now().UTC().Add(time.Hour)
days := 3
expiresAt, err := resolveRedeemCodeExpiresAt(&future, &days)
require.Error(t, err)
require.Nil(t, expiresAt)
}