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:
434
backend/internal/api/handlers/category_handler.go
Normal file
434
backend/internal/api/handlers/category_handler.go
Normal file
@ -0,0 +1,434 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type CategoryHandler struct {
|
||||
categoryService *service.CategoryService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCategoryHandler(categoryService *service.CategoryService, logger *zap.Logger) *CategoryHandler {
|
||||
return &CategoryHandler{
|
||||
categoryService: categoryService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCategories 获取分类列表
|
||||
// @Summary 获取分类列表
|
||||
// @Description 获取分类列表,可指定父分类
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param parent_id query int false "父分类ID"
|
||||
// @Success 200 {array} models.Category
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories [get]
|
||||
func (h *CategoryHandler) GetCategories(c *gin.Context) {
|
||||
var parentID *uint
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
id, err := strconv.ParseUint(parentIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid parent_id",
|
||||
Message: "Parent ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
parentIDUint := uint(id)
|
||||
parentID = &parentIDUint
|
||||
}
|
||||
|
||||
categories, err := h.categoryService.GetCategories(c.Request.Context(), parentID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get categories", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get categories",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
|
||||
// GetCategoryTree 获取分类树
|
||||
// @Summary 获取分类树
|
||||
// @Description 获取完整的分类树结构
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.CategoryTree
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/tree [get]
|
||||
func (h *CategoryHandler) GetCategoryTree(c *gin.Context) {
|
||||
tree, err := h.categoryService.GetCategoryTree(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get category tree", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get category tree",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
// GetCategory 获取分类详情
|
||||
// @Summary 获取分类详情
|
||||
// @Description 根据ID获取分类详情
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "分类ID"
|
||||
// @Success 200 {object} models.Category
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/{id} [get]
|
||||
func (h *CategoryHandler) GetCategory(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid category ID",
|
||||
Message: "Category ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.categoryService.GetCategoryByID(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "category not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Category not found",
|
||||
Message: "The requested category does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get category", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get category",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, category)
|
||||
}
|
||||
|
||||
// GetCategoryBySlug 根据slug获取分类
|
||||
// @Summary 根据slug获取分类
|
||||
// @Description 根据slug获取分类详情
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param slug path string true "分类slug"
|
||||
// @Success 200 {object} models.Category
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/slug/{slug} [get]
|
||||
func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
category, err := h.categoryService.GetCategoryBySlug(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
if err.Error() == "category not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Category not found",
|
||||
Message: "The requested category does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get category by slug", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get category",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, category)
|
||||
}
|
||||
|
||||
// CreateCategory 创建分类
|
||||
// @Summary 创建分类
|
||||
// @Description 创建新的分类
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param category body models.CreateCategoryRequest true "分类信息"
|
||||
// @Success 201 {object} models.Category
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories [post]
|
||||
func (h *CategoryHandler) CreateCategory(c *gin.Context) {
|
||||
var req models.CreateCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
if err := h.validateCreateCategoryRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request data",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.categoryService.CreateCategory(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create category", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to create category",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, category)
|
||||
}
|
||||
|
||||
// UpdateCategory 更新分类
|
||||
// @Summary 更新分类
|
||||
// @Description 更新分类信息
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "分类ID"
|
||||
// @Param category body models.UpdateCategoryRequest true "分类信息"
|
||||
// @Success 200 {object} models.Category
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/{id} [put]
|
||||
func (h *CategoryHandler) UpdateCategory(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid category ID",
|
||||
Message: "Category ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := h.categoryService.UpdateCategory(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if err.Error() == "category not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Category not found",
|
||||
Message: "The requested category does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to update category", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to update category",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, category)
|
||||
}
|
||||
|
||||
// DeleteCategory 删除分类
|
||||
// @Summary 删除分类
|
||||
// @Description 删除分类
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "分类ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/{id} [delete]
|
||||
func (h *CategoryHandler) DeleteCategory(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid category ID",
|
||||
Message: "Category ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.categoryService.DeleteCategory(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "category not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Category not found",
|
||||
Message: "The requested category does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to delete category", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to delete category",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ReorderCategories 重新排序分类
|
||||
// @Summary 重新排序分类
|
||||
// @Description 重新排序分类
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.ReorderCategoriesRequest true "排序请求"
|
||||
// @Success 200 {object} models.SuccessResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/reorder [post]
|
||||
func (h *CategoryHandler) ReorderCategories(c *gin.Context) {
|
||||
var req models.ReorderCategoriesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.CategoryIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "No category IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.categoryService.ReorderCategories(c.Request.Context(), req.ParentID, req.CategoryIDs)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to reorder categories", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to reorder categories",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{
|
||||
Message: "Categories reordered successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetCategoryStats 获取分类统计信息
|
||||
// @Summary 获取分类统计信息
|
||||
// @Description 获取分类统计信息
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.CategoryStats
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/stats [get]
|
||||
func (h *CategoryHandler) GetCategoryStats(c *gin.Context) {
|
||||
stats, err := h.categoryService.GetCategoryStats(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get category stats", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get category stats",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GenerateSlug 生成分类slug
|
||||
// @Summary 生成分类slug
|
||||
// @Description 根据分类名称生成唯一的slug
|
||||
// @Tags categories
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.GenerateSlugRequest true "生成slug请求"
|
||||
// @Success 200 {object} models.GenerateSlugResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /categories/generate-slug [post]
|
||||
func (h *CategoryHandler) GenerateSlug(c *gin.Context) {
|
||||
var req models.GenerateSlugRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "Name is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
slug, err := h.categoryService.GenerateSlug(c.Request.Context(), req.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to generate slug", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to generate slug",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.GenerateSlugResponse{
|
||||
Slug: slug,
|
||||
})
|
||||
}
|
||||
|
||||
// validateCreateCategoryRequest 验证创建分类请求
|
||||
func (h *CategoryHandler) validateCreateCategoryRequest(req *models.CreateCategoryRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
|
||||
if req.Slug == "" {
|
||||
return errors.New("slug is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
479
backend/internal/api/handlers/photo_handler.go
Normal file
479
backend/internal/api/handlers/photo_handler.go
Normal file
@ -0,0 +1,479 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PhotoHandler struct {
|
||||
photoService *service.PhotoService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewPhotoHandler(photoService *service.PhotoService, logger *zap.Logger) *PhotoHandler {
|
||||
return &PhotoHandler{
|
||||
photoService: photoService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPhotos 获取照片列表
|
||||
// @Summary 获取照片列表
|
||||
// @Description 获取照片列表,支持分页、搜索、过滤
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "页码"
|
||||
// @Param limit query int false "每页数量"
|
||||
// @Param search query string false "搜索关键词"
|
||||
// @Param status query string false "状态筛选"
|
||||
// @Param category_id query int false "分类ID"
|
||||
// @Param tags query string false "标签列表(逗号分隔)"
|
||||
// @Param start_date query string false "开始日期"
|
||||
// @Param end_date query string false "结束日期"
|
||||
// @Param sort_by query string false "排序字段"
|
||||
// @Param sort_order query string false "排序方向"
|
||||
// @Success 200 {object} service.PhotoListResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos [get]
|
||||
func (h *PhotoHandler) GetPhotos(c *gin.Context) {
|
||||
var params service.PhotoListParams
|
||||
|
||||
// 解析查询参数
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
h.logger.Error("Failed to bind query params", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析标签参数
|
||||
if tagsStr := c.Query("tags"); tagsStr != "" {
|
||||
params.Tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.photoService.GetPhotos(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get photos", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get photos",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetPhoto 获取照片详情
|
||||
// @Summary 获取照片详情
|
||||
// @Description 根据ID获取照片详情
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "照片ID"
|
||||
// @Success 200 {object} models.Photo
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/{id} [get]
|
||||
func (h *PhotoHandler) GetPhoto(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid photo ID",
|
||||
Message: "Photo ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := h.photoService.GetPhotoByID(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "photo not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Photo not found",
|
||||
Message: "The requested photo does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get photo", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get photo",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, photo)
|
||||
}
|
||||
|
||||
// CreatePhoto 创建照片
|
||||
// @Summary 创建照片
|
||||
// @Description 创建新的照片记录
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param photo body models.CreatePhotoRequest true "照片信息"
|
||||
// @Success 201 {object} models.Photo
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos [post]
|
||||
func (h *PhotoHandler) CreatePhoto(c *gin.Context) {
|
||||
var req models.CreatePhotoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
if err := h.validateCreatePhotoRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request data",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := h.photoService.CreatePhoto(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create photo", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to create photo",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, photo)
|
||||
}
|
||||
|
||||
// UpdatePhoto 更新照片
|
||||
// @Summary 更新照片
|
||||
// @Description 更新照片信息
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "照片ID"
|
||||
// @Param photo body models.UpdatePhotoRequest true "照片信息"
|
||||
// @Success 200 {object} models.Photo
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/{id} [put]
|
||||
func (h *PhotoHandler) UpdatePhoto(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid photo ID",
|
||||
Message: "Photo ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdatePhotoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := h.photoService.UpdatePhoto(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if err.Error() == "photo not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Photo not found",
|
||||
Message: "The requested photo does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to update photo", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to update photo",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, photo)
|
||||
}
|
||||
|
||||
// DeletePhoto 删除照片
|
||||
// @Summary 删除照片
|
||||
// @Description 删除照片
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "照片ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/{id} [delete]
|
||||
func (h *PhotoHandler) DeletePhoto(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid photo ID",
|
||||
Message: "Photo ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.photoService.DeletePhoto(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "photo not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Photo not found",
|
||||
Message: "The requested photo does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to delete photo", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to delete photo",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UploadPhoto 上传照片
|
||||
// @Summary 上传照片
|
||||
// @Description 上传照片文件并创建记录
|
||||
// @Tags photos
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "照片文件"
|
||||
// @Param title formData string false "标题"
|
||||
// @Param description formData string false "描述"
|
||||
// @Param status formData string false "状态"
|
||||
// @Param category_ids formData string false "分类ID列表(逗号分隔)"
|
||||
// @Param tag_ids formData string false "标签ID列表(逗号分隔)"
|
||||
// @Success 201 {object} models.Photo
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/upload [post]
|
||||
func (h *PhotoHandler) UploadPhoto(c *gin.Context) {
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "No file uploaded",
|
||||
Message: "Please select a file to upload",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 构建创建请求
|
||||
req := &models.CreatePhotoRequest{
|
||||
Title: c.PostForm("title"),
|
||||
Description: c.PostForm("description"),
|
||||
Status: c.PostForm("status"),
|
||||
}
|
||||
|
||||
// 如果未指定状态,默认为草稿
|
||||
if req.Status == "" {
|
||||
req.Status = "draft"
|
||||
}
|
||||
|
||||
// 解析分类ID
|
||||
if categoryIDsStr := c.PostForm("category_ids"); categoryIDsStr != "" {
|
||||
categoryIDStrs := strings.Split(categoryIDsStr, ",")
|
||||
for _, idStr := range categoryIDStrs {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {
|
||||
req.CategoryIDs = append(req.CategoryIDs, uint(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析标签ID
|
||||
if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
|
||||
tagIDStrs := strings.Split(tagIDsStr, ",")
|
||||
for _, idStr := range tagIDStrs {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {
|
||||
req.TagIDs = append(req.TagIDs, uint(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传照片
|
||||
photo, err := h.photoService.UploadPhoto(c.Request.Context(), file, header, req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to upload photo", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to upload photo",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, photo)
|
||||
}
|
||||
|
||||
// BatchUpdatePhotos 批量更新照片
|
||||
// @Summary 批量更新照片
|
||||
// @Description 批量更新照片信息
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.BatchUpdatePhotosRequest true "批量更新请求"
|
||||
// @Success 200 {object} models.SuccessResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/batch/update [post]
|
||||
func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) {
|
||||
var req models.BatchUpdatePhotosRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "No photo IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.photoService.BatchUpdatePhotos(c.Request.Context(), req.IDs, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to batch update photos", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to batch update photos",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{
|
||||
Message: "Photos updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchDeletePhotos 批量删除照片
|
||||
// @Summary 批量删除照片
|
||||
// @Description 批量删除照片
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.BatchDeleteRequest true "批量删除请求"
|
||||
// @Success 200 {object} models.SuccessResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/batch/delete [post]
|
||||
func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) {
|
||||
var req models.BatchDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "No photo IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.photoService.BatchDeletePhotos(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to batch delete photos", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to batch delete photos",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{
|
||||
Message: "Photos deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPhotoStats 获取照片统计信息
|
||||
// @Summary 获取照片统计信息
|
||||
// @Description 获取照片统计信息
|
||||
// @Tags photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.PhotoStats
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /photos/stats [get]
|
||||
func (h *PhotoHandler) GetPhotoStats(c *gin.Context) {
|
||||
stats, err := h.photoService.GetPhotoStats(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get photo stats", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get photo stats",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// validateCreatePhotoRequest 验证创建照片请求
|
||||
func (h *PhotoHandler) validateCreatePhotoRequest(req *models.CreatePhotoRequest) error {
|
||||
if req.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
req.Status = "draft"
|
||||
}
|
||||
|
||||
// 验证状态值
|
||||
validStatuses := []string{"draft", "published", "archived", "processing"}
|
||||
isValidStatus := false
|
||||
for _, status := range validStatuses {
|
||||
if req.Status == status {
|
||||
isValidStatus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidStatus {
|
||||
return errors.New("invalid status value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
535
backend/internal/api/handlers/tag_handler.go
Normal file
535
backend/internal/api/handlers/tag_handler.go
Normal file
@ -0,0 +1,535 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type TagHandler struct {
|
||||
tagService *service.TagService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewTagHandler(tagService *service.TagService, logger *zap.Logger) *TagHandler {
|
||||
return &TagHandler{
|
||||
tagService: tagService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTags 获取标签列表
|
||||
// @Summary 获取标签列表
|
||||
// @Description 获取标签列表,支持分页、搜索、过滤
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "页码"
|
||||
// @Param limit query int false "每页数量"
|
||||
// @Param search query string false "搜索关键词"
|
||||
// @Param is_active query bool false "是否激活"
|
||||
// @Param sort_by query string false "排序字段"
|
||||
// @Param sort_order query string false "排序方向"
|
||||
// @Success 200 {object} service.TagListResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags [get]
|
||||
func (h *TagHandler) GetTags(c *gin.Context) {
|
||||
var params service.TagListParams
|
||||
|
||||
// 解析查询参数
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
h.logger.Error("Failed to bind query params", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.tagService.GetTags(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get tags", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get tags",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetAllTags 获取所有活跃标签
|
||||
// @Summary 获取所有活跃标签
|
||||
// @Description 获取所有活跃标签(用于选择器)
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Tag
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/all [get]
|
||||
func (h *TagHandler) GetAllTags(c *gin.Context) {
|
||||
tags, err := h.tagService.GetAllTags(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get all tags", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get all tags",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// GetTag 获取标签详情
|
||||
// @Summary 获取标签详情
|
||||
// @Description 根据ID获取标签详情
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "标签ID"
|
||||
// @Success 200 {object} models.Tag
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/{id} [get]
|
||||
func (h *TagHandler) GetTag(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid tag ID",
|
||||
Message: "Tag ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := h.tagService.GetTagByID(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "tag not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Tag not found",
|
||||
Message: "The requested tag does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get tag", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get tag",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tag)
|
||||
}
|
||||
|
||||
// GetTagBySlug 根据slug获取标签
|
||||
// @Summary 根据slug获取标签
|
||||
// @Description 根据slug获取标签详情
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param slug path string true "标签slug"
|
||||
// @Success 200 {object} models.Tag
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/slug/{slug} [get]
|
||||
func (h *TagHandler) GetTagBySlug(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
|
||||
tag, err := h.tagService.GetTagBySlug(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
if err.Error() == "tag not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Tag not found",
|
||||
Message: "The requested tag does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get tag by slug", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get tag",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tag)
|
||||
}
|
||||
|
||||
// CreateTag 创建标签
|
||||
// @Summary 创建标签
|
||||
// @Description 创建新的标签
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tag body models.CreateTagRequest true "标签信息"
|
||||
// @Success 201 {object} models.Tag
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags [post]
|
||||
func (h *TagHandler) CreateTag(c *gin.Context) {
|
||||
var req models.CreateTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
if err := h.validateCreateTagRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request data",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := h.tagService.CreateTag(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create tag", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to create tag",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, tag)
|
||||
}
|
||||
|
||||
// UpdateTag 更新标签
|
||||
// @Summary 更新标签
|
||||
// @Description 更新标签信息
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "标签ID"
|
||||
// @Param tag body models.UpdateTagRequest true "标签信息"
|
||||
// @Success 200 {object} models.Tag
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/{id} [put]
|
||||
func (h *TagHandler) UpdateTag(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid tag ID",
|
||||
Message: "Tag ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := h.tagService.UpdateTag(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if err.Error() == "tag not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Tag not found",
|
||||
Message: "The requested tag does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to update tag", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to update tag",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tag)
|
||||
}
|
||||
|
||||
// DeleteTag 删除标签
|
||||
// @Summary 删除标签
|
||||
// @Description 删除标签
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "标签ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/{id} [delete]
|
||||
func (h *TagHandler) DeleteTag(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid tag ID",
|
||||
Message: "Tag ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.tagService.DeleteTag(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "tag not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "Tag not found",
|
||||
Message: "The requested tag does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to delete tag", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to delete tag",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// BatchDeleteTags 批量删除标签
|
||||
// @Summary 批量删除标签
|
||||
// @Description 批量删除标签
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.BatchDeleteRequest true "批量删除请求"
|
||||
// @Success 200 {object} models.SuccessResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/batch/delete [post]
|
||||
func (h *TagHandler) BatchDeleteTags(c *gin.Context) {
|
||||
var req models.BatchDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "No tag IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.tagService.BatchDeleteTags(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to batch delete tags", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to batch delete tags",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.SuccessResponse{
|
||||
Message: "Tags deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPopularTags 获取热门标签
|
||||
// @Summary 获取热门标签
|
||||
// @Description 获取热门标签(按使用次数排序)
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "限制数量"
|
||||
// @Success 200 {array} models.TagWithCount
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/popular [get]
|
||||
func (h *TagHandler) GetPopularTags(c *gin.Context) {
|
||||
limit := 10
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := h.tagService.GetPopularTags(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get popular tags", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get popular tags",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// GetTagCloud 获取标签云
|
||||
// @Summary 获取标签云
|
||||
// @Description 获取标签云数据
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.TagCloudItem
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/cloud [get]
|
||||
func (h *TagHandler) GetTagCloud(c *gin.Context) {
|
||||
cloud, err := h.tagService.GetTagCloud(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get tag cloud", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get tag cloud",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cloud)
|
||||
}
|
||||
|
||||
// GetTagStats 获取标签统计信息
|
||||
// @Summary 获取标签统计信息
|
||||
// @Description 获取标签统计信息
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.TagStats
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/stats [get]
|
||||
func (h *TagHandler) GetTagStats(c *gin.Context) {
|
||||
stats, err := h.tagService.GetTagStats(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get tag stats", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get tag stats",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// SearchTags 搜索标签
|
||||
// @Summary 搜索标签
|
||||
// @Description 搜索标签(用于自动完成)
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param q query string true "搜索关键词"
|
||||
// @Param limit query int false "限制数量"
|
||||
// @Success 200 {array} models.Tag
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/search [get]
|
||||
func (h *TagHandler) SearchTags(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid query",
|
||||
Message: "Search query is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := h.tagService.SearchTags(c.Request.Context(), query, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to search tags", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to search tags",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// GenerateSlug 生成标签slug
|
||||
// @Summary 生成标签slug
|
||||
// @Description 根据标签名称生成唯一的slug
|
||||
// @Tags tags
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.GenerateSlugRequest true "生成slug请求"
|
||||
// @Success 200 {object} models.GenerateSlugResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /tags/generate-slug [post]
|
||||
func (h *TagHandler) GenerateSlug(c *gin.Context) {
|
||||
var req models.GenerateSlugRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request",
|
||||
Message: "Name is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
slug, err := h.tagService.GenerateSlug(c.Request.Context(), req.Name)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to generate slug", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to generate slug",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.GenerateSlugResponse{
|
||||
Slug: slug,
|
||||
})
|
||||
}
|
||||
|
||||
// validateCreateTagRequest 验证创建标签请求
|
||||
func (h *TagHandler) validateCreateTagRequest(req *models.CreateTagRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
|
||||
if req.Slug == "" {
|
||||
return errors.New("slug is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
409
backend/internal/api/handlers/user_handler.go
Normal file
409
backend/internal/api/handlers/user_handler.go
Normal file
@ -0,0 +1,409 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService *service.UserService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewUserHandler(userService *service.UserService, logger *zap.Logger) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser 获取当前用户信息
|
||||
// @Summary 获取当前用户信息
|
||||
// @Description 获取当前登录用户的详细信息
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.UserResponse
|
||||
// @Failure 401 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /me [get]
|
||||
func (h *UserHandler) GetCurrentUser(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
user, err := h.userService.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get current user", zap.Error(err), zap.Uint("user_id", userID))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get user information",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userResponse := &models.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
// UpdateCurrentUser 更新当前用户信息
|
||||
// @Summary 更新当前用户信息
|
||||
// @Description 更新当前登录用户的个人信息
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body models.UpdateCurrentUserRequest true "用户信息"
|
||||
// @Success 200 {object} models.UserResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /me [put]
|
||||
func (h *UserHandler) UpdateCurrentUser(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
var req models.UpdateCurrentUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.UpdateCurrentUser(c.Request.Context(), userID, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to update current user", zap.Error(err), zap.Uint("user_id", userID))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to update user information",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userResponse := &models.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
// GetUsers 获取用户列表 (管理员功能)
|
||||
// @Summary 获取用户列表
|
||||
// @Description 获取系统中所有用户列表
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param page query int false "页码"
|
||||
// @Param limit query int false "每页数量"
|
||||
// @Param search query string false "搜索关键词"
|
||||
// @Success 200 {object} service.UserListResponse
|
||||
// @Failure 403 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /admin/users [get]
|
||||
func (h *UserHandler) GetUsers(c *gin.Context) {
|
||||
var params service.UserListParams
|
||||
|
||||
// 解析查询参数
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
h.logger.Error("Failed to bind query params", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.userService.GetUsers(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get users", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get users",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetUser 获取用户详情 (管理员功能)
|
||||
// @Summary 获取用户详情
|
||||
// @Description 根据ID获取用户详情
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "用户ID"
|
||||
// @Success 200 {object} models.UserResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /admin/users/{id} [get]
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid user ID",
|
||||
Message: "User ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "User not found",
|
||||
Message: "The requested user does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to get user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to get user",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userResponse := &models.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
// CreateUser 创建用户 (管理员功能)
|
||||
// @Summary 创建用户
|
||||
// @Description 创建新用户
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body models.CreateUserRequest true "用户信息"
|
||||
// @Success 201 {object} models.UserResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /admin/users [post]
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
var req models.CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证请求数据
|
||||
if err := h.validateCreateUserRequest(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request data",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.CreateUser(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to create user",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userResponse := &models.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userResponse)
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户 (管理员功能)
|
||||
// @Summary 更新用户
|
||||
// @Description 更新用户信息
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "用户ID"
|
||||
// @Param user body models.UpdateUserRequest true "用户信息"
|
||||
// @Success 200 {object} models.UserResponse
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /admin/users/{id} [put]
|
||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid user ID",
|
||||
Message: "User ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind JSON", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid request body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.UpdateUser(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "User not found",
|
||||
Message: "The requested user does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to update user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to update user",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userResponse := &models.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户 (管理员功能)
|
||||
// @Summary 删除用户
|
||||
// @Description 删除用户
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "用户ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} models.ErrorResponse
|
||||
// @Failure 404 {object} models.ErrorResponse
|
||||
// @Failure 500 {object} models.ErrorResponse
|
||||
// @Router /admin/users/{id} [delete]
|
||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Invalid user ID",
|
||||
Message: "User ID must be a valid number",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 防止删除自己
|
||||
currentUserID := c.GetUint("user_id")
|
||||
if uint(id) == currentUserID {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Error: "Cannot delete yourself",
|
||||
Message: "You cannot delete your own account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.userService.DeleteUser(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Error: "User not found",
|
||||
Message: "The requested user does not exist",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Failed to delete user", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Error: "Failed to delete user",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// validateCreateUserRequest 验证创建用户请求
|
||||
func (h *UserHandler) validateCreateUserRequest(req *models.CreateUserRequest) error {
|
||||
if req.Username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
return errors.New("email is required")
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
return errors.New("password is required")
|
||||
}
|
||||
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
}
|
||||
|
||||
// 验证角色
|
||||
validRoles := []string{"user", "editor", "admin"}
|
||||
isValidRole := false
|
||||
for _, role := range validRoles {
|
||||
if req.Role == role {
|
||||
isValidRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidRole {
|
||||
return errors.New("invalid role value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,44 +1,131 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"photography-backend/internal/api/handlers"
|
||||
"photography-backend/internal/api/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Handlers 处理器集合
|
||||
type Handlers struct {
|
||||
Auth *handlers.AuthHandler
|
||||
AuthHandler *handlers.AuthHandler
|
||||
UserHandler *handlers.UserHandler
|
||||
PhotoHandler *handlers.PhotoHandler
|
||||
CategoryHandler *handlers.CategoryHandler
|
||||
TagHandler *handlers.TagHandler
|
||||
}
|
||||
|
||||
// SetupRoutes 设置路由
|
||||
func SetupRoutes(r *gin.Engine, handlers *Handlers, authMiddleware *middleware.AuthMiddleware) {
|
||||
// API v1路由组
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 公开路由
|
||||
public := v1.Group("/auth")
|
||||
func SetupRoutes(r *gin.Engine, h *Handlers, authMiddleware *middleware.AuthMiddleware, logger *zap.Logger) {
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
{
|
||||
public.POST("/login", handlers.Auth.Login)
|
||||
public.POST("/register", handlers.Auth.Register)
|
||||
public.POST("/refresh", handlers.Auth.RefreshToken)
|
||||
// 公开路由
|
||||
public := api.Group("")
|
||||
{
|
||||
// 认证相关
|
||||
auth := public.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", h.AuthHandler.Login)
|
||||
auth.POST("/refresh", h.AuthHandler.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
protected := api.Group("")
|
||||
protected.Use(authMiddleware.RequireAuth())
|
||||
{
|
||||
// 当前用户信息
|
||||
protected.GET("/me", h.UserHandler.GetCurrentUser)
|
||||
protected.PUT("/me", h.UserHandler.UpdateCurrentUser)
|
||||
protected.POST("/auth/logout", h.AuthHandler.Logout)
|
||||
|
||||
// 照片管理
|
||||
photos := protected.Group("/photos")
|
||||
{
|
||||
photos.GET("", h.PhotoHandler.GetPhotos)
|
||||
photos.POST("", h.PhotoHandler.CreatePhoto)
|
||||
photos.GET("/stats", h.PhotoHandler.GetPhotoStats)
|
||||
photos.POST("/upload", h.PhotoHandler.UploadPhoto)
|
||||
photos.POST("/batch/update", h.PhotoHandler.BatchUpdatePhotos)
|
||||
photos.POST("/batch/delete", h.PhotoHandler.BatchDeletePhotos)
|
||||
photos.GET("/:id", h.PhotoHandler.GetPhoto)
|
||||
photos.PUT("/:id", h.PhotoHandler.UpdatePhoto)
|
||||
photos.DELETE("/:id", h.PhotoHandler.DeletePhoto)
|
||||
}
|
||||
|
||||
// 分类管理
|
||||
categories := protected.Group("/categories")
|
||||
{
|
||||
categories.GET("", h.CategoryHandler.GetCategories)
|
||||
categories.POST("", h.CategoryHandler.CreateCategory)
|
||||
categories.GET("/tree", h.CategoryHandler.GetCategoryTree)
|
||||
categories.GET("/stats", h.CategoryHandler.GetCategoryStats)
|
||||
categories.POST("/reorder", h.CategoryHandler.ReorderCategories)
|
||||
categories.POST("/generate-slug", h.CategoryHandler.GenerateSlug)
|
||||
categories.GET("/:id", h.CategoryHandler.GetCategory)
|
||||
categories.PUT("/:id", h.CategoryHandler.UpdateCategory)
|
||||
categories.DELETE("/:id", h.CategoryHandler.DeleteCategory)
|
||||
categories.GET("/slug/:slug", h.CategoryHandler.GetCategoryBySlug)
|
||||
}
|
||||
|
||||
// 标签管理
|
||||
tags := protected.Group("/tags")
|
||||
{
|
||||
tags.GET("", h.TagHandler.GetTags)
|
||||
tags.POST("", h.TagHandler.CreateTag)
|
||||
tags.GET("/all", h.TagHandler.GetAllTags)
|
||||
tags.GET("/popular", h.TagHandler.GetPopularTags)
|
||||
tags.GET("/cloud", h.TagHandler.GetTagCloud)
|
||||
tags.GET("/stats", h.TagHandler.GetTagStats)
|
||||
tags.GET("/search", h.TagHandler.SearchTags)
|
||||
tags.POST("/batch/delete", h.TagHandler.BatchDeleteTags)
|
||||
tags.POST("/generate-slug", h.TagHandler.GenerateSlug)
|
||||
tags.GET("/:id", h.TagHandler.GetTag)
|
||||
tags.PUT("/:id", h.TagHandler.UpdateTag)
|
||||
tags.DELETE("/:id", h.TagHandler.DeleteTag)
|
||||
tags.GET("/slug/:slug", h.TagHandler.GetTagBySlug)
|
||||
}
|
||||
|
||||
// 用户管理 (需要管理员权限)
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(authMiddleware.RequireRole("admin"))
|
||||
{
|
||||
users := admin.Group("/users")
|
||||
{
|
||||
users.GET("", h.UserHandler.GetUsers)
|
||||
users.POST("", h.UserHandler.CreateUser)
|
||||
users.GET("/:id", h.UserHandler.GetUser)
|
||||
users.PUT("/:id", h.UserHandler.UpdateUser)
|
||||
users.DELETE("/:id", h.UserHandler.DeleteUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
protected := v1.Group("/")
|
||||
protected.Use(authMiddleware.RequireAuth())
|
||||
|
||||
// 前端公共 API (无需认证)
|
||||
frontend := api.Group("/public")
|
||||
{
|
||||
// 用户资料
|
||||
protected.GET("/auth/profile", handlers.Auth.GetProfile)
|
||||
protected.PUT("/auth/password", handlers.Auth.UpdatePassword)
|
||||
protected.POST("/auth/logout", handlers.Auth.Logout)
|
||||
}
|
||||
|
||||
// 管理员路由
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(authMiddleware.RequireAuth())
|
||||
admin.Use(authMiddleware.RequireAdmin())
|
||||
{
|
||||
// 将在后续添加管理员相关路由
|
||||
// 公开的照片接口
|
||||
frontend.GET("/photos", h.PhotoHandler.GetPhotos)
|
||||
frontend.GET("/photos/:id", h.PhotoHandler.GetPhoto)
|
||||
|
||||
// 公开的分类接口
|
||||
frontend.GET("/categories", h.CategoryHandler.GetCategories)
|
||||
frontend.GET("/categories/tree", h.CategoryHandler.GetCategoryTree)
|
||||
frontend.GET("/categories/:id", h.CategoryHandler.GetCategory)
|
||||
frontend.GET("/categories/slug/:slug", h.CategoryHandler.GetCategoryBySlug)
|
||||
|
||||
// 公开的标签接口
|
||||
frontend.GET("/tags", h.TagHandler.GetTags)
|
||||
frontend.GET("/tags/popular", h.TagHandler.GetPopularTags)
|
||||
frontend.GET("/tags/cloud", h.TagHandler.GetTagCloud)
|
||||
frontend.GET("/tags/:id", h.TagHandler.GetTag)
|
||||
frontend.GET("/tags/slug/:slug", h.TagHandler.GetTagBySlug)
|
||||
}
|
||||
}
|
||||
242
backend/internal/models/requests.go
Normal file
242
backend/internal/models/requests.go
Normal file
@ -0,0 +1,242 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// 通用请求和响应结构
|
||||
|
||||
// ErrorResponse 错误响应
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SuccessResponse 成功响应
|
||||
type SuccessResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
type BatchDeleteRequest struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// GenerateSlugRequest 生成slug请求
|
||||
type GenerateSlugRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// GenerateSlugResponse 生成slug响应
|
||||
type GenerateSlugResponse struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
// 照片相关请求
|
||||
|
||||
// CreatePhotoRequest 创建照片请求
|
||||
type CreatePhotoRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Status string `json:"status" binding:"oneof=draft published archived processing"`
|
||||
CategoryIDs []uint `json:"category_ids"`
|
||||
TagIDs []uint `json:"tag_ids"`
|
||||
Camera string `json:"camera"`
|
||||
Lens string `json:"lens"`
|
||||
ISO int `json:"iso"`
|
||||
Aperture string `json:"aperture"`
|
||||
ShutterSpeed string `json:"shutter_speed"`
|
||||
FocalLength string `json:"focal_length"`
|
||||
TakenAt *time.Time `json:"taken_at"`
|
||||
}
|
||||
|
||||
// UpdatePhotoRequest 更新照片请求
|
||||
type UpdatePhotoRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"`
|
||||
CategoryIDs *[]uint `json:"category_ids"`
|
||||
TagIDs *[]uint `json:"tag_ids"`
|
||||
Camera *string `json:"camera"`
|
||||
Lens *string `json:"lens"`
|
||||
ISO *int `json:"iso"`
|
||||
Aperture *string `json:"aperture"`
|
||||
ShutterSpeed *string `json:"shutter_speed"`
|
||||
FocalLength *string `json:"focal_length"`
|
||||
TakenAt *time.Time `json:"taken_at"`
|
||||
}
|
||||
|
||||
// BatchUpdatePhotosRequest 批量更新照片请求
|
||||
type BatchUpdatePhotosRequest struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"`
|
||||
CategoryIDs *[]uint `json:"category_ids"`
|
||||
TagIDs *[]uint `json:"tag_ids"`
|
||||
}
|
||||
|
||||
// PhotoStats 照片统计信息
|
||||
type PhotoStats struct {
|
||||
Total int64 `json:"total"`
|
||||
ThisMonth int64 `json:"this_month"`
|
||||
Today int64 `json:"today"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
StatusStats map[string]int64 `json:"status_stats"`
|
||||
}
|
||||
|
||||
// 分类相关请求
|
||||
|
||||
// CreateCategoryRequest 创建分类请求
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
}
|
||||
|
||||
// UpdateCategoryRequest 更新分类请求
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ReorderCategoriesRequest 重新排序分类请求
|
||||
type ReorderCategoriesRequest struct {
|
||||
ParentID *uint `json:"parent_id"`
|
||||
CategoryIDs []uint `json:"category_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// CategoryStats 分类统计信息
|
||||
type CategoryStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Active int64 `json:"active"`
|
||||
TopLevel int64 `json:"top_level"`
|
||||
PhotoCounts map[string]int64 `json:"photo_counts"`
|
||||
}
|
||||
|
||||
// CategoryTree 分类树结构
|
||||
type CategoryTree struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
PhotoCount int64 `json:"photo_count"`
|
||||
Children []CategoryTree `json:"children"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// 标签相关请求
|
||||
|
||||
// CreateTagRequest 创建标签请求
|
||||
type CreateTagRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// UpdateTagRequest 更新标签请求
|
||||
type UpdateTagRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// TagStats 标签统计信息
|
||||
type TagStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Active int64 `json:"active"`
|
||||
Used int64 `json:"used"`
|
||||
Unused int64 `json:"unused"`
|
||||
AvgPhotosPerTag float64 `json:"avg_photos_per_tag"`
|
||||
}
|
||||
|
||||
// TagWithCount 带照片数量的标签
|
||||
type TagWithCount struct {
|
||||
Tag
|
||||
PhotoCount int64 `json:"photo_count"`
|
||||
}
|
||||
|
||||
// TagCloudItem 标签云项目
|
||||
type TagCloudItem struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Color string `json:"color"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// 用户相关请求
|
||||
|
||||
// CreateUserRequest 创建用户请求
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Role string `json:"role" binding:"oneof=user editor admin"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest 更新用户请求
|
||||
type UpdateUserRequest struct {
|
||||
Username *string `json:"username" binding:"omitempty,min=3,max=50"`
|
||||
Email *string `json:"email" binding:"omitempty,email"`
|
||||
Role *string `json:"role" binding:"omitempty,oneof=user editor admin"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdateCurrentUserRequest 更新当前用户请求
|
||||
type UpdateCurrentUserRequest struct {
|
||||
Username *string `json:"username" binding:"omitempty,min=3,max=50"`
|
||||
Email *string `json:"email" binding:"omitempty,email"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
User *UserResponse `json:"user"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新token请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenResponse 刷新token响应
|
||||
type RefreshTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// UserResponse 用户响应(隐藏敏感信息)
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
448
backend/internal/service/category_service.go
Normal file
448
backend/internal/service/category_service.go
Normal 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
|
||||
}
|
||||
678
backend/internal/service/photo_service.go
Normal file
678
backend/internal/service/photo_service.go
Normal 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. 更新数据库记录
|
||||
}
|
||||
218
backend/internal/service/storage/storage.go
Normal file
218
backend/internal/service/storage/storage.go
Normal 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)
|
||||
}
|
||||
}
|
||||
482
backend/internal/service/tag_service.go
Normal file
482
backend/internal/service/tag_service.go
Normal 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
|
||||
}
|
||||
433
backend/internal/service/user_service.go
Normal file
433
backend/internal/service/user_service.go
Normal 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
|
||||
}
|
||||
243
backend/internal/utils/utils.go
Normal file
243
backend/internal/utils/utils.go
Normal file
@ -0,0 +1,243 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Contains 检查字符串切片是否包含指定字符串
|
||||
func Contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ContainsUint 检查 uint 切片是否包含指定值
|
||||
func ContainsUint(slice []uint, item uint) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GenerateUniqueFilename 生成唯一文件名
|
||||
func GenerateUniqueFilename(originalFilename string) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
name := strings.TrimSuffix(originalFilename, ext)
|
||||
|
||||
// 生成时间戳和哈希
|
||||
timestamp := time.Now().Unix()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%s%d", name, timestamp)))
|
||||
|
||||
return fmt.Sprintf("%d_%x%s", timestamp, hash[:8], ext)
|
||||
}
|
||||
|
||||
// GenerateSlug 生成 URL 友好的 slug
|
||||
func GenerateSlug(text string) string {
|
||||
// 转换为小写
|
||||
slug := strings.ToLower(text)
|
||||
|
||||
// 移除重音符号
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
slug, _, _ = transform.String(t, slug)
|
||||
|
||||
// 只保留字母、数字、连字符和下划线
|
||||
reg := regexp.MustCompile(`[^a-z0-9\-_\s]`)
|
||||
slug = reg.ReplaceAllString(slug, "")
|
||||
|
||||
// 将空格替换为连字符
|
||||
slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
|
||||
|
||||
// 移除多余的连字符
|
||||
slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
|
||||
|
||||
// 移除开头和结尾的连字符
|
||||
slug = strings.Trim(slug, "-")
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
// ValidateEmail 验证邮箱格式
|
||||
func ValidateEmail(email string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||
return emailRegex.MatchString(strings.ToLower(email))
|
||||
}
|
||||
|
||||
// ValidatePassword 验证密码强度
|
||||
func ValidatePassword(password string) bool {
|
||||
if len(password) < 8 {
|
||||
return false
|
||||
}
|
||||
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
hasDigit := regexp.MustCompile(`\d`).MatchString(password)
|
||||
|
||||
return hasLower && hasUpper && hasDigit
|
||||
}
|
||||
|
||||
// Paginate 计算分页参数
|
||||
func Paginate(page, limit int) (offset int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
offset = (page - 1) * limit
|
||||
return offset
|
||||
}
|
||||
|
||||
// CalculatePages 计算总页数
|
||||
func CalculatePages(total int64, limit int) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int((total + int64(limit) - 1) / int64(limit))
|
||||
}
|
||||
|
||||
// TruncateString 截断字符串
|
||||
func TruncateString(s string, maxLength int) string {
|
||||
if len(s) <= maxLength {
|
||||
return s
|
||||
}
|
||||
return s[:maxLength] + "..."
|
||||
}
|
||||
|
||||
// FormatFileSize 格式化文件大小
|
||||
func FormatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
sizes := []string{"KB", "MB", "GB", "TB", "PB"}
|
||||
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), sizes[exp])
|
||||
}
|
||||
|
||||
// ParseSortOrder 解析排序方向
|
||||
func ParseSortOrder(order string) string {
|
||||
order = strings.ToLower(strings.TrimSpace(order))
|
||||
if order == "asc" || order == "desc" {
|
||||
return order
|
||||
}
|
||||
return "desc" // 默认降序
|
||||
}
|
||||
|
||||
// SanitizeSearchQuery 清理搜索查询
|
||||
func SanitizeSearchQuery(query string) string {
|
||||
// 移除特殊字符,只保留字母、数字、空格和常用标点
|
||||
reg := regexp.MustCompile(`[^\w\s\-\.\_\@]`)
|
||||
query = reg.ReplaceAllString(query, "")
|
||||
|
||||
// 移除多余的空格
|
||||
query = regexp.MustCompile(`\s+`).ReplaceAllString(query, " ")
|
||||
|
||||
return strings.TrimSpace(query)
|
||||
}
|
||||
|
||||
// GenerateRandomString 生成随机字符串
|
||||
func GenerateRandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 使用当前时间作为种子
|
||||
timestamp := time.Now().UnixNano()
|
||||
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = charset[(timestamp+int64(i))%int64(len(charset))]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// IsValidImageExtension 检查是否为有效的图片扩展名
|
||||
func IsValidImageExtension(filename string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
validExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
|
||||
return Contains(validExts, ext)
|
||||
}
|
||||
|
||||
// GetImageMimeType 根据文件扩展名获取 MIME 类型
|
||||
func GetImageMimeType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
mimeTypes := map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
}
|
||||
|
||||
if mimeType, exists := mimeTypes[ext]; exists {
|
||||
return mimeType
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// RemoveEmptyStrings 移除字符串切片中的空字符串
|
||||
func RemoveEmptyStrings(slice []string) []string {
|
||||
var result []string
|
||||
for _, s := range slice {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
result = append(result, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UniqueStrings 去重字符串切片
|
||||
func UniqueStrings(slice []string) []string {
|
||||
keys := make(map[string]bool)
|
||||
var result []string
|
||||
|
||||
for _, item := range slice {
|
||||
if !keys[item] {
|
||||
keys[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// UniqueUints 去重 uint 切片
|
||||
func UniqueUints(slice []uint) []uint {
|
||||
keys := make(map[uint]bool)
|
||||
var result []uint
|
||||
|
||||
for _, item := range slice {
|
||||
if !keys[item] {
|
||||
keys[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user