3.10.4 综合练习:博客系统后端

3.10.4 综合练习:博客系统后端 #

本节将整合前面学到的所有知识,构建一个完整的博客系统后端。我们将实现评论系统、文件上传、缓存优化、API 文档等功能,并进行系统集成测试。

评论系统实现 #

评论数据访问层 #

// internal/repository/comment.go
package repository

import (
    "context"
    "blog-system/internal/model"
)

// CommentRepository 评论仓储接口
type CommentRepository interface {
    Create(ctx context.Context, comment *model.Comment) error
    GetByID(ctx context.Context, id uint) (*model.Comment, error)
    Update(ctx context.Context, comment *model.Comment) error
    Delete(ctx context.Context, id uint) error
    GetByArticle(ctx context.Context, articleID uint, page, pageSize int) ([]*model.Comment, int64, error)
    GetReplies(ctx context.Context, parentID uint) ([]*model.Comment, error)
    UpdateStatus(ctx context.Context, id uint, status model.CommentStatus) error
}

// commentRepository 评论仓储实现
type commentRepository struct {
    BaseRepository
}

// Create 创建评论
func (r *commentRepository) Create(ctx context.Context, comment *model.Comment) error {
    return r.db.WithContext(ctx).Create(comment).Error
}

// GetByID 根据ID获取评论
func (r *commentRepository) GetByID(ctx context.Context, id uint) (*model.Comment, error) {
    var comment model.Comment
    err := r.db.WithContext(ctx).
        Preload("User").
        Preload("Parent.User").
        First(&comment, id).Error
    if err != nil {
        return nil, err
    }
    return &comment, nil
}

// GetByArticle 获取文章评论
func (r *commentRepository) GetByArticle(ctx context.Context, articleID uint, page, pageSize int) ([]*model.Comment, int64, error) {
    var comments []*model.Comment
    var total int64

    db := r.db.WithContext(ctx).Model(&model.Comment{}).
        Where("article_id = ? AND parent_id IS NULL AND status = ?", articleID, model.CommentStatusApproved)

    if err := db.Count(&total).Error; err != nil {
        return nil, 0, err
    }

    err := db.Preload("User").
        Preload("Children", func(db *gorm.DB) *gorm.DB {
            return db.Where("status = ?", model.CommentStatusApproved).
                Preload("User").
                Order("created_at ASC")
        }).
        Order("created_at DESC").
        Scopes(r.Paginate(page, pageSize)).
        Find(&comments).Error

    return comments, total, err
}

// UpdateStatus 更新评论状态
func (r *commentRepository) UpdateStatus(ctx context.Context, id uint, status model.CommentStatus) error {
    return r.db.WithContext(ctx).
        Model(&model.Comment{}).
        Where("id = ?", id).
        Update("status", status).Error
}

评论服务层 #

// internal/service/comment.go
package service

import (
    "context"
    "time"

    "blog-system/internal/model"
    "blog-system/internal/repository"
    "blog-system/pkg/response"
    "gorm.io/gorm"
)

// CommentService 评论服务接口
type CommentService interface {
    Create(ctx context.Context, userID uint, req *CreateCommentRequest) (*CommentResponse, error)
    GetByArticle(ctx context.Context, articleID uint, page, pageSize int) (*ListCommentResponse, error)
    Update(ctx context.Context, userID uint, id uint, req *UpdateCommentRequest) (*CommentResponse, error)
    Delete(ctx context.Context, userID uint, id uint) error
    Approve(ctx context.Context, id uint) error
    Reject(ctx context.Context, id uint) error
}

// CreateCommentRequest 创建评论请求
type CreateCommentRequest struct {
    Content   string `json:"content" binding:"required,max=1000"`
    ArticleID uint   `json:"article_id" binding:"required"`
    ParentID  *uint  `json:"parent_id"`
}

// UpdateCommentRequest 更新评论请求
type UpdateCommentRequest struct {
    Content string `json:"content" binding:"required,max=1000"`
}

