From b4170b4863f42b1885ddf0ad9b0e52562b59c790 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Sat, 4 Apr 2026 03:46:57 +0800 Subject: [PATCH] feat(marketing): add user logo management module Users can now save uploaded logos to the backend (marketing_user_logos table), avoiding repeated uploads. Includes CRUD endpoints: list, save, delete with a per-user limit of 10 logos. Made-with: Cursor --- cmd/api/main.go | 5 ++ .../marketing/handler/user_logo_handler.go | 73 +++++++++++++++++++ internal/marketing/model/user_logo.go | 16 ++++ .../marketing/repository/user_logo_repo.go | 65 +++++++++++++++++ .../marketing/service/user_logo_service.go | 55 ++++++++++++++ internal/routes/marketing_routes.go | 7 ++ internal/routes/routes.go | 3 +- 7 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 internal/marketing/handler/user_logo_handler.go create mode 100644 internal/marketing/model/user_logo.go create mode 100644 internal/marketing/repository/user_logo_repo.go create mode 100644 internal/marketing/service/user_logo_service.go diff --git a/cmd/api/main.go b/cmd/api/main.go index bc7eb7a..eaa52a2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -82,6 +82,7 @@ func main() { &marketingmodel.MarketingCategory{}, &marketingmodel.MarketingTemplate{}, &marketingmodel.MarketingDownload{}, + &marketingmodel.UserLogo{}, &quitcheckinmodel.Profile{}, &quitcheckinmodel.DailyStatus{}, &quitcheckinmodel.RelapseEvent{}, @@ -156,12 +157,15 @@ func main() { categoryRepo := marketingrepo.NewCategoryRepository(database.DB) templateRepo := marketingrepo.NewTemplateRepository(database.DB) downloadRepo := marketingrepo.NewDownloadRepository(database.DB) + userLogoRepo := marketingrepo.NewUserLogoRepository(database.DB) categorySvc := marketingservice.NewCategoryService(categoryRepo) templateSvc := marketingservice.NewTemplateService(templateRepo) downloadSvc := marketingservice.NewDownloadService(downloadRepo, templateRepo) + userLogoSvc := marketingservice.NewUserLogoService(userLogoRepo) marketingCategoryHandler := marketinghandler.NewCategoryHandler(categorySvc) marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc) marketingDownloadHandler := marketinghandler.NewDownloadHandler(downloadSvc) + marketingUserLogoHandler := marketinghandler.NewUserLogoHandler(userLogoSvc) adminService := adminmodule.NewService( database.DB, @@ -194,6 +198,7 @@ func main() { marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler, + marketingUserLogoHandler, quitCheckinHandler, ) diff --git a/internal/marketing/handler/user_logo_handler.go b/internal/marketing/handler/user_logo_handler.go new file mode 100644 index 0000000..da02c5e --- /dev/null +++ b/internal/marketing/handler/user_logo_handler.go @@ -0,0 +1,73 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "wx_service/internal/marketing/repository" + "wx_service/internal/marketing/service" + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +type UserLogoHandler struct { + svc *service.UserLogoService +} + +func NewUserLogoHandler(svc *service.UserLogoService) *UserLogoHandler { + return &UserLogoHandler{svc: svc} +} + +func (h *UserLogoHandler) List(c *gin.Context) { + user := middleware.MustCurrentUser(c) + logos, err := h.svc.List(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 Logo 列表失败")) + return + } + c.JSON(http.StatusOK, model.Success(logos)) +} + +func (h *UserLogoHandler) Save(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req service.SaveLogoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + logo, err := h.svc.Save(user.ID, req) + if err != nil { + if errors.Is(err, service.ErrLogoLimitReached) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "保存 Logo 失败")) + return + } + c.JSON(http.StatusOK, model.Success(logo)) +} + +func (h *UserLogoHandler) Delete(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "无效的 ID")) + return + } + + if err := h.svc.Delete(uint(id), user.ID); err != nil { + if errors.Is(err, repository.ErrUserLogoNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "Logo 不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "删除失败")) + return + } + c.JSON(http.StatusOK, model.Success(nil)) +} diff --git a/internal/marketing/model/user_logo.go b/internal/marketing/model/user_logo.go new file mode 100644 index 0000000..9906a73 --- /dev/null +++ b/internal/marketing/model/user_logo.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type UserLogo struct { + ID uint `json:"id" gorm:"primaryKey;comment:主键ID"` + UserID uint `json:"user_id" gorm:"not null;index;comment:用户ID"` + URL string `json:"url" gorm:"size:500;not null;comment:Logo CDN地址"` + Filename string `json:"filename" gorm:"size:255;comment:原始文件名"` + FileSize int64 `json:"file_size" gorm:"comment:文件大小(字节)"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` +} + +func (UserLogo) TableName() string { + return "marketing_user_logos" +} diff --git a/internal/marketing/repository/user_logo_repo.go b/internal/marketing/repository/user_logo_repo.go new file mode 100644 index 0000000..65b70db --- /dev/null +++ b/internal/marketing/repository/user_logo_repo.go @@ -0,0 +1,65 @@ +package repository + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "wx_service/internal/marketing/model" +) + +var ErrUserLogoNotFound = errors.New("user logo not found") + +type UserLogoRepository struct { + db *gorm.DB +} + +func NewUserLogoRepository(db *gorm.DB) *UserLogoRepository { + return &UserLogoRepository{db: db} +} + +func (r *UserLogoRepository) Create(logo *model.UserLogo) error { + if err := r.db.Create(logo).Error; err != nil { + return fmt.Errorf("create user logo: %w", err) + } + return nil +} + +func (r *UserLogoRepository) FindByUser(userID uint) ([]model.UserLogo, error) { + var logos []model.UserLogo + err := r.db.Where("user_id = ?", userID).Order("id DESC").Find(&logos).Error + if err != nil { + return nil, fmt.Errorf("list user logos: %w", err) + } + return logos, nil +} + +func (r *UserLogoRepository) FindByID(id, userID uint) (*model.UserLogo, error) { + var logo model.UserLogo + err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&logo).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserLogoNotFound + } + return nil, fmt.Errorf("find user logo: %w", err) + } + return &logo, nil +} + +func (r *UserLogoRepository) Delete(id, userID uint) error { + tx := r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.UserLogo{}) + if tx.Error != nil { + return fmt.Errorf("delete user logo: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrUserLogoNotFound + } + return nil +} + +func (r *UserLogoRepository) CountByUser(userID uint) (int64, error) { + var count int64 + err := r.db.Model(&model.UserLogo{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} diff --git a/internal/marketing/service/user_logo_service.go b/internal/marketing/service/user_logo_service.go new file mode 100644 index 0000000..a7db7f6 --- /dev/null +++ b/internal/marketing/service/user_logo_service.go @@ -0,0 +1,55 @@ +package service + +import ( + "errors" + + "wx_service/internal/marketing/model" + "wx_service/internal/marketing/repository" +) + +const MaxLogosPerUser = 10 + +var ErrLogoLimitReached = errors.New("Logo 数量已达上限") + +type UserLogoService struct { + repo *repository.UserLogoRepository +} + +func NewUserLogoService(repo *repository.UserLogoRepository) *UserLogoService { + return &UserLogoService{repo: repo} +} + +type SaveLogoRequest struct { + URL string `json:"url" binding:"required"` + Filename string `json:"filename"` + FileSize int64 `json:"file_size"` +} + +func (s *UserLogoService) Save(userID uint, req SaveLogoRequest) (*model.UserLogo, error) { + count, err := s.repo.CountByUser(userID) + if err != nil { + return nil, err + } + if count >= MaxLogosPerUser { + return nil, ErrLogoLimitReached + } + + logo := &model.UserLogo{ + UserID: userID, + URL: req.URL, + Filename: req.Filename, + FileSize: req.FileSize, + } + if err := s.repo.Create(logo); err != nil { + return nil, err + } + return logo, nil +} + +func (s *UserLogoService) List(userID uint) ([]model.UserLogo, error) { + return s.repo.FindByUser(userID) +} + +func (s *UserLogoService) Delete(id, userID uint) error { + return s.repo.Delete(id, userID) +} diff --git a/internal/routes/marketing_routes.go b/internal/routes/marketing_routes.go index fb8ac1b..7192579 100644 --- a/internal/routes/marketing_routes.go +++ b/internal/routes/marketing_routes.go @@ -13,6 +13,7 @@ func registerMarketingRoutes( categoryHandler *marketinghandler.CategoryHandler, templateHandler *marketinghandler.TemplateHandler, downloadHandler *marketinghandler.DownloadHandler, + userLogoHandler *marketinghandler.UserLogoHandler, ) { if categoryHandler == nil || templateHandler == nil || downloadHandler == nil { return @@ -30,6 +31,12 @@ func registerMarketingRoutes( protectedMarketing.POST("/downloads", downloadHandler.Create) protectedMarketing.POST("/ad_callback", downloadHandler.AdCallback) protectedMarketing.GET("/downloads", downloadHandler.ListByUser) + + if userLogoHandler != nil { + protectedMarketing.GET("/logos", userLogoHandler.List) + protectedMarketing.POST("/logos", userLogoHandler.Save) + protectedMarketing.DELETE("/logos/:id", userLogoHandler.Delete) + } } admin := api.Group("/admin/marketing") diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 77368d5..9624bfc 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -39,6 +39,7 @@ func Register( marketingCategoryHandler *marketinghandler.CategoryHandler, marketingTemplateHandler *marketinghandler.TemplateHandler, marketingDownloadHandler *marketinghandler.DownloadHandler, + marketingUserLogoHandler *marketinghandler.UserLogoHandler, quitCheckinHandler *quitcheckinhandler.Handler, ) { // Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰: @@ -70,7 +71,7 @@ func Register( registerSmokeRoutes(protected, smokeHandler, quitPlanHandler) } - registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) + registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler, marketingUserLogoHandler) } apiV2 := router.Group("/api/v2")