mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user