diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 0a932ee9..13ea88d9 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -41,12 +41,15 @@ type GenerateRedeemCodesRequest struct { } // 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:"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"` + 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,gt=0"` + UserID int64 `json:"user_id" binding:"required,gt=0"` + GroupID *int64 `json:"group_id"` // subscription 类型必填 + ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // subscription 类型必填,>0 + Notes string `json:"notes"` } // List handles listing all redeem codes with pagination @@ -136,6 +139,22 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { return } req.Code = strings.TrimSpace(req.Code) + // 向后兼容:旧版调用方(如 Sub2ApiPay)不传 type 字段,默认当作 balance 充值处理。 + // 请勿删除此默认值逻辑,否则会导致旧版调用方 400 报错。 + if req.Type == "" { + req.Type = "balance" + } + + if req.Type == "subscription" { + if req.GroupID == nil { + response.BadRequest(c, "group_id is required for subscription type") + return + } + if req.ValidityDays <= 0 { + response.BadRequest(c, "validity_days must be greater than 0 for subscription type") + 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) @@ -147,11 +166,13 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { } createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{ - Code: req.Code, - Type: req.Type, - Value: req.Value, - Status: service.StatusUnused, - Notes: req.Notes, + Code: req.Code, + Type: req.Type, + Value: req.Value, + Status: service.StatusUnused, + Notes: req.Notes, + GroupID: req.GroupID, + ValidityDays: req.ValidityDays, }) if createErr != nil { // Unique code race: if code now exists, use idempotent semantics by used_by. diff --git a/backend/internal/handler/admin/redeem_handler_test.go b/backend/internal/handler/admin/redeem_handler_test.go new file mode 100644 index 00000000..0d42f64f --- /dev/null +++ b/backend/internal/handler/admin/redeem_handler_test.go @@ -0,0 +1,135 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCreateAndRedeemHandler creates a RedeemHandler with a non-nil (but minimal) +// RedeemService so that CreateAndRedeem's nil guard passes and we can test the +// parameter-validation layer that runs before any service call. +func newCreateAndRedeemHandler() *RedeemHandler { + return &RedeemHandler{ + adminService: newStubAdminService(), + redeemService: &service.RedeemService{}, // non-nil to pass nil guard + } +} + +// postCreateAndRedeemValidation calls CreateAndRedeem and returns the response +// status code. For cases that pass validation and proceed into the service layer, +// a panic may occur (because RedeemService internals are nil); this is expected +// and treated as "validation passed" (returns 0 to indicate panic). +func postCreateAndRedeemValidation(t *testing.T, handler *RedeemHandler, body any) (code int) { + t.Helper() + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + jsonBytes, err := json.Marshal(body) + require.NoError(t, err) + c.Request, _ = http.NewRequest(http.MethodPost, "/api/v1/admin/redeem-codes/create-and-redeem", bytes.NewReader(jsonBytes)) + c.Request.Header.Set("Content-Type", "application/json") + + defer func() { + if r := recover(); r != nil { + // Panic means we passed validation and entered service layer (expected for minimal stub). + code = 0 + } + }() + handler.CreateAndRedeem(c) + return w.Code +} + +func TestCreateAndRedeem_TypeDefaultsToBalance(t *testing.T) { + // 不传 type 字段时应默认 balance,不触发 subscription 校验。 + // 验证通过后进入 service 层会 panic(返回 0),说明默认值生效。 + h := newCreateAndRedeemHandler() + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-balance-default", + "value": 10.0, + "user_id": 1, + }) + + assert.NotEqual(t, http.StatusBadRequest, code, + "omitting type should default to balance and pass validation") +} + +func TestCreateAndRedeem_SubscriptionRequiresGroupID(t *testing.T) { + h := newCreateAndRedeemHandler() + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-sub-no-group", + "type": "subscription", + "value": 29.9, + "user_id": 1, + "validity_days": 30, + // group_id 缺失 + }) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestCreateAndRedeem_SubscriptionRequiresPositiveValidityDays(t *testing.T) { + groupID := int64(5) + h := newCreateAndRedeemHandler() + + cases := []struct { + name string + validityDays int + }{ + {"zero", 0}, + {"negative", -1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-sub-bad-days-" + tc.name, + "type": "subscription", + "value": 29.9, + "user_id": 1, + "group_id": groupID, + "validity_days": tc.validityDays, + }) + + assert.Equal(t, http.StatusBadRequest, code) + }) + } +} + +func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) { + groupID := int64(5) + h := newCreateAndRedeemHandler() + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-sub-valid", + "type": "subscription", + "value": 29.9, + "user_id": 1, + "group_id": groupID, + "validity_days": 31, + }) + + assert.NotEqual(t, http.StatusBadRequest, code, + "valid subscription params should pass validation") +} + +func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) { + h := newCreateAndRedeemHandler() + // balance 类型不传 group_id 和 validity_days,不应报 400 + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-balance-no-extras", + "type": "balance", + "value": 50.0, + "user_id": 1, + }) + + assert.NotEqual(t, http.StatusBadRequest, code, + "balance type should not require group_id or validity_days") +}