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(), ®isterResp)
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
总结 #
本节完成了博客系统后端的完整实现,包括:
- 评论系统:支持多级评论和状态管理
- 文件上传:图片和文件上传功能
- 缓存优化:Redis 缓存提升性能
- API 文档:Swagger 自动生成文档
- 集成测试:完整的测试覆盖
- 部署配置:Docker 容器化部署
这个博客系统具备了企业级应用的特征:
- 完整的功能模块:用户认证、文章管理、评论系统
- 良好的架构设计:分层架构、依赖注入、统一错误处理
- 性能优化:缓存机制、数据库优化
- 安全保障:JWT 认证、权限控制、输入验证
- 可维护性:清晰的代码结构、完善的测试
- 可部署性:Docker 容器化、配置管理
通过这个综合项目,您已经掌握了 Go Web 开发的核心技能,可以独立开发和部署企业级的 Web 应用。