diff --git a/cmd/api/main.go b/cmd/api/main.go index c6c8d5d..4883e7a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -19,6 +19,10 @@ import ( expiry "wx_service/internal/expiry" lawyerhandler "wx_service/internal/lawyer/handler" lawyerservice "wx_service/internal/lawyer/service" + marketinghandler "wx_service/internal/marketing/handler" + marketingmodel "wx_service/internal/marketing/model" + marketingrepo "wx_service/internal/marketing/repository" + marketingservice "wx_service/internal/marketing/service" membershiphandler "wx_service/internal/membership/handler" membershipmodel "wx_service/internal/membership/model" membershipservice "wx_service/internal/membership/service" @@ -65,6 +69,9 @@ func main() { &smokemodel.SmokeAIAdviceUnlock{}, &smokemodel.SmokeAINextSmoke{}, &smokemodel.SmokeMotivationQuote{}, + &marketingmodel.MarketingCategory{}, + &marketingmodel.MarketingTemplate{}, + &marketingmodel.MarketingDownload{}, ); err != nil { log.Fatalf("auto migrate failed: %v", err) } @@ -127,8 +134,18 @@ func main() { } expiryHandler := expiry.NewHandler(expiryService) + categoryRepo := marketingrepo.NewCategoryRepository(database.DB) + templateRepo := marketingrepo.NewTemplateRepository(database.DB) + downloadRepo := marketingrepo.NewDownloadRepository(database.DB) + categorySvc := marketingservice.NewCategoryService(categoryRepo) + templateSvc := marketingservice.NewTemplateService(templateRepo) + downloadSvc := marketingservice.NewDownloadService(downloadRepo, templateRepo) + marketingCategoryHandler := marketinghandler.NewCategoryHandler(categorySvc) + marketingTemplateHandler := marketinghandler.NewTemplateHandler(templateSvc) + marketingDownloadHandler := marketinghandler.NewDownloadHandler(downloadSvc) + // 6) 注册路由:把 URL 映射到 handler - routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, sessionCache, lawyerHandler, expiryHandler) + routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, sessionCache, lawyerHandler, expiryHandler, config.AppConfig.Admin.Token, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) // 7) 启动监听端口 addr := ":" + config.AppConfig.Server.Port diff --git a/internal/marketing/handler/admin_middleware.go b/internal/marketing/handler/admin_middleware.go new file mode 100644 index 0000000..cd08313 --- /dev/null +++ b/internal/marketing/handler/admin_middleware.go @@ -0,0 +1,26 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "wx_service/internal/model" +) + +func AdminTokenMiddleware(adminToken string) gin.HandlerFunc { + return func(c *gin.Context) { + if adminToken == "" { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置管理员口令")) + return + } + + token := c.GetHeader("X-Admin-Token") + if token == "" || token != adminToken { + c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "无权限")) + return + } + + c.Next() + } +} diff --git a/internal/marketing/handler/category_handler.go b/internal/marketing/handler/category_handler.go new file mode 100644 index 0000000..1f31784 --- /dev/null +++ b/internal/marketing/handler/category_handler.go @@ -0,0 +1,119 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "wx_service/internal/marketing/service" + "wx_service/internal/model" +) + +type CategoryHandler struct { + svc *service.CategoryService +} + +func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler { + return &CategoryHandler{svc: svc} +} + +// ListEnabled returns enabled categories for mini-program users. +func (h *CategoryHandler) ListEnabled(c *gin.Context) { + categories, err := h.svc.List(true) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(categories)) +} + +// AdminList returns all categories (including disabled) for admin. +func (h *CategoryHandler) AdminList(c *gin.Context) { + categories, err := h.svc.List(false) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(categories)) +} + +func (h *CategoryHandler) AdminCreate(c *gin.Context) { + var req service.CategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + cat, err := h.svc.Create(req) + if err != nil { + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(cat)) +} + +func (h *CategoryHandler) AdminUpdate(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req service.CategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + cat, err := h.svc.Update(id, req) + if err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分类不存在")) + return + } + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(cat)) +} + +func (h *CategoryHandler) AdminDelete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + if err := h.svc.Delete(id); err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分类不存在")) + return + } + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(nil)) +} + +func parseID(c *gin.Context) (uint, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误")) + return 0, false + } + return uint(id), true +} diff --git a/internal/marketing/handler/download_handler.go b/internal/marketing/handler/download_handler.go new file mode 100644 index 0000000..410ef95 --- /dev/null +++ b/internal/marketing/handler/download_handler.go @@ -0,0 +1,87 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "wx_service/internal/marketing/service" + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +type DownloadHandler struct { + svc *service.DownloadService +} + +func NewDownloadHandler(svc *service.DownloadService) *DownloadHandler { + return &DownloadHandler{svc: svc} +} + +func (h *DownloadHandler) Create(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req service.CreateDownloadRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + dl, err := h.svc.Create(user.ID, req) + if err != nil { + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(dl)) +} + +type adCallbackRequest struct { + DownloadID uint `json:"download_id" binding:"required"` +} + +func (h *DownloadHandler) AdCallback(c *gin.Context) { + var req adCallbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + if err := h.svc.MarkAdCompleted(req.DownloadID); err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "记录不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(nil)) +} + +func (h *DownloadHandler) ListByUser(c *gin.Context) { + user := middleware.MustCurrentUser(c) + page := parseIntQuery(c, "page", 1) + pageSize := parseIntQuery(c, "page_size", 20) + + resp, err := h.svc.ListByUser(user.ID, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(resp)) +} + +func (h *DownloadHandler) AdminStats(c *gin.Context) { + stats, err := h.svc.GetStats() + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(stats)) +} diff --git a/internal/marketing/handler/template_handler.go b/internal/marketing/handler/template_handler.go new file mode 100644 index 0000000..018216f --- /dev/null +++ b/internal/marketing/handler/template_handler.go @@ -0,0 +1,168 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "wx_service/internal/marketing/repository" + "wx_service/internal/marketing/service" + "wx_service/internal/model" +) + +type TemplateHandler struct { + svc *service.TemplateService +} + +func NewTemplateHandler(svc *service.TemplateService) *TemplateHandler { + return &TemplateHandler{svc: svc} +} + +// ListEnabled returns enabled templates for mini-program users. +func (h *TemplateHandler) ListEnabled(c *gin.Context) { + categoryID := parseUintQuery(c, "category_id", 0) + page := parseIntQuery(c, "page", 1) + pageSize := parseIntQuery(c, "page_size", 20) + + resp, err := h.svc.List(repository.TemplateListParams{ + CategoryID: categoryID, + OnlyEnabled: true, + Page: page, + PageSize: pageSize, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(resp)) +} + +// GetDetail returns a single template by ID. +func (h *TemplateHandler) GetDetail(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + tpl, err := h.svc.GetByID(id) + if err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(tpl)) +} + +// AdminList returns all templates for admin. +func (h *TemplateHandler) AdminList(c *gin.Context) { + categoryID := parseUintQuery(c, "category_id", 0) + page := parseIntQuery(c, "page", 1) + pageSize := parseIntQuery(c, "page_size", 20) + + resp, err := h.svc.List(repository.TemplateListParams{ + CategoryID: categoryID, + OnlyEnabled: false, + Page: page, + PageSize: pageSize, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + c.JSON(http.StatusOK, model.Success(resp)) +} + +func (h *TemplateHandler) AdminCreate(c *gin.Context) { + var req service.TemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + tpl, err := h.svc.Create(req) + if err != nil { + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(tpl)) +} + +func (h *TemplateHandler) AdminUpdate(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req service.TemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + tpl, err := h.svc.Update(id, req) + if err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在")) + return + } + if service.IsBadRequestError(err) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(tpl)) +} + +func (h *TemplateHandler) AdminDelete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + if err := h.svc.Delete(id); err != nil { + if service.IsNotFoundError(err) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "模板不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "服务器错误")) + return + } + + c.JSON(http.StatusOK, model.Success(nil)) +} + +func parseIntQuery(c *gin.Context, key string, defaultValue int) int { + v := c.Query(key) + if v == "" { + return defaultValue + } + parsed, err := strconv.Atoi(v) + if err != nil { + return defaultValue + } + return parsed +} + +func parseUintQuery(c *gin.Context, key string, defaultValue uint) uint { + v := c.Query(key) + if v == "" { + return defaultValue + } + parsed, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return defaultValue + } + return uint(parsed) +} diff --git a/internal/marketing/model/category.go b/internal/marketing/model/category.go new file mode 100644 index 0000000..ef15f16 --- /dev/null +++ b/internal/marketing/model/category.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type MarketingCategory struct { + ID uint `json:"id" gorm:"primaryKey;comment:主键ID"` + Name string `json:"name" gorm:"size:50;not null;comment:分类名称"` + SortOrder int `json:"sort_order" gorm:"default:0;comment:排序权重(越大越靠前)"` + Icon string `json:"icon" gorm:"size:500;comment:图标URL"` + Status int `json:"status" gorm:"default:1;index;comment:状态(1=启用,0=禁用)"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"` +} + +func (MarketingCategory) TableName() string { + return "marketing_categories" +} diff --git a/internal/marketing/model/download.go b/internal/marketing/model/download.go new file mode 100644 index 0000000..24c8e2c --- /dev/null +++ b/internal/marketing/model/download.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type MarketingDownload struct { + ID uint `json:"id" gorm:"primaryKey;comment:主键ID"` + UserID uint `json:"user_id" gorm:"not null;index;comment:用户ID"` + TemplateID uint `json:"template_id" gorm:"not null;index;comment:模板ID"` + LogoURL string `json:"logo_url" gorm:"size:500;comment:用户Logo地址"` + LogoX float64 `json:"logo_x" gorm:"comment:Logo X坐标(相对比例)"` + LogoY float64 `json:"logo_y" gorm:"comment:Logo Y坐标(相对比例)"` + LogoW float64 `json:"logo_w" gorm:"comment:Logo 宽度(相对比例)"` + LogoH float64 `json:"logo_h" gorm:"comment:Logo 高度(相对比例)"` + AdCompleted bool `json:"ad_completed" gorm:"default:false;comment:是否已观看广告"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + + Template *MarketingTemplate `json:"template,omitempty" gorm:"foreignKey:TemplateID"` +} + +func (MarketingDownload) TableName() string { + return "marketing_user_downloads" +} diff --git a/internal/marketing/model/template.go b/internal/marketing/model/template.go new file mode 100644 index 0000000..53f8e28 --- /dev/null +++ b/internal/marketing/model/template.go @@ -0,0 +1,24 @@ +package model + +import "time" + +type MarketingTemplate struct { + ID uint `json:"id" gorm:"primaryKey;comment:主键ID"` + CategoryID uint `json:"category_id" gorm:"not null;index;comment:所属分类ID"` + Title string `json:"title" gorm:"size:100;not null;comment:模板名称"` + ImageURL string `json:"image_url" gorm:"size:500;not null;comment:模板图片URL"` + ThumbnailURL string `json:"thumbnail_url" gorm:"size:500;comment:缩略图URL"` + Width int `json:"width" gorm:"default:0;comment:图片宽度px"` + Height int `json:"height" gorm:"default:0;comment:图片高度px"` + SortOrder int `json:"sort_order" gorm:"default:0;comment:排序权重(越大越靠前)"` + Status int `json:"status" gorm:"default:1;index;comment:状态(1=启用,0=禁用)"` + DownloadCount int `json:"download_count" gorm:"default:0;comment:下载次数"` + CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"` + + Category *MarketingCategory `json:"category,omitempty" gorm:"foreignKey:CategoryID"` +} + +func (MarketingTemplate) TableName() string { + return "marketing_templates" +} diff --git a/internal/marketing/repository/category_repo.go b/internal/marketing/repository/category_repo.go new file mode 100644 index 0000000..3e56ec5 --- /dev/null +++ b/internal/marketing/repository/category_repo.go @@ -0,0 +1,90 @@ +package repository + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "wx_service/internal/marketing/model" +) + +var ErrCategoryNotFound = errors.New("marketing category not found") + +type CategoryRepository struct { + db *gorm.DB +} + +func NewCategoryRepository(db *gorm.DB) *CategoryRepository { + return &CategoryRepository{db: db} +} + +func (r *CategoryRepository) Create(cat *model.MarketingCategory) error { + if err := r.db.Create(cat).Error; err != nil { + return fmt.Errorf("create marketing category: %w", err) + } + return nil +} + +func (r *CategoryRepository) Update(cat *model.MarketingCategory) error { + tx := r.db.Model(cat).Updates(map[string]interface{}{ + "name": cat.Name, + "sort_order": cat.SortOrder, + "icon": cat.Icon, + "status": cat.Status, + }) + if tx.Error != nil { + return fmt.Errorf("update marketing category: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrCategoryNotFound + } + return nil +} + +func (r *CategoryRepository) Delete(id uint) error { + tx := r.db.Delete(&model.MarketingCategory{}, id) + if tx.Error != nil { + return fmt.Errorf("delete marketing category: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrCategoryNotFound + } + return nil +} + +func (r *CategoryRepository) FindByID(id uint) (*model.MarketingCategory, error) { + var cat model.MarketingCategory + err := r.db.First(&cat, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrCategoryNotFound + } + return nil, fmt.Errorf("find marketing category: %w", err) + } + return &cat, nil +} + +// FindAll returns all categories. If onlyEnabled is true, only status=1 records are returned. +func (r *CategoryRepository) FindAll(onlyEnabled bool) ([]model.MarketingCategory, error) { + query := r.db.Model(&model.MarketingCategory{}) + if onlyEnabled { + query = query.Where("status = ?", 1) + } + query = query.Order("sort_order DESC, id ASC") + + var categories []model.MarketingCategory + if err := query.Find(&categories).Error; err != nil { + return nil, fmt.Errorf("list marketing categories: %w", err) + } + return categories, nil +} + +func (r *CategoryRepository) HasTemplates(categoryID uint) (bool, error) { + var count int64 + err := r.db.Model(&model.MarketingTemplate{}).Where("category_id = ?", categoryID).Count(&count).Error + if err != nil { + return false, fmt.Errorf("count templates by category: %w", err) + } + return count > 0, nil +} diff --git a/internal/marketing/repository/download_repo.go b/internal/marketing/repository/download_repo.go new file mode 100644 index 0000000..62857b4 --- /dev/null +++ b/internal/marketing/repository/download_repo.go @@ -0,0 +1,98 @@ +package repository + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "wx_service/internal/marketing/model" +) + +var ErrDownloadNotFound = errors.New("marketing download not found") + +type DownloadRepository struct { + db *gorm.DB +} + +func NewDownloadRepository(db *gorm.DB) *DownloadRepository { + return &DownloadRepository{db: db} +} + +func (r *DownloadRepository) Create(dl *model.MarketingDownload) error { + if err := r.db.Create(dl).Error; err != nil { + return fmt.Errorf("create marketing download: %w", err) + } + return nil +} + +func (r *DownloadRepository) FindByID(id uint) (*model.MarketingDownload, error) { + var dl model.MarketingDownload + err := r.db.First(&dl, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrDownloadNotFound + } + return nil, fmt.Errorf("find marketing download: %w", err) + } + return &dl, nil +} + +func (r *DownloadRepository) MarkAdCompleted(id uint) error { + tx := r.db.Model(&model.MarketingDownload{}).Where("id = ?", id).Update("ad_completed", true) + if tx.Error != nil { + return fmt.Errorf("mark ad completed: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrDownloadNotFound + } + return nil +} + +func (r *DownloadRepository) FindByUser(userID uint, page, pageSize int) ([]model.MarketingDownload, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + query := r.db.Model(&model.MarketingDownload{}).Where("user_id = ?", userID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count marketing downloads: %w", err) + } + + var downloads []model.MarketingDownload + offset := (page - 1) * pageSize + if err := query.Preload("Template").Order("id DESC").Offset(offset).Limit(pageSize).Find(&downloads).Error; err != nil { + return nil, 0, fmt.Errorf("list marketing downloads: %w", err) + } + + return downloads, total, nil +} + +type DownloadStats struct { + TotalDownloads int64 + TodayDownloads int64 +} + +func (r *DownloadRepository) GetStats() (*DownloadStats, error) { + var total int64 + if err := r.db.Model(&model.MarketingDownload{}).Count(&total).Error; err != nil { + return nil, fmt.Errorf("count total downloads: %w", err) + } + + var today int64 + if err := r.db.Model(&model.MarketingDownload{}). + Where("DATE(created_at) = CURDATE()"). + Count(&today).Error; err != nil { + return nil, fmt.Errorf("count today downloads: %w", err) + } + + return &DownloadStats{TotalDownloads: total, TodayDownloads: today}, nil +} diff --git a/internal/marketing/repository/template_repo.go b/internal/marketing/repository/template_repo.go new file mode 100644 index 0000000..8369210 --- /dev/null +++ b/internal/marketing/repository/template_repo.go @@ -0,0 +1,120 @@ +package repository + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "wx_service/internal/marketing/model" +) + +var ErrTemplateNotFound = errors.New("marketing template not found") + +type TemplateRepository struct { + db *gorm.DB +} + +func NewTemplateRepository(db *gorm.DB) *TemplateRepository { + return &TemplateRepository{db: db} +} + +func (r *TemplateRepository) Create(tpl *model.MarketingTemplate) error { + if err := r.db.Create(tpl).Error; err != nil { + return fmt.Errorf("create marketing template: %w", err) + } + return nil +} + +func (r *TemplateRepository) Update(tpl *model.MarketingTemplate) error { + tx := r.db.Model(tpl).Updates(map[string]interface{}{ + "category_id": tpl.CategoryID, + "title": tpl.Title, + "image_url": tpl.ImageURL, + "thumbnail_url": tpl.ThumbnailURL, + "width": tpl.Width, + "height": tpl.Height, + "sort_order": tpl.SortOrder, + "status": tpl.Status, + }) + if tx.Error != nil { + return fmt.Errorf("update marketing template: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrTemplateNotFound + } + return nil +} + +func (r *TemplateRepository) Delete(id uint) error { + tx := r.db.Delete(&model.MarketingTemplate{}, id) + if tx.Error != nil { + return fmt.Errorf("delete marketing template: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return ErrTemplateNotFound + } + return nil +} + +func (r *TemplateRepository) FindByID(id uint) (*model.MarketingTemplate, error) { + var tpl model.MarketingTemplate + err := r.db.Preload("Category").First(&tpl, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrTemplateNotFound + } + return nil, fmt.Errorf("find marketing template: %w", err) + } + return &tpl, nil +} + +type TemplateListParams struct { + CategoryID uint + OnlyEnabled bool + Page int + PageSize int +} + +func (r *TemplateRepository) FindList(params TemplateListParams) ([]model.MarketingTemplate, int64, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.PageSize <= 0 { + params.PageSize = 20 + } + if params.PageSize > 100 { + params.PageSize = 100 + } + + query := r.db.Model(&model.MarketingTemplate{}) + if params.CategoryID > 0 { + query = query.Where("category_id = ?", params.CategoryID) + } + if params.OnlyEnabled { + query = query.Where("status = ?", 1) + } + query = query.Order("sort_order DESC, id DESC") + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count marketing templates: %w", err) + } + + var templates []model.MarketingTemplate + offset := (params.Page - 1) * params.PageSize + if err := query.Preload("Category").Offset(offset).Limit(params.PageSize).Find(&templates).Error; err != nil { + return nil, 0, fmt.Errorf("list marketing templates: %w", err) + } + + return templates, total, nil +} + +func (r *TemplateRepository) IncrementDownloadCount(id uint) error { + tx := r.db.Model(&model.MarketingTemplate{}).Where("id = ?", id). + UpdateColumn("download_count", gorm.Expr("download_count + 1")) + if tx.Error != nil { + return fmt.Errorf("increment download count: %w", tx.Error) + } + return nil +} diff --git a/internal/marketing/service/category_service.go b/internal/marketing/service/category_service.go new file mode 100644 index 0000000..5a0a126 --- /dev/null +++ b/internal/marketing/service/category_service.go @@ -0,0 +1,106 @@ +package service + +import ( + "errors" + "strings" + + "wx_service/internal/marketing/model" + "wx_service/internal/marketing/repository" +) + +var ( + ErrCategoryNameRequired = errors.New("分类名称不能为空") + ErrCategoryNameTooLong = errors.New("分类名称不能超过50个字符") + ErrCategoryHasTemplates = errors.New("该分类下还有模板,无法删除") +) + +type CategoryService struct { + repo *repository.CategoryRepository +} + +func NewCategoryService(repo *repository.CategoryRepository) *CategoryService { + return &CategoryService{repo: repo} +} + +type CategoryRequest struct { + Name string `json:"name"` + SortOrder int `json:"sort_order"` + Icon string `json:"icon"` + Status *int `json:"status"` +} + +func (s *CategoryService) Create(req CategoryRequest) (*model.MarketingCategory, error) { + if err := validateCategoryRequest(req); err != nil { + return nil, err + } + + status := 1 + if req.Status != nil { + status = *req.Status + } + + cat := &model.MarketingCategory{ + Name: strings.TrimSpace(req.Name), + SortOrder: req.SortOrder, + Icon: strings.TrimSpace(req.Icon), + Status: status, + } + + if err := s.repo.Create(cat); err != nil { + return nil, err + } + return cat, nil +} + +func (s *CategoryService) Update(id uint, req CategoryRequest) (*model.MarketingCategory, error) { + if err := validateCategoryRequest(req); err != nil { + return nil, err + } + + cat, err := s.repo.FindByID(id) + if err != nil { + return nil, err + } + + cat.Name = strings.TrimSpace(req.Name) + cat.SortOrder = req.SortOrder + cat.Icon = strings.TrimSpace(req.Icon) + if req.Status != nil { + cat.Status = *req.Status + } + + if err := s.repo.Update(cat); err != nil { + return nil, err + } + return cat, nil +} + +func (s *CategoryService) Delete(id uint) error { + has, err := s.repo.HasTemplates(id) + if err != nil { + return err + } + if has { + return ErrCategoryHasTemplates + } + return s.repo.Delete(id) +} + +func (s *CategoryService) GetByID(id uint) (*model.MarketingCategory, error) { + return s.repo.FindByID(id) +} + +func (s *CategoryService) List(onlyEnabled bool) ([]model.MarketingCategory, error) { + return s.repo.FindAll(onlyEnabled) +} + +func validateCategoryRequest(req CategoryRequest) error { + name := strings.TrimSpace(req.Name) + if name == "" { + return ErrCategoryNameRequired + } + if len(name) > 50 { + return ErrCategoryNameTooLong + } + return nil +} diff --git a/internal/marketing/service/download_service.go b/internal/marketing/service/download_service.go new file mode 100644 index 0000000..b3cb4df --- /dev/null +++ b/internal/marketing/service/download_service.go @@ -0,0 +1,103 @@ +package service + +import ( + "errors" + + "wx_service/internal/marketing/model" + "wx_service/internal/marketing/repository" +) + +var ( + ErrDownloadTemplateRequired = errors.New("请选择模板") +) + +type DownloadService struct { + downloadRepo *repository.DownloadRepository + templateRepo *repository.TemplateRepository +} + +func NewDownloadService(downloadRepo *repository.DownloadRepository, templateRepo *repository.TemplateRepository) *DownloadService { + return &DownloadService{ + downloadRepo: downloadRepo, + templateRepo: templateRepo, + } +} + +type CreateDownloadRequest struct { + TemplateID uint `json:"template_id"` + LogoURL string `json:"logo_url"` + LogoX float64 `json:"logo_x"` + LogoY float64 `json:"logo_y"` + LogoW float64 `json:"logo_w"` + LogoH float64 `json:"logo_h"` +} + +type DownloadListResponse struct { + Downloads []model.MarketingDownload `json:"downloads"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *DownloadService) Create(userID uint, req CreateDownloadRequest) (*model.MarketingDownload, error) { + if req.TemplateID == 0 { + return nil, ErrDownloadTemplateRequired + } + + dl := &model.MarketingDownload{ + UserID: userID, + TemplateID: req.TemplateID, + LogoURL: req.LogoURL, + LogoX: req.LogoX, + LogoY: req.LogoY, + LogoW: req.LogoW, + LogoH: req.LogoH, + } + + if err := s.downloadRepo.Create(dl); err != nil { + return nil, err + } + + _ = s.templateRepo.IncrementDownloadCount(req.TemplateID) + return dl, nil +} + +func (s *DownloadService) MarkAdCompleted(downloadID uint) error { + return s.downloadRepo.MarkAdCompleted(downloadID) +} + +func (s *DownloadService) ListByUser(userID uint, page, pageSize int) (*DownloadListResponse, error) { + downloads, total, err := s.downloadRepo.FindByUser(userID, page, pageSize) + if err != nil { + return nil, err + } + return &DownloadListResponse{ + Downloads: downloads, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (s *DownloadService) GetStats() (*repository.DownloadStats, error) { + return s.downloadRepo.GetStats() +} + +// IsBadRequestError checks if the error is a client-side validation error. +func IsBadRequestError(err error) bool { + return errors.Is(err, ErrDownloadTemplateRequired) || + errors.Is(err, ErrCategoryNameRequired) || + errors.Is(err, ErrCategoryNameTooLong) || + errors.Is(err, ErrCategoryHasTemplates) || + errors.Is(err, ErrTemplateTitleRequired) || + errors.Is(err, ErrTemplateTitleTooLong) || + errors.Is(err, ErrTemplateImageRequired) || + errors.Is(err, ErrTemplateCategoryRequired) +} + +// IsNotFoundError checks if the error is a not-found error. +func IsNotFoundError(err error) bool { + return errors.Is(err, repository.ErrCategoryNotFound) || + errors.Is(err, repository.ErrTemplateNotFound) || + errors.Is(err, repository.ErrDownloadNotFound) +} diff --git a/internal/marketing/service/template_service.go b/internal/marketing/service/template_service.go new file mode 100644 index 0000000..bce4351 --- /dev/null +++ b/internal/marketing/service/template_service.go @@ -0,0 +1,134 @@ +package service + +import ( + "errors" + "strings" + + "wx_service/internal/marketing/model" + "wx_service/internal/marketing/repository" +) + +var ( + ErrTemplateTitleRequired = errors.New("模板名称不能为空") + ErrTemplateTitleTooLong = errors.New("模板名称不能超过100个字符") + ErrTemplateImageRequired = errors.New("模板图片不能为空") + ErrTemplateCategoryRequired = errors.New("请选择所属分类") +) + +type TemplateService struct { + repo *repository.TemplateRepository +} + +func NewTemplateService(repo *repository.TemplateRepository) *TemplateService { + return &TemplateService{repo: repo} +} + +type TemplateRequest struct { + CategoryID uint `json:"category_id"` + Title string `json:"title"` + ImageURL string `json:"image_url"` + ThumbnailURL string `json:"thumbnail_url"` + Width int `json:"width"` + Height int `json:"height"` + SortOrder int `json:"sort_order"` + Status *int `json:"status"` +} + +type TemplateListResponse struct { + Templates []model.MarketingTemplate `json:"templates"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +func (s *TemplateService) Create(req TemplateRequest) (*model.MarketingTemplate, error) { + if err := validateTemplateRequest(req); err != nil { + return nil, err + } + + status := 1 + if req.Status != nil { + status = *req.Status + } + + tpl := &model.MarketingTemplate{ + CategoryID: req.CategoryID, + Title: strings.TrimSpace(req.Title), + ImageURL: strings.TrimSpace(req.ImageURL), + ThumbnailURL: strings.TrimSpace(req.ThumbnailURL), + Width: req.Width, + Height: req.Height, + SortOrder: req.SortOrder, + Status: status, + } + + if err := s.repo.Create(tpl); err != nil { + return nil, err + } + return tpl, nil +} + +func (s *TemplateService) Update(id uint, req TemplateRequest) (*model.MarketingTemplate, error) { + if err := validateTemplateRequest(req); err != nil { + return nil, err + } + + tpl, err := s.repo.FindByID(id) + if err != nil { + return nil, err + } + + tpl.CategoryID = req.CategoryID + tpl.Title = strings.TrimSpace(req.Title) + tpl.ImageURL = strings.TrimSpace(req.ImageURL) + tpl.ThumbnailURL = strings.TrimSpace(req.ThumbnailURL) + tpl.Width = req.Width + tpl.Height = req.Height + tpl.SortOrder = req.SortOrder + if req.Status != nil { + tpl.Status = *req.Status + } + + if err := s.repo.Update(tpl); err != nil { + return nil, err + } + return tpl, nil +} + +func (s *TemplateService) Delete(id uint) error { + return s.repo.Delete(id) +} + +func (s *TemplateService) GetByID(id uint) (*model.MarketingTemplate, error) { + return s.repo.FindByID(id) +} + +func (s *TemplateService) List(params repository.TemplateListParams) (*TemplateListResponse, error) { + templates, total, err := s.repo.FindList(params) + if err != nil { + return nil, err + } + return &TemplateListResponse{ + Templates: templates, + Total: total, + Page: params.Page, + PageSize: params.PageSize, + }, nil +} + +func validateTemplateRequest(req TemplateRequest) error { + title := strings.TrimSpace(req.Title) + if title == "" { + return ErrTemplateTitleRequired + } + if len(title) > 100 { + return ErrTemplateTitleTooLong + } + if strings.TrimSpace(req.ImageURL) == "" { + return ErrTemplateImageRequired + } + if req.CategoryID == 0 { + return ErrTemplateCategoryRequired + } + return nil +} diff --git a/internal/routes/marketing_routes.go b/internal/routes/marketing_routes.go new file mode 100644 index 0000000..85c63c7 --- /dev/null +++ b/internal/routes/marketing_routes.go @@ -0,0 +1,50 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + marketinghandler "wx_service/internal/marketing/handler" +) + +func registerMarketingRoutes( + api *gin.RouterGroup, + protected *gin.RouterGroup, + adminToken string, + categoryHandler *marketinghandler.CategoryHandler, + templateHandler *marketinghandler.TemplateHandler, + downloadHandler *marketinghandler.DownloadHandler, +) { + if categoryHandler == nil || templateHandler == nil || downloadHandler == nil { + return + } + + marketing := api.Group("/marketing") + { + marketing.GET("/categories", categoryHandler.ListEnabled) + marketing.GET("/templates", templateHandler.ListEnabled) + marketing.GET("/templates/:id", templateHandler.GetDetail) + } + + protectedMarketing := protected.Group("/marketing") + { + protectedMarketing.POST("/downloads", downloadHandler.Create) + protectedMarketing.POST("/ad_callback", downloadHandler.AdCallback) + protectedMarketing.GET("/downloads", downloadHandler.ListByUser) + } + + admin := api.Group("/admin/marketing") + admin.Use(marketinghandler.AdminTokenMiddleware(adminToken)) + { + admin.GET("/categories", categoryHandler.AdminList) + admin.POST("/categories", categoryHandler.AdminCreate) + admin.PUT("/categories/:id", categoryHandler.AdminUpdate) + admin.DELETE("/categories/:id", categoryHandler.AdminDelete) + + admin.GET("/templates", templateHandler.AdminList) + admin.POST("/templates", templateHandler.AdminCreate) + admin.PUT("/templates/:id", templateHandler.AdminUpdate) + admin.DELETE("/templates/:id", templateHandler.AdminDelete) + + admin.GET("/stats", downloadHandler.AdminStats) + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 2ceeada..58e28c8 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -12,6 +12,7 @@ import ( oahandler "wx_service/internal/common/wechat_official/handler" expiryhandler "wx_service/internal/expiry" lawyerhandler "wx_service/internal/lawyer/handler" + marketinghandler "wx_service/internal/marketing/handler" membershiphandler "wx_service/internal/membership/handler" "wx_service/internal/middleware" rmhandler "wx_service/internal/remove_watermark/handler" @@ -30,6 +31,10 @@ func Register( sessionCache *rediscache.SessionUserCache, lawyerHandler *lawyerhandler.LawyerHandler, expiryHandler *expiryhandler.Handler, + adminToken string, + marketingCategoryHandler *marketinghandler.CategoryHandler, + marketingTemplateHandler *marketinghandler.TemplateHandler, + marketingDownloadHandler *marketinghandler.DownloadHandler, ) { // Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰: // - main 只负责初始化(配置/DB/依赖注入) @@ -57,6 +62,8 @@ func Register( registerMembershipRoutes(protected, redeemCodeHandler) registerSmokeRoutes(protected, smokeHandler) } + + registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) } // 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。 diff --git a/web/marketing/index.html b/web/marketing/index.html new file mode 100644 index 0000000..a138d47 --- /dev/null +++ b/web/marketing/index.html @@ -0,0 +1,410 @@ + + +
+ + +