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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user