diff --git a/internal/admin/mini_program_handler.go b/internal/admin/mini_program_handler.go new file mode 100644 index 0000000..8184651 --- /dev/null +++ b/internal/admin/mini_program_handler.go @@ -0,0 +1,166 @@ +package admin + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "wx_service/internal/model" +) + +func (h *Handler) ListMiniPrograms(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.ListMiniPrograms(c.Request.Context(), ListMiniProgramsQuery{ + Page: page, + PageSize: pageSize, + Keyword: c.Query("keyword"), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load mini-programs failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetMiniProgram(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini-program id")) + return + } + + data, err := h.svc.GetMiniProgram(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrMiniProgramNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "mini program not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load mini-program failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetMiniProgramStats(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini-program id")) + return + } + + data, err := h.svc.GetMiniProgramStats(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrMiniProgramNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "mini program not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load mini-program stats failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +type createMiniProgramRequest struct { + Name string `json:"name" binding:"required"` + AppID string `json:"app_id" binding:"required"` + AppSecret string `json:"app_secret" binding:"required"` + Description string `json:"description"` +} + +func (h *Handler) CreateMiniProgram(c *gin.Context) { + var req createMiniProgramRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + data, err := h.svc.CreateMiniProgram(c.Request.Context(), CreateMiniProgramInput{ + Name: req.Name, + AppID: req.AppID, + AppSecret: req.AppSecret, + Description: req.Description, + }) + if err != nil { + switch { + case errors.Is(err, ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "name/app_id/app_secret are required")) + case errors.Is(err, ErrMiniProgramAppIDUsed): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "app_id already exists")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create mini-program failed")) + } + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +type updateMiniProgramRequest struct { + Name string `json:"name" binding:"required"` + AppID string `json:"app_id" binding:"required"` + AppSecret *string `json:"app_secret"` + Description string `json:"description"` +} + +func (h *Handler) UpdateMiniProgram(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini-program id")) + return + } + + var req updateMiniProgramRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + data, err := h.svc.UpdateMiniProgram(c.Request.Context(), id, UpdateMiniProgramInput{ + Name: req.Name, + AppID: req.AppID, + AppSecret: req.AppSecret, + Description: req.Description, + }) + if err != nil { + switch { + case errors.Is(err, ErrMiniProgramNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "mini program not found")) + case errors.Is(err, ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "name/app_id are required")) + case errors.Is(err, ErrMiniProgramAppIDUsed): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "app_id already exists")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update mini-program failed")) + } + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteMiniProgram(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid mini-program id")) + return + } + + err = h.svc.DeleteMiniProgram(c.Request.Context(), id) + if err != nil { + switch { + case errors.Is(err, ErrMiniProgramNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "mini program not found")) + case errors.Is(err, ErrMiniProgramHasUsers): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "mini program has related users")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete mini-program failed")) + } + return + } + c.JSON(http.StatusOK, model.Success(gin.H{ + "message": "删除成功", + })) +} diff --git a/internal/admin/mini_program_service.go b/internal/admin/mini_program_service.go new file mode 100644 index 0000000..e3e5c1a --- /dev/null +++ b/internal/admin/mini_program_service.go @@ -0,0 +1,265 @@ +package admin + +import ( + "context" + "errors" + "strings" + "time" + + expirymodel "wx_service/internal/expiry" + membershipmodel "wx_service/internal/membership/model" + "wx_service/internal/model" + rmmodel "wx_service/internal/remove_watermark/model" + + "gorm.io/gorm" +) + +type ListMiniProgramsQuery struct { + Page int + PageSize int + Keyword string +} + +type MiniProgramItem struct { + ID uint `json:"id"` + Name string `json:"name"` + AppID string `json:"app_id"` + Description string `json:"description"` + UserCount int64 `json:"user_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AppSecretSet bool `json:"app_secret_set"` +} + +type ListMiniProgramsResult struct { + List []MiniProgramItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *Service) ListMiniPrograms(ctx context.Context, query ListMiniProgramsQuery) (*ListMiniProgramsResult, 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) + + dbQuery := s.db.WithContext(ctx).Model(&model.MiniProgram{}) + if query.Keyword != "" { + keywordLike := "%" + query.Keyword + "%" + dbQuery = dbQuery.Where("name LIKE ? OR app_id LIKE ?", keywordLike, keywordLike) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + var miniPrograms []model.MiniProgram + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&miniPrograms).Error; err != nil { + return nil, err + } + } + + ids := make([]uint, 0, len(miniPrograms)) + for _, item := range miniPrograms { + ids = append(ids, item.ID) + } + + userCountMap, err := s.groupCountByMiniProgramID(ctx, &model.User{}, ids, "") + if err != nil { + return nil, err + } + + result := make([]MiniProgramItem, 0, len(miniPrograms)) + for _, item := range miniPrograms { + result = append(result, MiniProgramItem{ + ID: item.ID, + Name: item.Name, + AppID: item.AppID, + Description: item.Description, + UserCount: userCountMap[item.ID], + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + AppSecretSet: item.AppSecret != "", + }) + } + + return &ListMiniProgramsResult{ + List: result, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) GetMiniProgram(ctx context.Context, id uint) (*MiniProgramItem, error) { + var miniProgram model.MiniProgram + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&miniProgram).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrMiniProgramNotFound + } + return nil, err + } + + var userCount int64 + if err := s.db.WithContext(ctx).Model(&model.User{}).Where("mini_program_id = ?", id).Count(&userCount).Error; err != nil { + return nil, err + } + + return &MiniProgramItem{ + ID: miniProgram.ID, + Name: miniProgram.Name, + AppID: miniProgram.AppID, + Description: miniProgram.Description, + UserCount: userCount, + CreatedAt: miniProgram.CreatedAt, + UpdatedAt: miniProgram.UpdatedAt, + AppSecretSet: miniProgram.AppSecret != "", + }, nil +} + +type CreateMiniProgramInput struct { + Name string + AppID string + AppSecret string + Description string +} + +func (s *Service) CreateMiniProgram(ctx context.Context, input CreateMiniProgramInput) (*MiniProgramItem, error) { + input.Name = strings.TrimSpace(input.Name) + input.AppID = strings.TrimSpace(input.AppID) + input.AppSecret = strings.TrimSpace(input.AppSecret) + input.Description = strings.TrimSpace(input.Description) + if input.Name == "" || input.AppID == "" || input.AppSecret == "" { + return nil, ErrInvalidInput + } + + item := &model.MiniProgram{ + Name: input.Name, + AppID: input.AppID, + AppSecret: input.AppSecret, + Description: input.Description, + } + if err := s.db.WithContext(ctx).Create(item).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrMiniProgramAppIDUsed + } + return nil, err + } + + return s.GetMiniProgram(ctx, item.ID) +} + +type UpdateMiniProgramInput struct { + Name string + AppID string + AppSecret *string + Description string +} + +func (s *Service) UpdateMiniProgram(ctx context.Context, id uint, input UpdateMiniProgramInput) (*MiniProgramItem, error) { + input.Name = strings.TrimSpace(input.Name) + input.AppID = strings.TrimSpace(input.AppID) + input.Description = strings.TrimSpace(input.Description) + if input.Name == "" || input.AppID == "" { + return nil, ErrInvalidInput + } + + var item model.MiniProgram + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrMiniProgramNotFound + } + return nil, err + } + + updateData := map[string]interface{}{ + "name": input.Name, + "app_id": input.AppID, + "description": input.Description, + } + if input.AppSecret != nil { + trimmedSecret := strings.TrimSpace(*input.AppSecret) + if trimmedSecret != "" { + updateData["app_secret"] = trimmedSecret + } + } + + if err := s.db.WithContext(ctx).Model(&item).Updates(updateData).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrMiniProgramAppIDUsed + } + return nil, err + } + + return s.GetMiniProgram(ctx, id) +} + +func (s *Service) DeleteMiniProgram(ctx context.Context, id uint) error { + var item model.MiniProgram + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrMiniProgramNotFound + } + return err + } + + var userCount int64 + if err := s.db.WithContext(ctx).Model(&model.User{}).Where("mini_program_id = ?", id).Count(&userCount).Error; err != nil { + return err + } + if userCount > 0 { + return ErrMiniProgramHasUsers + } + + return s.db.WithContext(ctx).Delete(&item).Error +} + +type MiniProgramDetailStats struct { + UserCount int64 `json:"user_count"` + DataCount int64 `json:"data_count"` +} + +func (s *Service) GetMiniProgramStats(ctx context.Context, id uint) (*MiniProgramDetailStats, error) { + var exists int64 + if err := s.db.WithContext(ctx).Model(&model.MiniProgram{}).Where("id = ?", id).Count(&exists).Error; err != nil { + return nil, err + } + if exists == 0 { + return nil, ErrMiniProgramNotFound + } + + userCounts, err := s.groupCountByMiniProgramID(ctx, &model.User{}, []uint{id}, "") + if err != nil { + return nil, err + } + expiryCounts, err := s.groupCountByMiniProgramID(ctx, &expirymodel.ExpiryItem{}, []uint{id}, "") + if err != nil { + return nil, err + } + videoCounts, err := s.groupCountByMiniProgramID(ctx, &rmmodel.VideoParseLog{}, []uint{id}, "") + if err != nil { + return nil, err + } + redeemCounts, err := s.groupCountByMiniProgramID(ctx, &membershipmodel.MembershipRedemption{}, []uint{id}, "") + if err != nil { + return nil, err + } + + dataCount := expiryCounts[id] + videoCounts[id] + redeemCounts[id] + return &MiniProgramDetailStats{ + UserCount: userCounts[id], + DataCount: dataCount, + }, nil +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 48507b7..d60a3d3 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -24,6 +24,13 @@ func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) { protected.GET("/stats/overview", handler.StatsOverview) protected.GET("/stats/mini-programs", handler.StatsMiniPrograms) protected.GET("/stats/user-growth", handler.StatsUserGrowth) + + protected.GET("/mini-programs", handler.ListMiniPrograms) + protected.GET("/mini-programs/:id", handler.GetMiniProgram) + protected.GET("/mini-programs/:id/stats", handler.GetMiniProgramStats) + protected.POST("/mini-programs", handler.CreateMiniProgram) + protected.PUT("/mini-programs/:id", handler.UpdateMiniProgram) + protected.DELETE("/mini-programs/:id", handler.DeleteMiniProgram) } } }