| package admin |
|
|
| 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" |
|
|
| "github.com/gin-gonic/gin" |
| ) |
|
|
| |
| type RedeemHandler struct { |
| adminService service.AdminService |
| redeemService *service.RedeemService |
| } |
|
|
| |
| func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler { |
| return &RedeemHandler{ |
| adminService: adminService, |
| redeemService: redeemService, |
| } |
| } |
|
|
| |
| 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" binding:"min=0"` |
| GroupID *int64 `json:"group_id"` |
| ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` |
| } |
|
|
| |
| |
| type CreateAndRedeemCodeRequest struct { |
| Code string `json:"code" binding:"required,min=3,max=128"` |
| Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` |
| Value float64 `json:"value" binding:"required,gt=0"` |
| UserID int64 `json:"user_id" binding:"required,gt=0"` |
| GroupID *int64 `json:"group_id"` |
| ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` |
| Notes string `json:"notes"` |
| } |
|
|
| |
| |
| func (h *RedeemHandler) List(c *gin.Context) { |
| page, pageSize := response.ParsePagination(c) |
| codeType := c.Query("type") |
| status := c.Query("status") |
| search := c.Query("search") |
| |
| search = strings.TrimSpace(search) |
| if len(search) > 100 { |
| search = search[:100] |
| } |
|
|
| codes, total, err := h.adminService.ListRedeemCodes(c.Request.Context(), page, pageSize, codeType, status, search) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| out := make([]dto.AdminRedeemCode, 0, len(codes)) |
| for i := range codes { |
| out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i])) |
| } |
| response.Paginated(c, out, total, page, pageSize) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) GetByID(c *gin.Context) { |
| codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) |
| if err != nil { |
| response.BadRequest(c, "Invalid redeem code ID") |
| return |
| } |
|
|
| code, err := h.adminService.GetRedeemCode(c.Request.Context(), codeID) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, dto.RedeemCodeFromServiceAdmin(code)) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) Generate(c *gin.Context) { |
| var req GenerateRedeemCodesRequest |
| if err := c.ShouldBindJSON(&req); err != nil { |
| response.BadRequest(c, "Invalid request: "+err.Error()) |
| 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, |
| Type: req.Type, |
| Value: req.Value, |
| GroupID: req.GroupID, |
| ValidityDays: req.ValidityDays, |
| }) |
| if execErr != nil { |
| return nil, execErr |
| } |
|
|
| out := make([]dto.AdminRedeemCode, 0, len(codes)) |
| for i := range codes { |
| out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i])) |
| } |
| return out, nil |
| }) |
| } |
|
|
| |
| |
| 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) |
| |
| |
| 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) |
| 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, |
| GroupID: req.GroupID, |
| ValidityDays: req.ValidityDays, |
| }) |
| if createErr != nil { |
| |
| 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 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") |
| } |
|
|
| |
| |
| func (h *RedeemHandler) Delete(c *gin.Context) { |
| codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) |
| if err != nil { |
| response.BadRequest(c, "Invalid redeem code ID") |
| return |
| } |
|
|
| err = h.adminService.DeleteRedeemCode(c.Request.Context(), codeID) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, gin.H{"message": "Redeem code deleted successfully"}) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) BatchDelete(c *gin.Context) { |
| var req struct { |
| IDs []int64 `json:"ids" binding:"required,min=1"` |
| } |
| if err := c.ShouldBindJSON(&req); err != nil { |
| response.BadRequest(c, "Invalid request: "+err.Error()) |
| return |
| } |
|
|
| deleted, err := h.adminService.BatchDeleteRedeemCodes(c.Request.Context(), req.IDs) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, gin.H{ |
| "deleted": deleted, |
| "message": "Redeem codes deleted successfully", |
| }) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) Expire(c *gin.Context) { |
| codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) |
| if err != nil { |
| response.BadRequest(c, "Invalid redeem code ID") |
| return |
| } |
|
|
| code, err := h.adminService.ExpireRedeemCode(c.Request.Context(), codeID) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, dto.RedeemCodeFromServiceAdmin(code)) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) GetStats(c *gin.Context) { |
| |
| response.Success(c, gin.H{ |
| "total_codes": 0, |
| "active_codes": 0, |
| "used_codes": 0, |
| "expired_codes": 0, |
| "total_value_distributed": 0.0, |
| "by_type": gin.H{ |
| "balance": 0, |
| "concurrency": 0, |
| "trial": 0, |
| }, |
| }) |
| } |
|
|
| |
| |
| func (h *RedeemHandler) Export(c *gin.Context) { |
| codeType := c.Query("type") |
| status := c.Query("status") |
|
|
| |
| codes, _, err := h.adminService.ListRedeemCodes(c.Request.Context(), 1, 10000, codeType, status, "") |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| |
| var buf bytes.Buffer |
| writer := csv.NewWriter(&buf) |
|
|
| |
| if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil { |
| response.InternalError(c, "Failed to export redeem codes: "+err.Error()) |
| return |
| } |
|
|
| |
| for _, code := range codes { |
| usedBy := "" |
| if code.UsedBy != nil { |
| usedBy = fmt.Sprintf("%d", *code.UsedBy) |
| } |
| usedByEmail := "" |
| if code.User != nil { |
| usedByEmail = code.User.Email |
| } |
| usedAt := "" |
| if code.UsedAt != nil { |
| usedAt = code.UsedAt.Format("2006-01-02 15:04:05") |
| } |
| if err := writer.Write([]string{ |
| fmt.Sprintf("%d", code.ID), |
| code.Code, |
| code.Type, |
| fmt.Sprintf("%.2f", code.Value), |
| code.Status, |
| usedBy, |
| usedByEmail, |
| usedAt, |
| code.CreatedAt.Format("2006-01-02 15:04:05"), |
| }); err != nil { |
| response.InternalError(c, "Failed to export redeem codes: "+err.Error()) |
| return |
| } |
| } |
|
|
| writer.Flush() |
| if err := writer.Error(); err != nil { |
| response.InternalError(c, "Failed to export redeem codes: "+err.Error()) |
| return |
| } |
|
|
| c.Header("Content-Type", "text/csv") |
| c.Header("Content-Disposition", "attachment; filename=redeem_codes.csv") |
| c.Data(200, "text/csv", buf.Bytes()) |
| } |
|
|