// CommentResponse 评论响应
type CommentResponse struct {
    ID        uint                 `json:"id"`
    Content   string               `json:"content"`
    Status    model.CommentStatus  `json:"status"`
    User      *UserInfo            `json:"user"`
    Parent    *CommentResponse     `json:"parent,omitempty"`
    Children  []*CommentResponse   `json:"children,omitempty"`
    CreatedAt time.Time            `json:"created_at"`
    UpdatedAt time.Time            `json:"updated_at"`
}

// ListCommentResponse 评论列表响应
type ListCommentResponse struct {
    List       []*CommentResponse `json:"list"`
    Total      int64              `json:"total"`
    Page       int                `json:"page"`
    PageSize   int                `json:"page_size"`
    TotalPages int                `json:"total_pages"`
}

// commentService 评论服务实现
type commentService struct {
    BaseService
    commentRepo repository.CommentRepository
    articleRepo repository.ArticleRepository
}

// Create 创建评论
func (s *commentService) Create(ctx context.Context, userID uint, req *CreateCommentRequest) (*CommentResponse, error) {
    // 检查文章是否存在
    article, err := s.articleRepo.GetByID(ctx, req.ArticleID)
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return nil, response.ErrArticleNotFound
        }
        return nil, response.ErrInternalServer
    }

    // 检查文章是否允许评论
    if article.Status != model.ArticleStatusPublished {
        return nil, response.NewError(40001, "文章不允许评论", "")
    }

    // 检查父评论是否存在
    if req.ParentID != nil {
        if _, err := s.commentRepo.GetByID(ctx, *req.ParentID); err != nil {
            return nil, response.NewError(40002, "父评论不存在", "")
        }
    }

    // 创建评论
    comment := &model.Comment{
        Content:   req.Content,
        UserID:    userID,
        ArticleID: req.ArticleID,
        ParentID:  req.ParentID,
        Status:    model.CommentStatusPending, // 默认待审核
    }

    if err := s.commentRepo.Create(ctx, comment); err != nil {
        return nil, response.ErrInternalServer
    }

    // 更新文章评论数(异步)
    go func() {
        s.articleRepo.UpdateCommentCount(context.Background(), req.ArticleID)
    }()

    return s.GetByID(ctx, comment.ID)
}

// GetByArticle 获取文章评论
func (s *commentService) GetByArticle(ctx context.Context, articleID uint, page, pageSize int) (*ListCommentResponse, error) {
    comments, total, err := s.commentRepo.GetByArticle(ctx, articleID, page, pageSize)
    if err != nil {
        return nil, response.ErrInternalServer
    }

    list := make([]*CommentResponse, len(comments))
    for i, comment := range comments {
        list[i] = s.buildCommentResponse(comment)
    }

    totalPages := int(total) / pageSize
    if int(total)%pageSize > 0 {
        totalPages++
    }

    return &ListCommentResponse{
        List:       list,
        Total:      total,
        Page:       page,
        PageSize:   pageSize,
        TotalPages: totalPages,
    }, nil
}

// buildCommentResponse 构建评论响应
func (s *commentService) buildCommentResponse(comment *model.Comment) *CommentResponse {
    resp := &CommentResponse{
        ID:        comment.ID,
        Content:   comment.Content,
        Status:    comment.Status,
        CreatedAt: comment.CreatedAt,
        UpdatedAt: comment.UpdatedAt,
    }

    // 用户信息
    resp.User = &UserInfo{
        ID:       comment.User.ID,
        Username: comment.User.Username,
        Nickname: comment.User.Nickname,
        Avatar:   comment.User.Avatar,
    }

    // 父评论信息
    if comment.Parent != nil {
        resp.Parent = &CommentResponse{
            ID:      comment.Parent.ID,
            Content: comment.Parent.Content,
            User: &UserInfo{
                ID:       comment.Parent.User.ID,
                Username: comment.Parent.User.Username,
                Nickname: comment.Parent.User.Nickname,
                Avatar:   comment.Parent.User.Avatar,
            },
        }
    }

    // 子评论信息
    if len(comment.Children) > 0 {
        resp.Children = make([]*CommentResponse, len(comment.Children))
        for i, child := range comment.Children {
            resp.Children[i] = s.buildCommentResponse(&child)
        }
    }

    return resp
}

