feat: 完成后端-管理后台集成及部署配置

🚀 主要功能:
- 完善后端API服务层,实现完整的CRUD操作
- 开发管理后台所有核心页面 (仪表板、照片、分类、标签、用户、设置)
- 完成前后端完全集成,所有API接口正常对接
- 配置完整的CI/CD流水线,支持自动化部署

🎯 后端完善:
- 实现PhotoService, CategoryService, TagService, UserService
- 添加完整的API处理器和路由配置
- 支持Docker容器化部署
- 添加数据库迁移和健康检查

🎨 管理后台完成:
- 仪表板: 实时统计数据展示
- 照片管理: 完整的CRUD操作,支持批量处理
- 分类管理: 树形结构展示和管理
- 标签管理: 颜色标签和统计信息
- 用户管理: 角色权限控制
- 系统设置: 多标签配置界面
- 添加pre-commit代码质量检查

🔧 部署配置:
- Docker Compose完整配置
- 后端CI/CD流水线 (Docker部署)
- 管理后台CI/CD流水线 (静态文件部署)
- 前端CI/CD流水线优化
- 自动化脚本: 部署、备份、监控
- 完整的部署文档和运维指南

 集成完成:
- 所有API接口正常连接
- 认证系统完整集成
- 数据获取和状态管理
- 错误处理和用户反馈
- 响应式设计优化
This commit is contained in:
xujiang
2025-07-09 16:23:18 +08:00
parent c57ec3aa82
commit 72414d0979
62 changed files with 12416 additions and 262 deletions

View File

