From 386877da9a7698ba8280f10bd6e515b155c3ff84 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 22:44:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eadmin=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=B8=8E=E4=BC=9A=E5=91=98=E7=AE=A1=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/admin/handler/membership_handler.go | 120 +++++++++ internal/admin/handler/user_handler.go | 57 ++++ internal/admin/service/membership_service.go | 269 +++++++++++++++++++ internal/admin/service/types.go | 16 +- internal/admin/service/user_service.go | 228 ++++++++++++++++ internal/routes/admin_routes.go | 8 + 6 files changed, 691 insertions(+), 7 deletions(-) create mode 100644 internal/admin/handler/membership_handler.go create mode 100644 internal/admin/handler/user_handler.go create mode 100644 internal/admin/service/membership_service.go create mode 100644 internal/admin/service/user_service.go diff --git a/internal/admin/handler/membership_handler.go b/internal/admin/handler/membership_handler.go new file mode 100644 index 0000000..dc9f7d3 --- /dev/null +++ b/internal/admin/handler/membership_handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" +) + +func (h *Handler) MembershipOverview(c *gin.Context) { + data, err := h.svc.MembershipOverview(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load membership overview failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) ListMembershipRedeemCodes(c *gin.Context) { + page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1"))) + pageSize, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page_size", "20"))) + + data, err := h.svc.ListMembershipRedeemCodes(c.Request.Context(), adminservice.ListMembershipRedeemCodesQuery{ + Page: page, + PageSize: pageSize, + Keyword: c.Query("keyword"), + Status: c.Query("status"), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load redeem codes failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +type createMembershipRedeemCodesRequest struct { + PackageType string `json:"package_type"` + DurationDays int `json:"duration_days"` + Quantity int `json:"quantity"` + MaxUses int `json:"max_uses"` + ExpiresAt string `json:"expires_at"` +} + +func (h *Handler) CreateMembershipRedeemCodes(c *gin.Context) { + var req createMembershipRedeemCodesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + var expiresAt *time.Time + if strings.TrimSpace(req.ExpiresAt) != "" { + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", req.ExpiresAt, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "expires_at format should be YYYY-MM-DD HH:mm:ss")) + return + } + expiresAt = &parsed + } + + codes, err := h.svc.CreateMembershipRedeemCodes(c.Request.Context(), adminservice.CreateMembershipRedeemCodesInput{ + PackageType: req.PackageType, + DurationDays: req.DurationDays, + Quantity: req.Quantity, + MaxUses: req.MaxUses, + ExpiresAt: expiresAt, + }) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid input")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create redeem codes failed")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "count": len(codes), + "codes": codes, + })) +} + +type updateMembershipRedeemCodeStatusRequest struct { + Status string `json:"status"` +} + +func (h *Handler) UpdateMembershipRedeemCodeStatus(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid redeem code id")) + return + } + + var req updateMembershipRedeemCodeStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + err = h.svc.UpdateMembershipRedeemCodeStatus(c.Request.Context(), id, req.Status) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrMembershipRedeemCodeNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "redeem code not found")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "status must be active/disabled")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update redeem code status failed")) + } + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{"message": "更新成功"})) +} diff --git a/internal/admin/handler/user_handler.go b/internal/admin/handler/user_handler.go new file mode 100644 index 0000000..4c53f96 --- /dev/null +++ b/internal/admin/handler/user_handler.go @@ -0,0 +1,57 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" +) + +func (h *Handler) ListUsers(c *gin.Context) { + page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1"))) + pageSize, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page_size", "20"))) + miniProgramID, _ := strconv.ParseUint(strings.TrimSpace(c.DefaultQuery("mini_program_id", "0")), 10, 64) + + var isMember *bool + if raw := strings.TrimSpace(c.Query("is_member")); raw != "" { + value := raw == "1" || strings.EqualFold(raw, "true") + isMember = &value + } + + data, err := h.svc.ListUsers(c.Request.Context(), adminservice.ListUsersQuery{ + Page: page, + PageSize: pageSize, + MiniProgramID: uint(miniProgramID), + Keyword: c.Query("keyword"), + IsMember: isMember, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load users failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetUserDetail(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid user id")) + return + } + + data, err := h.svc.GetUserDetail(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrAdminUserNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "user not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load user detail failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} diff --git a/internal/admin/service/membership_service.go b/internal/admin/service/membership_service.go new file mode 100644 index 0000000..d24f5cc --- /dev/null +++ b/internal/admin/service/membership_service.go @@ -0,0 +1,269 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + membershipmodel "wx_service/internal/membership/model" + "wx_service/internal/model" + + "gorm.io/gorm" +) + +type MembershipOverview struct { + TotalMembers int64 `json:"total_members"` + ActiveMembers int64 `json:"active_members"` + ExpiringSoon int64 `json:"expiring_soon"` + TodayRedeemed int64 `json:"today_redeemed"` +} + +type ListMembershipRedeemCodesQuery struct { + Page int + PageSize int + Keyword string + Status string +} + +type MembershipRedeemCodeItem struct { + ID uint `json:"id"` + Code string `json:"code"` + CodeSuffix string `json:"code_suffix"` + PackageType string `json:"package_type"` + DurationDays int `json:"duration_days"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ListMembershipRedeemCodesResult struct { + List []MembershipRedeemCodeItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type CreateMembershipRedeemCodesInput struct { + PackageType string + DurationDays int + Quantity int + MaxUses int + ExpiresAt *time.Time +} + +func (s *Service) MembershipOverview(ctx context.Context) (*MembershipOverview, error) { + result := &MembershipOverview{} + now := time.Now() + + if err := s.db.WithContext(ctx).Model(&model.UserMembership{}).Count(&result.TotalMembers).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx). + Model(&model.UserMembership{}). + Where("status = ?", "active"). + Where("ends_at > ?", now). + Count(&result.ActiveMembers).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx). + Model(&model.UserMembership{}). + Where("status = ?", "active"). + Where("ends_at > ?", now). + Where("ends_at <= ?", now.AddDate(0, 0, 7)). + Count(&result.ExpiringSoon).Error; err != nil { + return nil, err + } + + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + if err := s.db.WithContext(ctx). + Model(&membershipmodel.MembershipRedemption{}). + Where("created_at >= ?", todayStart). + Count(&result.TodayRedeemed).Error; err != nil { + return nil, err + } + + return result, nil +} + +func (s *Service) ListMembershipRedeemCodes(ctx context.Context, query ListMembershipRedeemCodesQuery) (*ListMembershipRedeemCodesResult, error) { + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + query.Keyword = strings.TrimSpace(strings.ToUpper(query.Keyword)) + query.Status = strings.TrimSpace(strings.ToLower(query.Status)) + + dbQuery := s.db.WithContext(ctx).Model(&membershipmodel.MembershipRedeemCode{}) + if query.Keyword != "" { + dbQuery = dbQuery.Where("code_suffix LIKE ?", "%"+query.Keyword+"%") + } + if query.Status != "" { + dbQuery = dbQuery.Where("status = ?", query.Status) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + var rows []membershipmodel.MembershipRedeemCode + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + result := make([]MembershipRedeemCodeItem, 0, len(rows)) + for _, item := range rows { + status := item.Status + if item.ExpiresAt != nil && item.ExpiresAt.Before(time.Now()) { + status = "expired" + } + result = append(result, MembershipRedeemCodeItem{ + ID: item.ID, + Code: "******" + item.CodeSuffix, + CodeSuffix: item.CodeSuffix, + PackageType: item.Plan, + DurationDays: item.DurationDays, + MaxUses: item.MaxUses, + UsedCount: item.UsedUses, + Status: status, + ExpiresAt: item.ExpiresAt, + CreatedAt: item.CreatedAt, + }) + } + + return &ListMembershipRedeemCodesResult{ + List: result, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) CreateMembershipRedeemCodes(ctx context.Context, input CreateMembershipRedeemCodesInput) ([]string, error) { + packageType := strings.TrimSpace(input.PackageType) + if packageType == "" { + packageType = "month" + } + if input.DurationDays <= 0 { + return nil, ErrInvalidInput + } + if input.Quantity <= 0 { + input.Quantity = 1 + } + if input.Quantity > 500 { + input.Quantity = 500 + } + if input.MaxUses <= 0 { + input.MaxUses = 1 + } + + codes := make([]string, 0, input.Quantity) + records := make([]membershipmodel.MembershipRedeemCode, 0, input.Quantity) + for len(codes) < input.Quantity { + code, err := generateAdminRedeemCode(20) + if err != nil { + return nil, err + } + hash := hashAdminRedeemCode(code) + suffix := suffixAdminRedeemCode(code, 6) + + records = append(records, membershipmodel.MembershipRedeemCode{ + CodeHash: hash, + CodeSuffix: suffix, + Plan: packageType, + DurationDays: input.DurationDays, + ExpiresAt: input.ExpiresAt, + MaxUses: input.MaxUses, + UsedUses: 0, + Status: "active", + }) + codes = append(codes, code) + } + + if err := s.db.WithContext(ctx).Create(&records).Error; err != nil { + return nil, fmt.Errorf("save redeem codes: %w", err) + } + return codes, nil +} + +func (s *Service) UpdateMembershipRedeemCodeStatus(ctx context.Context, id uint, status string) error { + status = strings.TrimSpace(strings.ToLower(status)) + if status != "active" && status != "disabled" { + return ErrInvalidInput + } + + result := s.db.WithContext(ctx). + Model(&membershipmodel.MembershipRedeemCode{}). + Where("id = ?", id). + Update("status", status) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrMembershipRedeemCodeNotFound + } + return nil +} + +func generateAdminRedeemCode(length int) (string, error) { + if length <= 0 { + length = 16 + } + const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return "", err + } + out := make([]byte, length) + for i, b := range buf { + out[i] = alphabet[int(b)%len(alphabet)] + } + return string(out), nil +} + +func hashAdminRedeemCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +func suffixAdminRedeemCode(code string, n int) string { + if n <= 0 { + return "" + } + if len(code) <= n { + return code + } + return code[len(code)-n:] +} + +func (s *Service) EnsureMembershipRedeemCode(id uint) error { + var count int64 + if err := s.db.Model(&membershipmodel.MembershipRedeemCode{}).Where("id = ?", id).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return ErrMembershipRedeemCodeNotFound + } + return nil +} + +func isNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/internal/admin/service/types.go b/internal/admin/service/types.go index db8bf64..78a765a 100644 --- a/internal/admin/service/types.go +++ b/internal/admin/service/types.go @@ -10,13 +10,15 @@ import ( ) var ( - ErrInvalidCredentials = errors.New("invalid credentials") - ErrInvalidToken = errors.New("invalid token") - ErrMissingJWTSecret = errors.New("missing jwt secret") - ErrMiniProgramNotFound = errors.New("mini program not found") - ErrMiniProgramHasUsers = errors.New("mini program has users") - ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists") - ErrInvalidInput = errors.New("invalid input") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrMissingJWTSecret = errors.New("missing jwt secret") + ErrMiniProgramNotFound = errors.New("mini program not found") + ErrMiniProgramHasUsers = errors.New("mini program has users") + ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists") + ErrAdminUserNotFound = errors.New("admin user not found") + ErrMembershipRedeemCodeNotFound = errors.New("membership redeem code not found") + ErrInvalidInput = errors.New("invalid input") ) type Claims struct { diff --git a/internal/admin/service/user_service.go b/internal/admin/service/user_service.go new file mode 100644 index 0000000..638399f --- /dev/null +++ b/internal/admin/service/user_service.go @@ -0,0 +1,228 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + expirymodel "wx_service/internal/expiry/model" + membershipmodel "wx_service/internal/membership/model" + "wx_service/internal/model" + rmmodel "wx_service/internal/remove_watermark/model" + + "gorm.io/gorm" +) + +type ListUsersQuery struct { + Page int + PageSize int + MiniProgramID uint + Keyword string + IsMember *bool +} + +type UserListItem struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + Phone string `json:"phone"` + MiniProgramID uint `json:"mini_program_id"` + MiniProgramName string `json:"mini_program_name"` + IsMember bool `json:"is_member"` + CreatedAt time.Time `json:"created_at"` +} + +type ListUsersResult struct { + List []UserListItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type UserMembershipInfo struct { + IsMember bool `json:"is_member"` + PackageType string `json:"package_type"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type UserDetail struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + Phone string `json:"phone"` + OpenID string `json:"open_id"` + Gender int `json:"gender"` + MiniProgramID uint `json:"mini_program_id"` + MiniProgramName string `json:"mini_program_name"` + IsMember bool `json:"is_member"` + CreatedAt time.Time `json:"created_at"` + Membership UserMembershipInfo `json:"membership"` + Stats map[string]int64 `json:"stats"` +} + +func (s *Service) ListUsers(ctx context.Context, query ListUsersQuery) (*ListUsersResult, error) { + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + query.Keyword = strings.TrimSpace(query.Keyword) + + now := time.Now() + dbQuery := s.db.WithContext(ctx).Model(&model.User{}) + if query.MiniProgramID > 0 { + dbQuery = dbQuery.Where("mini_program_id = ?", query.MiniProgramID) + } + if query.Keyword != "" { + likeKeyword := "%" + query.Keyword + "%" + dbQuery = dbQuery.Where("nick_name LIKE ? OR phone LIKE ?", likeKeyword, likeKeyword) + } + if query.IsMember != nil { + subQuery := s.db.WithContext(ctx).Table("user_memberships AS um"). + Select("1"). + Where("um.user_id = users.id"). + Where("um.status = ?", "active"). + Where("um.ends_at > ?", now) + if *query.IsMember { + dbQuery = dbQuery.Where("EXISTS (?)", subQuery) + } else { + dbQuery = dbQuery.Where("NOT EXISTS (?)", subQuery) + } + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + var users []model.User + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&users).Error; err != nil { + return nil, err + } + } + + miniProgramIDs := make([]uint, 0, len(users)) + userIDs := make([]uint, 0, len(users)) + for _, user := range users { + miniProgramIDs = append(miniProgramIDs, user.MiniProgramID) + userIDs = append(userIDs, user.ID) + } + + miniProgramNameMap := map[uint]string{} + if len(miniProgramIDs) > 0 { + var miniPrograms []model.MiniProgram + if err := s.db.WithContext(ctx). + Select("id", "name"). + Where("id IN ?", miniProgramIDs). + Find(&miniPrograms).Error; err != nil { + return nil, err + } + for _, item := range miniPrograms { + miniProgramNameMap[item.ID] = item.Name + } + } + + memberUserMap := map[uint]bool{} + if len(userIDs) > 0 { + var memberships []model.UserMembership + if err := s.db.WithContext(ctx). + Select("user_id"). + Where("user_id IN ?", userIDs). + Where("status = ?", "active"). + Where("ends_at > ?", now). + Find(&memberships).Error; err != nil { + return nil, err + } + for _, membership := range memberships { + memberUserMap[membership.UserID] = true + } + } + + result := make([]UserListItem, 0, len(users)) + for _, user := range users { + result = append(result, UserListItem{ + ID: user.ID, + Nickname: user.NickName, + AvatarURL: user.AvatarURL, + Phone: user.Phone, + MiniProgramID: user.MiniProgramID, + MiniProgramName: miniProgramNameMap[user.MiniProgramID], + IsMember: memberUserMap[user.ID], + CreatedAt: user.CreatedAt, + }) + } + + return &ListUsersResult{ + List: result, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) GetUserDetail(ctx context.Context, userID uint) (*UserDetail, error) { + var user model.User + if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + var miniProgram model.MiniProgram + _ = s.db.WithContext(ctx).Select("id", "name").Where("id = ?", user.MiniProgramID).First(&miniProgram).Error + + now := time.Now() + membershipInfo := UserMembershipInfo{IsMember: false} + var membership model.UserMembership + if err := s.db.WithContext(ctx). + Where("user_id = ?", user.ID). + Where("status = ?", "active"). + Where("ends_at > ?", now). + Order("ends_at DESC"). + First(&membership).Error; err == nil { + expiresAt := membership.EndsAt + membershipInfo = UserMembershipInfo{ + IsMember: true, + PackageType: membership.Plan, + ExpiresAt: &expiresAt, + } + } + + stats := map[string]int64{} + var expiryCount int64 + _ = s.db.WithContext(ctx).Model(&expirymodel.ExpiryItem{}).Where("user_id = ?", user.ID).Count(&expiryCount).Error + stats["expiry_items"] = expiryCount + + var watermarkCount int64 + _ = s.db.WithContext(ctx).Model(&rmmodel.VideoParseLog{}).Where("user_id = ?", user.ID).Count(&watermarkCount).Error + stats["watermark_tasks"] = watermarkCount + + var redemptionCount int64 + _ = s.db.WithContext(ctx).Model(&membershipmodel.MembershipRedemption{}).Where("user_id = ?", user.ID).Count(&redemptionCount).Error + stats["membership_redemptions"] = redemptionCount + + return &UserDetail{ + ID: user.ID, + Nickname: user.NickName, + AvatarURL: user.AvatarURL, + Phone: user.Phone, + OpenID: user.OpenID, + Gender: user.Gender, + MiniProgramID: user.MiniProgramID, + MiniProgramName: miniProgram.Name, + IsMember: membershipInfo.IsMember, + CreatedAt: user.CreatedAt, + Membership: membershipInfo, + Stats: stats, + }, nil +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index d60a3d3..1655d73 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -31,6 +31,14 @@ func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) { protected.POST("/mini-programs", handler.CreateMiniProgram) protected.PUT("/mini-programs/:id", handler.UpdateMiniProgram) protected.DELETE("/mini-programs/:id", handler.DeleteMiniProgram) + + protected.GET("/users", handler.ListUsers) + protected.GET("/users/:id", handler.GetUserDetail) + + protected.GET("/memberships/overview", handler.MembershipOverview) + protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) + protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes) + protected.POST("/memberships/redeem-codes/:id/status", handler.UpdateMembershipRedeemCodeStatus) } } }