文件上传功能 #

文件上传服务 #

// internal/service/upload.go
package service

import (
    "context"
    "fmt"
    "io"
    "mime/multipart"
    "os"
    "path/filepath"
    "strings"
    "time"

    "blog-system/internal/config"
    "blog-system/pkg/response"
    "github.com/google/uuid"
)

// UploadService 文件上传服务接口
type UploadService interface {
    UploadImage(ctx context.Context, file *multipart.FileHeader) (*UploadResponse, error)
    UploadFile(ctx context.Context, file *multipart.FileHeader) (*UploadResponse, error)
    DeleteFile(ctx context.Context, filename string) error
}

// UploadResponse 上传响应
type UploadResponse struct {
    Filename string `json:"filename"`
    URL      string `json:"url"`
    Size     int64  `json:"size"`
    Type     string `json:"type"`
}

// uploadService 文件上传服务实现
type uploadService struct {
    BaseService
    config *config.UploadConfig
}

// NewUploadService 创建文件上传服务
func NewUploadService(config *config.UploadConfig) UploadService {
    return &uploadService{
        config: config,
    }
}

// UploadImage 上传图片
func (s *uploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (*UploadResponse, error) {
    // 检查文件大小
    if file.Size > s.config.MaxSize {
        return nil, response.NewError(40001, "文件大小超出限制", "")
    }

    // 检查文件类型
    ext := strings.ToLower(filepath.Ext(file.Filename))
    imageExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
    if !s.isAllowedExt(ext, imageExts) {
        return nil, response.NewError(40002, "不支持的图片格式", "")
    }

    return s.saveFile(file, "images")
}

// UploadFile 上传文件
func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader) (*UploadResponse, error) {
    // 检查文件大小
    if file.Size > s.config.MaxSize {
        return nil, response.NewError(40001, "文件大小超出限制", "")
    }

    // 检查文件类型
    ext := strings.ToLower(filepath.Ext(file.Filename))
    if !s.isAllowedExt(ext, s.config.AllowExts) {
        return nil, response.NewError(40002, "不支持的文件格式", "")
    }

    return s.saveFile(file, "files")
}

// saveFile 保存文件
func (s *uploadService) saveFile(file *multipart.FileHeader, subDir string) (*UploadResponse, error) {
    // 生成文件名
    ext := filepath.Ext(file.Filename)
    filename := fmt.Sprintf("%s%s", uuid.New().String(), ext)

    // 创建目录结构
    dateDir := time.Now().Format("2006/01/02")
    dir := filepath.Join(s.config.Path, subDir, dateDir)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return nil, response.ErrInternalServer
    }

    // 完整文件路径
    fullPath := filepath.Join(dir, filename)

    // 打开上传的文件
    src, err := file.Open()
    if err != nil {
        return nil, response.ErrInternalServer
    }
    defer src.Close()

    // 创建目标文件
    dst, err := os.Create(fullPath)
    if err != nil {
        return nil, response.ErrInternalServer
    }
    defer dst.Close()

    // 复制文件内容
    if _, err := io.Copy(dst, src); err != nil {
        return nil, response.ErrInternalServer
    }

    // 生成访问URL
    relativePath := filepath.Join(subDir, dateDir, filename)
    url := fmt.Sprintf("/uploads/%s", strings.ReplaceAll(relativePath, "\\", "/"))

    return &UploadResponse{
        Filename: filename,
        URL:      url,
        Size:     file.Size,
        Type:     file.Header.Get("Content-Type"),
    }, nil
}

// isAllowedExt 检查文件扩展名是否允许
func (s *uploadService) isAllowedExt(ext string, allowedExts []string) bool {
    for _, allowedExt := range allowedExts {
        if ext == allowedExt {
            return true
        }
    }
    return false
}