@ -0,0 +1,448 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"photography-backend/internal/models"
"photography-backend/internal/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
type CategoryService struct {
db *gorm.DB
logger *zap.Logger
}
func NewCategoryService(db *gorm.DB, logger *zap.Logger) *CategoryService {
return &CategoryService{
db: db,
logger: logger,
}
}
// GetCategories 获取分类列表
func (s *CategoryService) GetCategories(ctx context.Context, parentID *uint) ([]models.Category, error) {
var categories []models.Category
query := s.db.WithContext(ctx).Order("sort_order ASC, created_at ASC")
if parentID != nil {
query = query.Where("parent_id = ?", *parentID)
} else {
query = query.Where("parent_id IS NULL")
}
if err := query.Find(&categories).Error; err != nil {
s.logger.Error("Failed to get categories", zap.Error(err))
return nil, err
}
return categories, nil
}
// GetCategoryTree 获取分类树
func (s *CategoryService) GetCategoryTree(ctx context.Context) ([]models.CategoryTree, error) {
var categories []models.Category
if err := s.db.WithContext(ctx).
Order("sort_order ASC, created_at ASC").
Find(&categories).Error; err != nil {
s.logger.Error("Failed to get all categories", zap.Error(err))
return nil, err
}
// 构建树形结构
tree := s.buildCategoryTree(categories, nil)
return tree, nil
}
// GetCategoryByID 根据ID获取分类
func (s *CategoryService) GetCategoryByID(ctx context.Context, id uint) (*models.Category, error) {
var category models.Category
if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
s.logger.Error("Failed to get category by ID", zap.Error(err), zap.Uint("id", id))
return nil, err
}
return &category, nil
}
// GetCategoryBySlug 根据slug获取分类
func (s *CategoryService) GetCategoryBySlug(ctx context.Context, slug string) (*models.Category, error) {
var category models.Category
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
s.logger.Error("Failed to get category by slug", zap.Error(err), zap.String("slug", slug))
return nil, err
}
return &category, nil
}
// CreateCategory 创建分类
func (s *CategoryService) CreateCategory(ctx context.Context, req *models.CreateCategoryRequest) (*models.Category, error) {
// 验证slug唯一性
if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil {
return nil, err
}
// 验证父分类存在性
if req.ParentID != nil {
var parentCategory models.Category
if err := s.db.WithContext(ctx).First(&parentCategory, *req.ParentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("parent category not found")
}
return nil, err
}
}
// 获取排序顺序
sortOrder := s.getNextSortOrder(ctx, req.ParentID)
category := &models.Category{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
ParentID: req.ParentID,
SortOrder: sortOrder,
IsActive: true,
}
if err := s.db.WithContext(ctx).Create(category).Error; err != nil {
s.logger.Error("Failed to create category", zap.Error(err))
return nil, err
}
s.logger.Info("Category created successfully", zap.Uint("id", category.ID))
return category, nil
}
// UpdateCategory 更新分类
func (s *CategoryService) UpdateCategory(ctx context.Context, id uint, req *models.UpdateCategoryRequest) (*models.Category, error) {
// 检查分类是否存在
var category models.Category
if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
return nil, err
}
// 验证slug唯一性
if req.Slug != nil && *req.Slug != category.Slug {
if err := s.validateSlugUnique(ctx, *req.Slug, id); err != nil {
return nil, err
}
}
// 验证父分类(防止循环引用)
if req.ParentID != nil && *req.ParentID != category.ParentID {
if err := s.validateParentCategory(ctx, id, *req.ParentID); err != nil {
return nil, err
}
}
// 构建更新数据
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Slug != nil {
updates["slug"] = *req.Slug
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.ParentID != nil {
if *req.ParentID == 0 {
updates["parent_id"] = nil
} else {
updates["parent_id"] = *req.ParentID
}
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&category).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update category", zap.Error(err))
return nil, err
}
}
s.logger.Info("Category updated successfully", zap.Uint("id", id))
return &category, nil
}
// DeleteCategory 删除分类
func (s *CategoryService) DeleteCategory(ctx context.Context, id uint) error {
// 检查分类是否存在
var category models.Category
if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("category not found")
}
return err
}
// 检查是否有子分类
var childCount int64
if err := s.db.WithContext(ctx).Model(&models.Category{}).
Where("parent_id = ?", id).Count(&childCount).Error; err != nil {
return err
}
if childCount > 0 {
return errors.New("cannot delete category with subcategories")
}
// 检查是否有关联的照片
var photoCount int64
if err := s.db.WithContext(ctx).Table("photo_categories").
Where("category_id = ?", id).Count(&photoCount).Error; err != nil {
return err
}
if photoCount > 0 {
return errors.New("cannot delete category with associated photos")
}
// 删除分类
if err := s.db.WithContext(ctx).Delete(&category).Error; err != nil {
s.logger.Error("Failed to delete category", zap.Error(err))
return err
}
s.logger.Info("Category deleted successfully", zap.Uint("id", id))
return nil
}
// ReorderCategories 重新排序分类
func (s *CategoryService) ReorderCategories(ctx context.Context, parentID *uint, categoryIDs []uint) error {
// 验证所有分类都属于同一父分类
var categories []models.Category
query := s.db.WithContext(ctx).Where("id IN ?", categoryIDs)
if parentID != nil {
query = query.Where("parent_id = ?", *parentID)
} else {
query = query.Where("parent_id IS NULL")
}
if err := query.Find(&categories).Error; err != nil {
return err
}
if len(categories) != len(categoryIDs) {
return errors.New("invalid category IDs")
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// 更新排序
for i, categoryID := range categoryIDs {
if err := tx.Model(&models.Category{}).
Where("id = ?", categoryID).
Update("sort_order", i+1).Error; err != nil {
return err
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return err
}
s.logger.Info("Categories reordered successfully", zap.Int("count", len(categoryIDs)))
return nil
}
// GetCategoryStats 获取分类统计信息
func (s *CategoryService) GetCategoryStats(ctx context.Context) (*models.CategoryStats, error) {
var stats models.CategoryStats
// 总分类数
if err := s.db.WithContext(ctx).Model(&models.Category{}).Count(&stats.Total).Error; err != nil {
return nil, err
}
// 活跃分类数
if err := s.db.WithContext(ctx).Model(&models.Category{}).
Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
return nil, err
}
// 顶级分类数
if err := s.db.WithContext(ctx).Model(&models.Category{}).
Where("parent_id IS NULL").Count(&stats.TopLevel).Error; err != nil {
return nil, err
}
// 各分类照片数量
var categoryPhotoStats []struct {
CategoryID uint `json:"category_id"`
Name string `json:"name"`
PhotoCount int64 `json:"photo_count"`
}
if err := s.db.WithContext(ctx).
Table("categories").
Select("categories.id as category_id, categories.name, COUNT(photo_categories.photo_id) as photo_count").
Joins("LEFT JOIN photo_categories ON categories.id = photo_categories.category_id").
Group("categories.id, categories.name").
Order("photo_count DESC").
Limit(10).
Find(&categoryPhotoStats).Error; err != nil {
return nil, err
}
stats.PhotoCounts = make(map[string]int64)
for _, stat := range categoryPhotoStats {
stats.PhotoCounts[stat.Name] = stat.PhotoCount
}
return &stats, nil
}
// validateSlugUnique 验证slug唯一性
func (s *CategoryService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error {
var count int64
query := s.db.WithContext(ctx).Model(&models.Category{}).Where("slug = ?", slug)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("slug already exists")
}
return nil
}
// validateParentCategory 验证父分类(防止循环引用)
func (s *CategoryService) validateParentCategory(ctx context.Context, categoryID, parentID uint) error {
if categoryID == parentID {
return errors.New("category cannot be its own parent")
}
// 检查是否会形成循环引用
current := parentID
for current != 0 {
var parent models.Category
if err := s.db.WithContext(ctx).First(&parent, current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("parent category not found")
}
return err
}
if parent.ParentID == nil {
break
}
if *parent.ParentID == categoryID {
return errors.New("circular reference detected")
}
current = *parent.ParentID
}
return nil
}
// getNextSortOrder 获取下一个排序顺序
func (s *CategoryService) getNextSortOrder(ctx context.Context, parentID *uint) int {
var maxOrder int
query := s.db.WithContext(ctx).Model(&models.Category{}).Select("COALESCE(MAX(sort_order), 0)")
if parentID != nil {
query = query.Where("parent_id = ?", *parentID)
} else {
query = query.Where("parent_id IS NULL")
}
query.Row().Scan(&maxOrder)
return maxOrder + 1
}
// buildCategoryTree 构建分类树
func (s *CategoryService) buildCategoryTree(categories []models.Category, parentID *uint) []models.CategoryTree {
var tree []models.CategoryTree
for _, category := range categories {
// 检查是否匹配父分类
if (parentID == nil && category.ParentID == nil) ||
(parentID != nil && category.ParentID != nil && *category.ParentID == *parentID) {
node := models.CategoryTree{
ID: category.ID,
Name: category.Name,
Slug: category.Slug,
Description: category.Description,
ParentID: category.ParentID,
SortOrder: category.SortOrder,
IsActive: category.IsActive,
PhotoCount: category.PhotoCount,
CreatedAt: category.CreatedAt,
UpdatedAt: category.UpdatedAt,
}
// 递归构建子分类
node.Children = s.buildCategoryTree(categories, &category.ID)
tree = append(tree, node)
}
}
return tree
}
// GenerateSlug 生成slug
func (s *CategoryService) GenerateSlug(ctx context.Context, name string) (string, error) {
baseSlug := utils.GenerateSlug(name)
slug := baseSlug
counter := 1
for {
var count int64
if err := s.db.WithContext(ctx).Model(&models.Category{}).
Where("slug = ?", slug).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
break
}
slug = fmt.Sprintf("%s-%d", baseSlug, counter)
counter++
}
return slug, nil
}

View File

