feat(marketing): 新增营销图管理模块

- 新增 marketing 模块:model/repository/service/handler 四层架构
- 数据模型:marketing_categories、marketing_templates、marketing_user_downloads
- 小程序端接口:分类列表、模板列表/详情、下载记录、广告回调
- 管理后台接口:分类/模板 CRUD、下载统计(X-Admin-Token 鉴权)
- 路由注册:接入现有 AuthMiddleware,新增 AdminTokenMiddleware
- Web 管理后台:单页面 Vue3 + Element Plus(分类管理、模板管理、数据概览)

Closes #37, #38, #39, #40

Made-with: Cursor
This commit is contained in:
nepiedg
2026-03-06 07:36:05 +00:00
parent 5f492929df
commit ac49e1458c
17 changed files with 1599 additions and 1 deletions
@@ -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()
}
}
@@ -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
}
@@ -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))
}
@@ -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)
}
+17
View File
@@ -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"
}
+22
View File
@@ -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"
}
+24
View File
@@ -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"
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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
}
+50
View File
@@ -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)
}
}
+7
View File
@@ -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 并存。