// DeleteFile 删除文件
func (s *uploadService) DeleteFile(ctx context.Context, filename string) error {
    // 构建文件路径
    fullPath := filepath.Join(s.config.Path, filename)

    // 检查文件是否存在
    if _, err := os.Stat(fullPath); os.IsNotExist(err) {
        return response.NewError(40404, "文件不存在", "")
    }

    // 删除文件
    if err := os.Remove(fullPath); err != nil {
        return response.ErrInternalServer
    }

    return nil
}

文件上传控制器 #

// internal/handler/upload.go
package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "blog-system/internal/service"
    "blog-system/pkg/response"
)

// UploadHandler 文件上传处理器
type UploadHandler struct {
    BaseHandler
    uploadService service.UploadService
}

// NewUploadHandler 创建文件上传处理器
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
    return &UploadHandler{
        uploadService: uploadService,
    }
}

// UploadImage 上传图片
// @Summary 上传图片
// @Description 上传图片文件
// @Tags 文件上传
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "图片文件"
// @Success 200 {object} response.Response{data=service.UploadResponse}
// @Failure 400 {object} response.Response
// @Security ApiKeyAuth
// @Router /api/v1/upload/image [post]
func (h *UploadHandler) UploadImage(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        response.Error(c, response.NewError(http.StatusBadRequest, "请选择要上传的图片", ""))
        return
    }

    result, err := h.uploadService.UploadImage(c.Request.Context(), file)
    if err != nil {
        response.Error(c, err.(*response.Error))
        return
    }

    response.SuccessWithMessage(c, "图片上传成功", result)
}

// UploadFile 上传文件
// @Summary 上传文件
// @Description 上传文件
// @Tags 文件上传
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "文件"
// @Success 200 {object} response.Response{data=service.UploadResponse}
// @Failure 400 {object} response.Response
// @Security ApiKeyAuth
// @Router /api/v1/upload/file [post]
func (h *UploadHandler) UploadFile(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
        response.Error(c, response.NewError(http.StatusBadRequest, "请选择要上传的文件", ""))
        return
    }

    result, err := h.uploadService.UploadFile(c.Request.Context(), file)
    if err != nil {
        response.Error(c, err.(*response.Error))
        return
    }

    response.SuccessWithMessage(c, "文件上传成功", result)
}

缓存优化 #

Redis 缓存服务 #

// pkg/cache/redis.go
package cache

import (
    "context"
    "encoding/json"
    "time"

    "github.com/go-redis/redis/v8"
)

// RedisCache Redis缓存
type RedisCache struct {
    client *redis.Client
}

// NewRedisCache 创建Redis缓存
func NewRedisCache(client *redis.Client) *RedisCache {
    return &RedisCache{
        client: client,
    }
}

// Set 设置缓存
func (c *RedisCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
    data, err := json.Marshal(value)
    if err != nil {
        return err
    }
    return c.client.Set(ctx, key, data, expiration).Err()
}

// Get 获取缓存
func (c *RedisCache) Get(ctx context.Context, key string, dest interface{}) error {
    data, err := c.client.Get(ctx, key).Result()
    if err != nil {
        return err
    }
    return json.Unmarshal([]byte(data), dest)
}

// Delete 删除缓存
func (c *RedisCache) Delete(ctx context.Context, key string) error {
    return c.client.Del(ctx, key).Err()
}

// Exists 检查缓存是否存在
func (c *RedisCache) Exists(ctx context.Context, key string) (bool, error) {
    count, err := c.client.Exists(ctx, key).Result()
    return count > 0, err
}

缓存中间件 #

// internal/middleware/cache.go
package middleware

import (
    "fmt"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "blog-system/pkg/cache"
)