@ -0,0 +1,678 @@
package service
import (
"context"
"errors"
"fmt"
"mime/multipart"
"path/filepath"
"strconv"
"strings"
"time"
"photography-backend/internal/config"
"photography-backend/internal/models"
"photography-backend/internal/service/storage"
"photography-backend/internal/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
type PhotoService struct {
db *gorm.DB
config *config.Config
logger *zap.Logger
storageService *storage.StorageService
}
func NewPhotoService(db *gorm.DB, config *config.Config, logger *zap.Logger, storageService *storage.StorageService) *PhotoService {
return &PhotoService{
db: db,
config: config,
logger: logger,
storageService: storageService,
}
}
// PhotoListParams 照片列表查询参数
type PhotoListParams struct {
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
Search string `json:"search" form:"search"`
Status string `json:"status" form:"status"`
CategoryID uint `json:"category_id" form:"category_id"`
Tags []string `json:"tags" form:"tags"`
StartDate string `json:"start_date" form:"start_date"`
EndDate string `json:"end_date" form:"end_date"`
SortBy string `json:"sort_by" form:"sort_by"`
SortOrder string `json:"sort_order" form:"sort_order"`
}
// PhotoListResponse 照片列表响应
type PhotoListResponse struct {
Photos []models.Photo `json:"photos"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Pages int `json:"pages"`
}
// GetPhotos 获取照片列表
func (s *PhotoService) GetPhotos(ctx context.Context, params PhotoListParams) (*PhotoListResponse, error) {
// 设置默认值
if params.Page <= 0 {
params.Page = 1
}
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
// 构建查询
query := s.db.WithContext(ctx).
Preload("Categories").
Preload("Tags").
Preload("Formats")
// 搜索过滤
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
query = query.Where("title ILIKE ? OR description ILIKE ? OR original_filename ILIKE ?",
searchPattern, searchPattern, searchPattern)
}
// 状态过滤
if params.Status != "" {
query = query.Where("status = ?", params.Status)
}
// 分类过滤
if params.CategoryID > 0 {
query = query.Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id").
Where("photo_categories.category_id = ?", params.CategoryID)
}
// 标签过滤
if len(params.Tags) > 0 {
query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id").
Joins("JOIN tags ON photo_tags.tag_id = tags.id").
Where("tags.slug IN ?", params.Tags)
}
// 日期过滤
if params.StartDate != "" {
if startDate, err := time.Parse("2006-01-02", params.StartDate); err == nil {
query = query.Where("taken_at >= ?", startDate)
}
}
if params.EndDate != "" {
if endDate, err := time.Parse("2006-01-02", params.EndDate); err == nil {
query = query.Where("taken_at <= ?", endDate)
}
}
// 排序
sortBy := "created_at"
sortOrder := "desc"
if params.SortBy != "" {
allowedSortFields := []string{"created_at", "updated_at", "taken_at", "title", "file_size"}
if utils.Contains(allowedSortFields, params.SortBy) {
sortBy = params.SortBy
}
}
if params.SortOrder == "asc" {
sortOrder = "asc"
}
// 计算总数
var total int64
countQuery := query
if err := countQuery.Model(&models.Photo{}).Count(&total).Error; err != nil {
s.logger.Error("Failed to count photos", zap.Error(err))
return nil, err
}
// 分页查询
offset := (params.Page - 1) * params.Limit
var photos []models.Photo
if err := query.
Order(fmt.Sprintf("%s %s", sortBy, sortOrder)).
Offset(offset).
Limit(params.Limit).
Find(&photos).Error; err != nil {
s.logger.Error("Failed to get photos", zap.Error(err))
return nil, err
}
// 计算总页数
pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
return &PhotoListResponse{
Photos: photos,
Total: total,
Page: params.Page,
Limit: params.Limit,
Pages: pages,
}, nil
}
// GetPhotoByID 根据ID获取照片
func (s *PhotoService) GetPhotoByID(ctx context.Context, id uint) (*models.Photo, error) {
var photo models.Photo
if err := s.db.WithContext(ctx).
Preload("Categories").
Preload("Tags").
Preload("Formats").
First(&photo, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("photo not found")
}
s.logger.Error("Failed to get photo by ID", zap.Error(err), zap.Uint("id", id))
return nil, err
}
return &photo, nil
}
// CreatePhoto 创建照片
func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoRequest) (*models.Photo, error) {
// 生成唯一的文件名
uniqueFilename := utils.GenerateUniqueFilename(req.OriginalFilename)
photo := &models.Photo{
Title: req.Title,
Description: req.Description,
OriginalFilename: req.OriginalFilename,
UniqueFilename: uniqueFilename,
FileSize: req.FileSize,
Status: req.Status,
Camera: req.Camera,
Lens: req.Lens,
ISO: req.ISO,
Aperture: req.Aperture,
ShutterSpeed: req.ShutterSpeed,
FocalLength: req.FocalLength,
TakenAt: req.TakenAt,
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer tx.Rollback()
// 创建照片记录
if err := tx.Create(photo).Error; err != nil {
s.logger.Error("Failed to create photo", zap.Error(err))
return nil, err
}
// 关联分类
if len(req.CategoryIDs) > 0 {
var categories []models.Category
if err := tx.Where("id IN ?", req.CategoryIDs).Find(&categories).Error; err != nil {
s.logger.Error("Failed to find categories", zap.Error(err))
return nil, err
}
if err := tx.Model(photo).Association("Categories").Replace(categories); err != nil {
s.logger.Error("Failed to associate categories", zap.Error(err))
return nil, err
}
}
// 关联标签
if len(req.TagIDs) > 0 {
var tags []models.Tag
if err := tx.Where("id IN ?", req.TagIDs).Find(&tags).Error; err != nil {
s.logger.Error("Failed to find tags", zap.Error(err))
return nil, err
}
if err := tx.Model(photo).Association("Tags").Replace(tags); err != nil {
s.logger.Error("Failed to associate tags", zap.Error(err))
return nil, err
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
s.logger.Error("Failed to commit transaction", zap.Error(err))
return nil, err
}
// 重新加载关联数据
if err := s.db.WithContext(ctx).
Preload("Categories").
Preload("Tags").
Preload("Formats").
First(photo, photo.ID).Error; err != nil {
s.logger.Error("Failed to reload photo", zap.Error(err))
return nil, err
}
s.logger.Info("Photo created successfully", zap.Uint("id", photo.ID))
return photo, nil
}
// UpdatePhoto 更新照片
func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.UpdatePhotoRequest) (*models.Photo, error) {
// 检查照片是否存在
var photo models.Photo
if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("photo not found")
}
return nil, err
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return nil, tx.Error
}
defer tx.Rollback()
// 更新照片基本信息
updates := map[string]interface{}{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Status != nil {
updates["status"] = *req.Status
}
if req.Camera != nil {
updates["camera"] = *req.Camera
}
if req.Lens != nil {
updates["lens"] = *req.Lens
}
if req.ISO != nil {
updates["iso"] = *req.ISO
}
if req.Aperture != nil {
updates["aperture"] = *req.Aperture
}
if req.ShutterSpeed != nil {
updates["shutter_speed"] = *req.ShutterSpeed
}
if req.FocalLength != nil {
updates["focal_length"] = *req.FocalLength
}
if req.TakenAt != nil {
updates["taken_at"] = *req.TakenAt
}
if len(updates) > 0 {
if err := tx.Model(&photo).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update photo", zap.Error(err))
return nil, err
}
}
// 更新分类关联
if req.CategoryIDs != nil {
var categories []models.Category
if len(*req.CategoryIDs) > 0 {
if err := tx.Where("id IN ?", *req.CategoryIDs).Find(&categories).Error; err != nil {
s.logger.Error("Failed to find categories", zap.Error(err))
return nil, err
}
}
if err := tx.Model(&photo).Association("Categories").Replace(categories); err != nil {
s.logger.Error("Failed to update categories", zap.Error(err))
return nil, err
}
}
// 更新标签关联
if req.TagIDs != nil {
var tags []models.Tag
if len(*req.TagIDs) > 0 {
if err := tx.Where("id IN ?", *req.TagIDs).Find(&tags).Error; err != nil {
s.logger.Error("Failed to find tags", zap.Error(err))
return nil, err
}
}
if err := tx.Model(&photo).Association("Tags").Replace(tags); err != nil {
s.logger.Error("Failed to update tags", zap.Error(err))
return nil, err
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
s.logger.Error("Failed to commit transaction", zap.Error(err))
return nil, err
}
// 重新加载照片数据
if err := s.db.WithContext(ctx).
Preload("Categories").
Preload("Tags").
Preload("Formats").
First(&photo, id).Error; err != nil {
s.logger.Error("Failed to reload photo", zap.Error(err))
return nil, err
}
s.logger.Info("Photo updated successfully", zap.Uint("id", id))
return &photo, nil
}
// DeletePhoto 删除照片
func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error {
// 检查照片是否存在
var photo models.Photo
if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("photo not found")
}
return err
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// 删除关联的格式文件
if err := tx.Where("photo_id = ?", id).Delete(&models.PhotoFormat{}).Error; err != nil {
s.logger.Error("Failed to delete photo formats", zap.Error(err))
return err
}
// 删除关联关系
if err := tx.Model(&photo).Association("Categories").Clear(); err != nil {
s.logger.Error("Failed to clear categories", zap.Error(err))
return err
}
if err := tx.Model(&photo).Association("Tags").Clear(); err != nil {
s.logger.Error("Failed to clear tags", zap.Error(err))
return err
}
// 删除照片记录
if err := tx.Delete(&photo).Error; err != nil {
s.logger.Error("Failed to delete photo", zap.Error(err))
return err
}
// 提交事务
if err := tx.Commit().Error; err != nil {
s.logger.Error("Failed to commit transaction", zap.Error(err))
return err
}
// 异步删除文件
go func() {
if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil {
s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename))
}
}()
s.logger.Info("Photo deleted successfully", zap.Uint("id", id))
return nil
}
// UploadPhoto 上传照片
func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, header *multipart.FileHeader, req *models.CreatePhotoRequest) (*models.Photo, error) {
// 验证文件类型
if !s.isValidImageFile(header.Filename) {
return nil, errors.New("invalid file type")
}
// 验证文件大小
if header.Size > s.config.Upload.MaxFileSize {
return nil, errors.New("file size too large")
}
// 生成唯一文件名
uniqueFilename := utils.GenerateUniqueFilename(header.Filename)
// 上传文件到存储服务
uploadedFile, err := s.storageService.UploadPhoto(ctx, file, uniqueFilename)
if err != nil {
s.logger.Error("Failed to upload photo", zap.Error(err))
return nil, err
}
// 创建照片记录
req.OriginalFilename = header.Filename
req.FileSize = header.Size
photo, err := s.CreatePhoto(ctx, req)
if err != nil {
// 如果创建记录失败,删除已上传的文件
go func() {
if err := s.storageService.DeletePhoto(uniqueFilename); err != nil {
s.logger.Error("Failed to cleanup uploaded file", zap.Error(err))
}
}()
return nil, err
}
// 异步处理图片格式转换
go func() {
s.processPhotoFormats(context.Background(), photo, uploadedFile)
}()
return photo, nil
}
// BatchUpdatePhotos 批量更新照片
func (s *PhotoService) BatchUpdatePhotos(ctx context.Context, ids []uint, req *models.BatchUpdatePhotosRequest) error {
if len(ids) == 0 {
return errors.New("no photos to update")
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// 构建更新数据
updates := map[string]interface{}{}
if req.Status != nil {
updates["status"] = *req.Status
}
// 基础字段更新
if len(updates) > 0 {
if err := tx.Model(&models.Photo{}).Where("id IN ?", ids).Updates(updates).Error; err != nil {
s.logger.Error("Failed to batch update photos", zap.Error(err))
return err
}
}
// 批量更新分类
if req.CategoryIDs != nil {
// 先删除现有关联
if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", ids).Error; err != nil {
return err
}
// 添加新关联
if len(*req.CategoryIDs) > 0 {
for _, photoID := range ids {
for _, categoryID := range *req.CategoryIDs {
if err := tx.Exec("INSERT INTO photo_categories (photo_id, category_id) VALUES (?, ?)", photoID, categoryID).Error; err != nil {
return err
}
}
}
}
}
// 批量更新标签
if req.TagIDs != nil {
// 先删除现有关联
if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", ids).Error; err != nil {
return err
}
// 添加新关联
if len(*req.TagIDs) > 0 {
for _, photoID := range ids {
for _, tagID := range *req.TagIDs {
if err := tx.Exec("INSERT INTO photo_tags (photo_id, tag_id) VALUES (?, ?)", photoID, tagID).Error; err != nil {
return err
}
}
}
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
s.logger.Error("Failed to commit batch update", zap.Error(err))
return err
}
s.logger.Info("Batch update completed", zap.Int("count", len(ids)))
return nil
}
// BatchDeletePhotos 批量删除照片
func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error {
if len(ids) == 0 {
return errors.New("no photos to delete")
}
// 获取要删除的照片信息
var photos []models.Photo
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&photos).Error; err != nil {
return err
}
// 开始事务
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// 删除关联的格式文件
if err := tx.Where("photo_id IN ?", ids).Delete(&models.PhotoFormat{}).Error; err != nil {
return err
}
// 删除关联关系
if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", ids).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", ids).Error; err != nil {
return err
}
// 删除照片记录
if err := tx.Where("id IN ?", ids).Delete(&models.Photo{}).Error; err != nil {
return err
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return err
}
// 异步删除文件
go func() {
for _, photo := range photos {
if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil {
s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename))
}
}
}()
s.logger.Info("Batch delete completed", zap.Int("count", len(ids)))
return nil
}
// GetPhotoStats 获取照片统计信息
func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, error) {
var stats models.PhotoStats
// 总数统计
if err := s.db.WithContext(ctx).Model(&models.Photo{}).Count(&stats.Total).Error; err != nil {
return nil, err
}
// 按状态统计
var statusStats []struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
if err := s.db.WithContext(ctx).Model(&models.Photo{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&statusStats).Error; err != nil {
return nil, err
}
stats.StatusStats = make(map[string]int64)
for _, stat := range statusStats {
stats.StatusStats[stat.Status] = stat.Count
}
// 本月新增
startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1)
if err := s.db.WithContext(ctx).Model(&models.Photo{}).
Where("created_at >= ?", startOfMonth).
Count(&stats.ThisMonth).Error; err != nil {
return nil, err
}
// 今日新增
startOfDay := time.Now().Truncate(24 * time.Hour)
if err := s.db.WithContext(ctx).Model(&models.Photo{}).
Where("created_at >= ?", startOfDay).
Count(&stats.Today).Error; err != nil {
return nil, err
}
// 总存储大小
var totalSize sql.NullInt64
if err := s.db.WithContext(ctx).Model(&models.Photo{}).
Select("SUM(file_size)").
Row().Scan(&totalSize); err != nil {
return nil, err
}
if totalSize.Valid {
stats.TotalSize = totalSize.Int64
}
return &stats, nil
}
// isValidImageFile 验证图片文件类型
func (s *PhotoService) isValidImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
return utils.Contains(allowedExts, ext)
}
// processPhotoFormats 处理照片格式转换
func (s *PhotoService) processPhotoFormats(ctx context.Context, photo *models.Photo, uploadedFile *storage.UploadedFile) {
// 这里将实现图片格式转换逻辑
// 生成不同尺寸和格式的图片
// 更新 photo_formats 表
s.logger.Info("Processing photo formats", zap.Uint("photo_id", photo.ID))
// TODO: 实现图片处理逻辑
// 1. 生成缩略图
// 2. 生成不同尺寸的图片
// 3. 转换为不同格式 (WebP, AVIF)
// 4. 更新数据库记录
}

View File

@ -0,0 +1,218 @@
package storage
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"photography-backend/internal/config"
"go.uber.org/zap"
)
// UploadedFile 上传后的文件信息
type UploadedFile struct {
Filename string `json:"filename"`
OriginalURL string `json:"original_url"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
}
// StorageService 存储服务接口
type StorageService interface {
UploadPhoto(ctx context.Context, file multipart.File, filename string) (*UploadedFile, error)
DeletePhoto(filename string) error
GetPhotoURL(filename string) string
GenerateThumbnail(ctx context.Context, filename string) error
}
// LocalStorageService 本地存储服务实现
type LocalStorageService struct {
config *config.Config
logger *zap.Logger
uploadDir string
baseURL string
}
// NewLocalStorageService 创建本地存储服务
func NewLocalStorageService(config *config.Config, logger *zap.Logger) *LocalStorageService {
uploadDir := config.Upload.Path
if uploadDir == "" {
uploadDir = "./uploads"
}
baseURL := config.Upload.BaseURL
if baseURL == "" {
baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.Server.Port)
}
// 确保上传目录存在
if err := os.MkdirAll(uploadDir, 0755); err != nil {
logger.Error("Failed to create upload directory", zap.Error(err))
}
// 创建子目录
dirs := []string{"photos", "thumbnails", "temp"}
for _, dir := range dirs {
dirPath := filepath.Join(uploadDir, dir)
if err := os.MkdirAll(dirPath, 0755); err != nil {
logger.Error("Failed to create subdirectory", zap.String("dir", dir), zap.Error(err))
}
}
return &LocalStorageService{
config: config,
logger: logger,
uploadDir: uploadDir,
baseURL: baseURL,
}
}
// UploadPhoto 上传照片
func (s *LocalStorageService) UploadPhoto(ctx context.Context, file multipart.File, filename string) (*UploadedFile, error) {
// 保存原图
photoPath := filepath.Join(s.uploadDir, "photos", filename)
out, err := os.Create(photoPath)
if err != nil {
s.logger.Error("Failed to create file", zap.String("path", photoPath), zap.Error(err))
return nil, err
}
defer out.Close()
// 重置文件指针
file.Seek(0, 0)
// 复制文件内容
size, err := io.Copy(out, file)
if err != nil {
s.logger.Error("Failed to copy file", zap.Error(err))
return nil, err
}
// 获取文件信息
fileInfo, err := out.Stat()
if err != nil {
s.logger.Error("Failed to get file info", zap.Error(err))
return nil, err
}
uploadedFile := &UploadedFile{
Filename: filename,
OriginalURL: s.GetPhotoURL(filename),
Size: size,
MimeType: s.getMimeType(filename),
}
s.logger.Info("Photo uploaded successfully",
zap.String("filename", filename),
zap.Int64("size", size))
return uploadedFile, nil
}
// DeletePhoto 删除照片
func (s *LocalStorageService) DeletePhoto(filename string) error {
// 删除原图
photoPath := filepath.Join(s.uploadDir, "photos", filename)
if err := os.Remove(photoPath); err != nil && !os.IsNotExist(err) {
s.logger.Error("Failed to delete photo", zap.String("path", photoPath), zap.Error(err))
return err
}
// 删除缩略图
thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename)
if err := os.Remove(thumbnailPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to delete thumbnail", zap.String("path", thumbnailPath), zap.Error(err))
}
s.logger.Info("Photo deleted successfully", zap.String("filename", filename))
return nil
}
// GetPhotoURL 获取照片 URL
func (s *LocalStorageService) GetPhotoURL(filename string) string {
return fmt.Sprintf("%s/photos/%s", s.baseURL, filename)
}
// GetThumbnailURL 获取缩略图 URL
func (s *LocalStorageService) GetThumbnailURL(filename string) string {
return fmt.Sprintf("%s/thumbnails/%s", s.baseURL, filename)
}
// GenerateThumbnail 生成缩略图
func (s *LocalStorageService) GenerateThumbnail(ctx context.Context, filename string) error {
// TODO: 实现缩略图生成逻辑
// 这里需要使用图像处理库,如 imaging 或 bild
s.logger.Info("Generating thumbnail", zap.String("filename", filename))
// 示例实现 - 实际项目中应该使用图像处理库
photoPath := filepath.Join(s.uploadDir, "photos", filename)
thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename)
// 检查原图是否存在
if _, err := os.Stat(photoPath); os.IsNotExist(err) {
return fmt.Errorf("original photo not found: %s", filename)
}
// 这里应该实现实际的缩略图生成逻辑
// 暂时复制原图作为缩略图
sourceFile, err := os.Open(photoPath)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(thumbnailPath)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
s.logger.Info("Thumbnail generated successfully", zap.String("filename", filename))
return nil
}
// getMimeType 根据文件扩展名获取 MIME 类型
func (s *LocalStorageService) getMimeType(filename string) string {
ext := filepath.Ext(filename)
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".bmp":
return "image/bmp"
default:
return "application/octet-stream"
}
}
// NewStorageService 根据配置创建存储服务
func NewStorageService(config *config.Config, logger *zap.Logger) StorageService {
switch config.Upload.Type {
case "s3":
// TODO: 实现 S3 存储服务
logger.Warn("S3 storage not implemented yet, using local storage")
return NewLocalStorageService(config, logger)
case "minio":
// TODO: 实现 MinIO 存储服务
logger.Warn("MinIO storage not implemented yet, using local storage")
return NewLocalStorageService(config, logger)
default:
return NewLocalStorageService(config, logger)
}
}

