feat(ECR-015): RedemptionCode 兑换码并 Closed

批次生成/作废、C 端兑码延长会员;admin-h5 /codes。
Loop continuous。Next:ECR-016 UserIntelligence。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 18:16:13 +08:00
co-authored by Cursor
parent 0e26aabef8
commit 1eeb0b00e7
33 changed files with 1073 additions and 36 deletions
+1
View File
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerRBAC(authed)
h.registerLifecycle(authed)
h.registerMembershipPlans(authed)
h.registerRedemption(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -0,0 +1,99 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerRedemption(authed *gin.RouterGroup) {
authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch)
authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches)
authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes)
authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode)
}
func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
var body struct {
Label string `json:"label"`
PlanCode string `json:"plan_code"`
Quantity int `json:"quantity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
return
}
batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity)
if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || errors.Is(err, admin.ErrInvalidPlanU) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"batch": batch, "codes": codes})
}
func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
return
}
items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id)
if errors.Is(err, admin.ErrBatchNotFound) {
response.Fail(c, http.StatusNotFound, 40400, "batch not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
return
}
err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id)
if errors.Is(err, admin.ErrCodeDisable) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"ok": true})
}
+32
View File
@@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.GET("/reports/latest", h.GetLatest)
rg.GET("/reports/:id", h.Get)
rg.GET("/membership/me", h.GetMembership)
rg.POST("/membership/redeem", h.RedeemCode)
rg.POST("/orders", h.CreateOrder)
rg.POST("/orders/:id/pay-mock", h.PayMock)
}
@@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
response.OK(c, me)
}
// RedeemCode handles POST /membership/redeem.
func (h *ReportHandler) RedeemCode(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
msvc, ok := h.requireMembership(c)
if !ok {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
response.Fail(c, http.StatusBadRequest, 40000, "code required")
return
}
plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code)
if err != nil {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
me, err := msvc.Get(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"plan": plan, "membership": me})
}
// CreateOrder handles POST /orders.
func (h *ReportHandler) CreateOrder(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)