// CacheMiddleware 缓存中间件
func CacheMiddleware(cache *cache.RedisCache, duration time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 只缓存GET请求
        if c.Request.Method != "GET" {
            c.Next()
            return
        }

        // 生成缓存键
        cacheKey := fmt.Sprintf("cache:%s:%s", c.Request.Method, c.Request.URL.Path)
        if c.Request.URL.RawQuery != "" {
            cacheKey += ":" + c.Request.URL.RawQuery
        }

        // 尝试从缓存获取
        var cachedResponse CachedResponse
        if err := cache.Get(c.Request.Context(), cacheKey, &cachedResponse); err == nil {
            // 设置响应头
            for key, value := range cachedResponse.Headers {
                c.Header(key, value)
            }

            // 返回缓存的响应
            c.Data(cachedResponse.StatusCode, cachedResponse.ContentType, cachedResponse.Body)
            c.Abort()
            return
        }

        // 创建响应写入器
        writer := &CacheWriter{
            ResponseWriter: c.Writer,
            cache:          cache,
            cacheKey:       cacheKey,
            duration:       duration,
            context:        c.Request.Context(),
        }
        c.Writer = writer

        c.Next()
    }
}

// CachedResponse 缓存的响应
type CachedResponse struct {
    StatusCode  int               `json:"status_code"`
    Headers     map[string]string `json:"headers"`
    Body        []byte            `json:"body"`
    ContentType string            `json:"content_type"`
}

// CacheWriter 缓存写入器
type CacheWriter struct {
    gin.ResponseWriter
    cache     *cache.RedisCache
    cacheKey  string
    duration  time.Duration
    context   context.Context
    body      []byte
}

// Write 写入响应
func (w *CacheWriter) Write(data []byte) (int, error) {
    w.body = append(w.body, data...)
    return w.ResponseWriter.Write(data)
}

// WriteHeaderNow 写入响应头
func (w *CacheWriter) WriteHeaderNow() {
    w.ResponseWriter.WriteHeaderNow()

    // 只缓存成功的响应
    if w.Status() == http.StatusOK {
        // 构建缓存响应
        cachedResponse := CachedResponse{
            StatusCode:  w.Status(),
            Headers:     make(map[string]string),
            Body:        w.body,
            ContentType: w.Header().Get("Content-Type"),
        }

        // 复制响应头
        for key, values := range w.Header() {
            if len(values) > 0 {
                cachedResponse.Headers[key] = values[0]
            }
        }

        // 异步缓存响应
        go func() {
            w.cache.Set(w.context, w.cacheKey, cachedResponse, w.duration)
        }()
    }
}

API 文档生成 #

Swagger 配置 #

// docs/swagger.go
package docs

import "github.com/swaggo/swag"

const docTemplate = `{
    "schemes": {{ marshal .Schemes }},
    "swagger": "2.0",
    "info": {
        "description": "{{escape .Description}}",
        "title": "{{.Title}}",
        "contact": {},
        "version": "{{.Version}}"
    },
    "host": "{{.Host}}",
    "basePath": "{{.BasePath}}",
    "paths": {},
    "definitions": {},
    "securityDefinitions": {
        "ApiKeyAuth": {
            "type": "apiKey",
            "name": "Authorization",
            "in": "header"
        }
    }
}`

// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
    Version:          "1.0",
    Host:             "localhost:8080",
    BasePath:         "/",
    Schemes:          []string{},
    Title:            "博客系统API",
    Description:      "基于Go语言开发的博客系统后端API",
    InfoInstanceName: "swagger",
    SwaggerTemplate:  docTemplate,
}

func init() {
    swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

路由集成 #

// internal/router/router.go
package router

import (
    "net/http"

    "github.com/gin-gonic/gin"
    swaggerFiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"

    "blog-system/internal/container"
    "blog-system/internal/middleware"
    _ "blog-system/docs" // 导入swagger文档
)

// NewRouter 创建路由
func NewRouter(c *container.Container) *gin.Engine {
    // 设置Gin模式
    gin.SetMode(c.Config.Server.Mode)

    r := gin.New()

    // 全局中间件
    r.Use(gin.Logger())
    r.Use(gin.Recovery())
    r.Use(middleware.CORSMiddleware())

    // 静态文件服务
    r.Static("/uploads", c.Config.Upload.Path)

    // 健康检查
    r.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status": "ok",
            "time":   time.Now().Unix(),
        })
    })

    // Swagger文档
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

    // API路由组
    api := r.Group("/api/v1")

    // 缓存中间件(仅对部分接口启用)
    cacheMiddleware := middleware.CacheMiddleware(
        cache.NewRedisCache(c.Redis),
        5*time.Minute,
    )

    // 认证中间件
    authMiddleware := middleware.AuthMiddleware(c.JWTManager)

    // 设置各模块路由
    setupAuthRoutes(api, c.Handler, authMiddleware)
    setupArticleRoutes(api, c.Handler, authMiddleware, cacheMiddleware)
    setupCommentRoutes(api, c.Handler, authMiddleware)
    setupUploadRoutes(api, c.Handler, authMiddleware)
    setupCategoryRoutes(api, c.Handler, authMiddleware, cacheMiddleware)
    setupTagRoutes(api, c.Handler, authMiddleware, cacheMiddleware)

    return r
}