View File

@ -0,0 +1,482 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"photography-backend/internal/models"
"photography-backend/internal/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
type TagService struct {
db *gorm.DB
logger *zap.Logger
}
func NewTagService(db *gorm.DB, logger *zap.Logger) *TagService {
return &TagService{
db: db,
logger: logger,
}
}
// TagListParams 标签列表查询参数
type TagListParams struct {
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
Search string `json:"search" form:"search"`
IsActive *bool `json:"is_active" form:"is_active"`
SortBy string `json:"sort_by" form:"sort_by"`
SortOrder string `json:"sort_order" form:"sort_order"`
}
// TagListResponse 标签列表响应
type TagListResponse struct {
Tags []models.Tag `json:"tags"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Pages int `json:"pages"`
}
// GetTags 获取标签列表
func (s *TagService) GetTags(ctx context.Context, params TagListParams) (*TagListResponse, error) {
// 设置默认值
if params.Page <= 0 {
params.Page = 1
}
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
// 构建查询
query := s.db.WithContext(ctx)
// 搜索过滤
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
query = query.Where("name ILIKE ? OR slug ILIKE ?", searchPattern, searchPattern)
}
// 状态过滤
if params.IsActive != nil {
query = query.Where("is_active = ?", *params.IsActive)
}
// 排序
sortBy := "created_at"
sortOrder := "desc"
if params.SortBy != "" {
allowedSortFields := []string{"created_at", "updated_at", "name", "photo_count"}
if utils.Contains(allowedSortFields, params.SortBy) {
sortBy = params.SortBy
}
}
if params.SortOrder == "asc" {
sortOrder = "asc"
}
// 计算总数
var total int64
countQuery := query
if err := countQuery.Model(&models.Tag{}).Count(&total).Error; err != nil {
s.logger.Error("Failed to count tags", zap.Error(err))
return nil, err
}
// 分页查询
offset := (params.Page - 1) * params.Limit
var tags []models.Tag
if err := query.
Order(fmt.Sprintf("%s %s", sortBy, sortOrder)).
Offset(offset).
Limit(params.Limit).
Find(&tags).Error; err != nil {
s.logger.Error("Failed to get tags", zap.Error(err))
return nil, err
}
// 计算总页数
pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
return &TagListResponse{
Tags: tags,
Total: total,
Page: params.Page,
Limit: params.Limit,
Pages: pages,
}, nil
}
// GetAllTags 获取所有活跃标签
func (s *TagService) GetAllTags(ctx context.Context) ([]models.Tag, error) {
var tags []models.Tag
if err := s.db.WithContext(ctx).
Where("is_active = ?", true).
Order("name ASC").
Find(&tags).Error; err != nil {
s.logger.Error("Failed to get all tags", zap.Error(err))
return nil, err
}
return tags, nil
}
// GetTagByID 根据ID获取标签
func (s *TagService) GetTagByID(ctx context.Context, id uint) (*models.Tag, error) {
var tag models.Tag
if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("tag not found")
}
s.logger.Error("Failed to get tag by ID", zap.Error(err), zap.Uint("id", id))
return nil, err
}
return &tag, nil
}
// GetTagBySlug 根据slug获取标签
func (s *TagService) GetTagBySlug(ctx context.Context, slug string) (*models.Tag, error) {
var tag models.Tag
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("tag not found")
}
s.logger.Error("Failed to get tag by slug", zap.Error(err), zap.String("slug", slug))
return nil, err
}
return &tag, nil
}
// CreateTag 创建标签
func (s *TagService) CreateTag(ctx context.Context, req *models.CreateTagRequest) (*models.Tag, error) {
// 验证slug唯一性
if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil {
return nil, err
}
tag := &models.Tag{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
Color: req.Color,
IsActive: true,
}
if err := s.db.WithContext(ctx).Create(tag).Error; err != nil {
s.logger.Error("Failed to create tag", zap.Error(err))
return nil, err
}
s.logger.Info("Tag created successfully", zap.Uint("id", tag.ID))
return tag, nil
}
// UpdateTag 更新标签
func (s *TagService) UpdateTag(ctx context.Context, id uint, req *models.UpdateTagRequest) (*models.Tag, error) {
// 检查标签是否存在
var tag models.Tag
if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("tag not found")
}
return nil, err
}
// 验证slug唯一性
if req.Slug != nil && *req.Slug != tag.Slug {
if err := s.validateSlugUnique(ctx, *req.Slug, id); err != nil {
return nil, err
}
}
// 构建更新数据
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Slug != nil {
updates["slug"] = *req.Slug
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Color != nil {
updates["color"] = *req.Color
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&tag).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update tag", zap.Error(err))
return nil, err
}
}
s.logger.Info("Tag updated successfully", zap.Uint("id", id))
return &tag, nil
}
// DeleteTag 删除标签
func (s *TagService) DeleteTag(ctx context.Context, id uint) error {
// 检查标签是否存在
var tag models.Tag
if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("tag not found")
}
return err
}
// 检查是否有关联的照片
var photoCount int64
if err := s.db.WithContext(ctx).Table("photo_tags").
Where("tag_id = ?", id).Count(&photoCount).Error; err != nil {
return err
}
if photoCount > 0 {
return errors.New("cannot delete tag with associated photos")
}
// 删除标签
if err := s.db.WithContext(ctx).Delete(&tag).Error; err != nil {
s.logger.Error("Failed to delete tag", zap.Error(err))
return err
}
s.logger.Info("Tag deleted successfully", zap.Uint("id", id))
return nil
}
// BatchDeleteTags 批量删除标签
func (s *TagService) BatchDeleteTags(ctx context.Context, ids []uint) error {
if len(ids) == 0 {
return errors.New("no tags to delete")
}
// 检查是否有关联的照片
var photoCount int64
if err := s.db.WithContext(ctx).Table("photo_tags").
Where("tag_id IN ?", ids).Count(&photoCount).Error; err != nil {
return err
}
if photoCount > 0 {
return errors.New("cannot delete tags with associated photos")
}
// 删除标签
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&models.Tag{}).Error; err != nil {
s.logger.Error("Failed to batch delete tags", zap.Error(err))
return err
}
s.logger.Info("Batch delete tags completed", zap.Int("count", len(ids)))
return nil
}
// GetPopularTags 获取热门标签
func (s *TagService) GetPopularTags(ctx context.Context, limit int) ([]models.TagWithCount, error) {
if limit <= 0 {
limit = 10
}
var tags []models.TagWithCount
if err := s.db.WithContext(ctx).
Table("tags").
Select("tags.*, COUNT(photo_tags.photo_id) as photo_count").
Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id").
Where("tags.is_active = ?", true).
Group("tags.id").
Order("photo_count DESC").
Limit(limit).
Find(&tags).Error; err != nil {
s.logger.Error("Failed to get popular tags", zap.Error(err))
return nil, err
}
return tags, nil
}
// GetTagCloud 获取标签云数据
func (s *TagService) GetTagCloud(ctx context.Context) ([]models.TagCloudItem, error) {
var items []models.TagCloudItem
if err := s.db.WithContext(ctx).
Table("tags").
Select("tags.name, tags.slug, tags.color, COUNT(photo_tags.photo_id) as count").
Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id").
Where("tags.is_active = ?", true).
Group("tags.id, tags.name, tags.slug, tags.color").
Having("COUNT(photo_tags.photo_id) > 0").
Order("count DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to get tag cloud", zap.Error(err))
return nil, err
}
return items, nil
}
// GetTagStats 获取标签统计信息
func (s *TagService) GetTagStats(ctx context.Context) (*models.TagStats, error) {
var stats models.TagStats
// 总标签数
if err := s.db.WithContext(ctx).Model(&models.Tag{}).Count(&stats.Total).Error; err != nil {
return nil, err
}
// 活跃标签数
if err := s.db.WithContext(ctx).Model(&models.Tag{}).
Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
return nil, err
}
// 已使用标签数
if err := s.db.WithContext(ctx).
Table("tags").
Joins("JOIN photo_tags ON tags.id = photo_tags.tag_id").
Where("tags.is_active = ?", true).
Group("tags.id").
Count(&stats.Used).Error; err != nil {
return nil, err
}
// 未使用标签数
stats.Unused = stats.Active - stats.Used
// 平均每个标签的照片数
var totalPhotos int64
if err := s.db.WithContext(ctx).Table("photo_tags").
Joins("JOIN tags ON photo_tags.tag_id = tags.id").
Where("tags.is_active = ?", true).
Count(&totalPhotos).Error; err != nil {
return nil, err
}
if stats.Used > 0 {
stats.AvgPhotosPerTag = float64(totalPhotos) / float64(stats.Used)
}
return &stats, nil
}
// SearchTags 搜索标签
func (s *TagService) SearchTags(ctx context.Context, query string, limit int) ([]models.Tag, error) {
if limit <= 0 {
limit = 10
}
var tags []models.Tag
searchPattern := "%" + query + "%"
if err := s.db.WithContext(ctx).
Where("is_active = ? AND (name ILIKE ? OR slug ILIKE ?)", true, searchPattern, searchPattern).
Order("name ASC").
Limit(limit).
Find(&tags).Error; err != nil {
s.logger.Error("Failed to search tags", zap.Error(err))
return nil, err
}
return tags, nil
}
// CreateTagsFromNames 从名称列表创建标签
func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([]models.Tag, error) {
var tags []models.Tag
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
// 生成slug
slug, err := s.GenerateSlug(ctx, name)
if err != nil {
s.logger.Error("Failed to generate slug", zap.Error(err))
continue
}
// 检查标签是否已存在
var existingTag models.Tag
if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&existingTag).Error; err == nil {
tags = append(tags, existingTag)
continue
}
// 创建新标签
tag := models.Tag{
Name: name,
Slug: slug,
IsActive: true,
}
if err := s.db.WithContext(ctx).Create(&tag).Error; err != nil {
s.logger.Error("Failed to create tag", zap.Error(err))
continue
}
tags = append(tags, tag)
}
return tags, nil
}
// validateSlugUnique 验证slug唯一性
func (s *TagService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error {
var count int64
query := s.db.WithContext(ctx).Model(&models.Tag{}).Where("slug = ?", slug)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("slug already exists")
}
return nil
}
// GenerateSlug 生成slug
func (s *TagService) GenerateSlug(ctx context.Context, name string) (string, error) {
baseSlug := utils.GenerateSlug(name)
slug := baseSlug
counter := 1
for {
var count int64
if err := s.db.WithContext(ctx).Model(&models.Tag{}).
Where("slug = ?", slug).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
break
}
slug = fmt.Sprintf("%s-%d", baseSlug, counter)
counter++
}
return slug, nil
}

View File

@ -0,0 +1,433 @@
package service
import (
"context"
"errors"
"fmt"
"photography-backend/internal/models"
"photography-backend/internal/utils"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserService struct {
db *gorm.DB
logger *zap.Logger
}
func NewUserService(db *gorm.DB, logger *zap.Logger) *UserService {
return &UserService{
db: db,
logger: logger,
}
}
// UserListParams 用户列表查询参数
type UserListParams struct {
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
Search string `json:"search" form:"search"`
Role string `json:"role" form:"role"`
IsActive *bool `json:"is_active" form:"is_active"`
}
// UserListResponse 用户列表响应
type UserListResponse struct {
Users []models.User `json:"users"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Pages int `json:"pages"`
}
// GetUsers 获取用户列表
func (s *UserService) GetUsers(ctx context.Context, params UserListParams) (*UserListResponse, error) {
// 设置默认值
if params.Page <= 0 {
params.Page = 1
}
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
// 构建查询
query := s.db.WithContext(ctx)
// 搜索过滤
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
query = query.Where("username ILIKE ? OR email ILIKE ?", searchPattern, searchPattern)
}
// 角色过滤
if params.Role != "" {
query = query.Where("role = ?", params.Role)
}
// 状态过滤
if params.IsActive != nil {
query = query.Where("is_active = ?", *params.IsActive)
}
// 计算总数
var total int64
countQuery := query
if err := countQuery.Model(&models.User{}).Count(&total).Error; err != nil {
s.logger.Error("Failed to count users", zap.Error(err))
return nil, err
}
// 分页查询
offset := (params.Page - 1) * params.Limit
var users []models.User
if err := query.
Order("created_at DESC").
Offset(offset).
Limit(params.Limit).
Find(&users).Error; err != nil {
s.logger.Error("Failed to get users", zap.Error(err))
return nil, err
}
// 计算总页数
pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
return &UserListResponse{
Users: users,
Total: total,
Page: params.Page,
Limit: params.Limit,
Pages: pages,
}, nil
}
// GetUserByID 根据ID获取用户
func (s *UserService) GetUserByID(ctx context.Context, id uint) (*models.User, error) {
var user models.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
s.logger.Error("Failed to get user by ID", zap.Error(err), zap.Uint("id", id))
return nil, err
}
return &user, nil
}
// GetUserByUsername 根据用户名获取用户
func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
var user models.User
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
s.logger.Error("Failed to get user by username", zap.Error(err), zap.String("username", username))
return nil, err
}
return &user, nil
}
// GetUserByEmail 根据邮箱获取用户
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
var user models.User
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
s.logger.Error("Failed to get user by email", zap.Error(err), zap.String("email", email))
return nil, err
}
return &user, nil
}
// CreateUser 创建用户
func (s *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequest) (*models.User, error) {
// 验证用户名唯一性
var existingUser models.User
if err := s.db.WithContext(ctx).Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
return nil, errors.New("username already exists")
}
// 验证邮箱唯一性
if err := s.db.WithContext(ctx).Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
return nil, errors.New("email already exists")
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash password", zap.Error(err))
return nil, err
}
user := &models.User{
Username: req.Username,
Email: req.Email,
Password: string(hashedPassword),
Role: req.Role,
IsActive: true,
}
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
s.logger.Error("Failed to create user", zap.Error(err))
return nil, err
}
s.logger.Info("User created successfully", zap.Uint("id", user.ID))
return user, nil
}
// UpdateUser 更新用户
func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.UpdateUserRequest) (*models.User, error) {
// 检查用户是否存在
var user models.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
return nil, err
}
// 构建更新数据
updates := map[string]interface{}{}
if req.Username != nil {
// 验证用户名唯一性
var existingUser models.User
if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil {
return nil, errors.New("username already exists")
}
updates["username"] = *req.Username
}
if req.Email != nil {
// 验证邮箱唯一性
var existingUser models.User
if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil {
return nil, errors.New("email already exists")
}
updates["email"] = *req.Email
}
if req.Role != nil {
updates["role"] = *req.Role
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update user", zap.Error(err))
return nil, err
}
}
s.logger.Info("User updated successfully", zap.Uint("id", id))
return &user, nil
}
// UpdateCurrentUser 更新当前用户信息
func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *models.UpdateCurrentUserRequest) (*models.User, error) {
// 检查用户是否存在
var user models.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
return nil, err
}
// 构建更新数据
updates := map[string]interface{}{}
if req.Username != nil {
// 验证用户名唯一性
var existingUser models.User
if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil {
return nil, errors.New("username already exists")
}
updates["username"] = *req.Username
}
if req.Email != nil {
// 验证邮箱唯一性
var existingUser models.User
if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil {
return nil, errors.New("email already exists")
}
updates["email"] = *req.Email
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update current user", zap.Error(err))
return nil, err
}
}
s.logger.Info("Current user updated successfully", zap.Uint("id", id))
return &user, nil
}
// DeleteUser 删除用户
func (s *UserService) DeleteUser(ctx context.Context, id uint) error {
// 检查用户是否存在
var user models.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("user not found")
}
return err
}
// 删除用户
if err := s.db.WithContext(ctx).Delete(&user).Error; err != nil {
s.logger.Error("Failed to delete user", zap.Error(err))
return err
}
s.logger.Info("User deleted successfully", zap.Uint("id", id))
return nil
}
// ChangePassword 修改密码
func (s *UserService) ChangePassword(ctx context.Context, id uint, req *models.ChangePasswordRequest) error {
// 检查用户是否存在
var user models.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("user not found")
}
return err
}
// 验证旧密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
return errors.New("old password is incorrect")
}
// 加密新密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash new password", zap.Error(err))
return err
}
// 更新密码
if err := s.db.WithContext(ctx).Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
s.logger.Error("Failed to update password", zap.Error(err))
return err
}
s.logger.Info("Password changed successfully", zap.Uint("id", id))
return nil
}
// ValidateCredentials 验证用户凭据
func (s *UserService) ValidateCredentials(ctx context.Context, username, password string) (*models.User, error) {
var user models.User
// 根据用户名或邮箱查找用户
if err := s.db.WithContext(ctx).Where("username = ? OR email = ?", username, username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid credentials")
}
s.logger.Error("Failed to find user", zap.Error(err))
return nil, err
}
// 检查用户是否激活
if !user.IsActive {
return nil, errors.New("user account is disabled")
}
// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
return &user, nil
}
// GetUserStats 获取用户统计信息
func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, error) {
var stats models.UserStats
// 总用户数
if err := s.db.WithContext(ctx).Model(&models.User{}).Count(&stats.Total).Error; err != nil {
return nil, err
}
// 活跃用户数
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
return nil, err
}
// 按角色统计
var roleStats []struct {
Role string `json:"role"`
Count int64 `json:"count"`
}
if err := s.db.WithContext(ctx).Model(&models.User{}).
Select("role, COUNT(*) as count").
Where("is_active = ?", true).
Group("role").
Find(&roleStats).Error; err != nil {
return nil, err
}
stats.RoleStats = make(map[string]int64)
for _, stat := range roleStats {
stats.RoleStats[stat.Role] = stat.Count
}
// 本月新增用户
startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1)
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("created_at >= ?", startOfMonth).
Count(&stats.ThisMonth).Error; err != nil {
return nil, err
}
// 今日新增用户
startOfDay := time.Now().Truncate(24 * time.Hour)
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("created_at >= ?", startOfDay).
Count(&stats.Today).Error; err != nil {
return nil, err
}
return &stats, nil
}
// IsUsernameAvailable 检查用户名是否可用
func (s *UserService) IsUsernameAvailable(ctx context.Context, username string) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
return count == 0, nil
}
// IsEmailAvailable 检查邮箱是否可用
func (s *UserService) IsEmailAvailable(ctx context.Context, email string) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&models.User{}).
Where("email = ?", email).Count(&count).Error; err != nil {
return false, err
}
return count == 0, nil
}