// setupCommentRoutes 设置评论路由
func setupCommentRoutes(r *gin.RouterGroup, h *handler.Handlers, auth gin.HandlerFunc) {
    comments := r.Group("/comments")
    {
        // 公开路由
        comments.GET("/article/:article_id", h.Comment.GetByArticle)

        // 需要认证的路由
        authenticated := comments.Group("")
        authenticated.Use(auth)
        {
            authenticated.POST("", h.Comment.Create)
            authenticated.PUT("/:id", h.Comment.Update)
            authenticated.DELETE("/:id", h.Comment.Delete)
        }
    }
}

// setupUploadRoutes 设置上传路由
func setupUploadRoutes(r *gin.RouterGroup, h *handler.Handlers, auth gin.HandlerFunc) {
    upload := r.Group("/upload")
    upload.Use(auth) // 上传需要认证
    {
        upload.POST("/image", h.Upload.UploadImage)
        upload.POST("/file", h.Upload.UploadFile)
    }
}

系统集成测试 #

测试配置 #

// test/setup.go
package test

import (
    "blog-system/internal/config"
    "blog-system/internal/container"
    "blog-system/internal/router"
    "github.com/gin-gonic/gin"
)

// SetupTestApp 设置测试应用
func SetupTestApp() (*gin.Engine, *container.Container, error) {
    // 加载测试配置
    cfg := &config.Config{
        Server: config.ServerConfig{
            Port: 8080,
            Mode: "test",
        },
        Database: config.DatabaseConfig{
            Driver:   "sqlite",
            Database: ":memory:",
        },
        JWT: config.JWTConfig{
            Secret:     "test-secret",
            Expiration: 24 * time.Hour,
        },
        Upload: config.UploadConfig{
            Path:      "./test/uploads",
            MaxSize:   10 * 1024 * 1024,
            AllowExts: []string{".jpg", ".png", ".gif"},
        },
    }

    // 创建容器
    c, err := container.NewContainer(cfg)
    if err != nil {
        return nil, nil, err
    }

    // 创建路由
    r := router.NewRouter(c)

    return r, c, nil
}

集成测试 #

// test/integration_test.go
package test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
    "blog-system/internal/service"
)

func TestUserRegistrationAndLogin(t *testing.T) {
    app, _, err := SetupTestApp()
    assert.NoError(t, err)

    // 测试用户注册
    registerReq := service.RegisterRequest{
        Username: "testuser",
        Email:    "[email protected]",
        Password: "password123",
        Nickname: "Test User",
    }

    reqBody, _ := json.Marshal(registerReq)
    req := httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(reqBody))
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    app.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var registerResp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &registerResp)
    assert.Equal(t, float64(0), registerResp["code"])

    // 测试用户登录
    loginReq := service.LoginRequest{
        Username: "testuser",
        Password: "password123",
    }

    reqBody, _ = json.Marshal(loginReq)
    req = httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(reqBody))
    req.Header.Set("Content-Type", "application/json")

    w = httptest.NewRecorder()
    app.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var loginResp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &loginResp)
    assert.Equal(t, float64(0), loginResp["code"])

    // 验证返回的数据结构
    data := loginResp["data"].(map[string]interface{})
    assert.NotEmpty(t, data["access_token"])
    assert.NotEmpty(t, data["refresh_token"])
}

func TestArticleManagement(t *testing.T) {
    app, _, err := SetupTestApp()
    assert.NoError(t, err)

    // 首先注册并登录用户
    token := registerAndLogin(t, app)

    // 测试创建文章
    createReq := service.CreateArticleRequest{
        Title:   "Test Article",
        Summary: "This is a test article",
        Content: "This is the content of the test article",
        Status:  model.ArticleStatusPublished,
    }

    reqBody, _ := json.Marshal(createReq)
    req := httptest.NewRequest("POST", "/api/v1/articles", bytes.NewBuffer(reqBody))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)

    w := httptest.NewRecorder()
    app.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var createResp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &createResp)
    assert.Equal(t, float64(0), createResp["code"])

    // 获取创建的文章ID
    data := createResp["data"].(map[string]interface{})
    articleID := data["id"].(float64)

    // 测试获取文章详情
    req = httptest.NewRequest("GET", fmt.Sprintf("/api/v1/articles/%.0f", articleID), nil)
    w = httptest.NewRecorder()
    app.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)

    var getResp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &getResp)
    assert.Equal(t, float64(0), getResp["code"])

    // 验证文章数据
    articleData := getResp["data"].(map[string]interface{})
    assert.Equal(t, "Test Article", articleData["title"])
    assert.Equal(t, "This is a test article", articleData["summary"])
}

// registerAndLogin 注册并登录用户,返回访问令牌
func registerAndLogin(t *testing.T, app *gin.Engine) string {
    // 注册用户
    registerReq := service.RegisterRequest{
        Username: "testuser",
        Email:    "[email protected]",
        Password: "password123",
    }

    reqBody, _ := json.Marshal(registerReq)
    req := httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(reqBody))
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    app.ServeHTTP(w, req)

    // 登录用户
    loginReq := service.LoginRequest{
        Username: "testuser",
        Password: "password123",
    }

    reqBody, _ = json.Marshal(loginReq)
    req = httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(reqBody))
    req.Header.Set("Content-Type", "application/json")

    w = httptest.NewRecorder()
    app.ServeHTTP(w, req)

    var loginResp map[string]interface{}
    json.Unmarshal(w.Body.Bytes(), &loginResp)

    data := loginResp["data"].(map[string]interface{})
    return data["access_token"].(string)
}

部署配置 #

Docker 配置 #

# Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main cmd/server/main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/

COPY --from=builder /app/main .
COPY --from=builder /app/configs ./configs

EXPOSE 8080
CMD ["./main", "-config", "configs/config.prod.yaml"]

Docker Compose 配置 #

# docker-compose.yml
version: "3.8"

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - GIN_MODE=release
    volumes:
      - ./storage:/root/storage
    depends_on:
      - mysql
      - redis
    networks:
      - blog-network

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: blog_system
      MYSQL_USER: blog_user
      MYSQL_PASSWORD: blog_password
    volumes:
      - mysql_data:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - blog-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - blog-network

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./storage/uploads:/var/www/uploads
    depends_on:
      - app
    networks:
      - blog-network

volumes:
  mysql_data:
  redis_data:

networks:
  blog-network:
    driver: bridge

总结 #

本节完成了博客系统后端的完整实现,包括:

  1. 评论系统:支持多级评论和状态管理
  2. 文件上传:图片和文件上传功能
  3. 缓存优化:Redis 缓存提升性能
  4. API 文档:Swagger 自动生成文档
  5. 集成测试:完整的测试覆盖
  6. 部署配置:Docker 容器化部署

这个博客系统具备了企业级应用的特征:

  • 完整的功能模块:用户认证、文章管理、评论系统
  • 良好的架构设计:分层架构、依赖注入、统一错误处理
  • 性能优化:缓存机制、数据库优化
  • 安全保障:JWT 认证、权限控制、输入验证
  • 可维护性:清晰的代码结构、完善的测试
  • 可部署性:Docker 容器化、配置管理

通过这个综合项目,您已经掌握了 Go Web 开发的核心技能,可以独立开发和部署企业级的 Web 应用。