go语言 gin grom jwt 登陆token验证 增删改查 分页 完整 图书管理系统

因为 依赖的swagger 中 在浏览器中 不能注册,无法测试接口

所以改为postman进行测试 配合swagger查询参数

postamn官方下载需要登陆,这里提供一个不用登陆的版本

通过网盘分享的文件:Postman-win64-10.13.6-Setup 免登录.exe
链接: https://pan.baidu.com/s/1OBxASJrmBv2tqm_qZH1Ykg?pwd=2ziv 提取码: 2ziv

http://192.168.0.103:8080/swagger/index.html

上面的是swagger的登陆地址

注册好后登陆

登陆 后 返回 jwt token 填写到需要的接口 token中就可以用了

依赖安装

go get github.com/gin-gonic/gin@v1.9.1

go get gorm.io/gorm@v1.25.4

go get gorm.io/driver/mysql@v1.5.4

go get github.com/golang-jwt/jwt/v5@v5.2.1

go get golang.org/x/crypto@latest

安装Swag CLI(生成文档用,全局可用)

go install github.com/swaggo/swag/cmd/swag@v1.16.1

安装Gin适配插件和静态文件

go get github.com/swaggo/gin-swagger@v1.6.0

go get github.com/swaggo/files@v1.0.1

运行后swagger init 初始化docs

main.go

Go 复制代码
// @title 图书管理系统API文档
// @version 1.0
// @description 基于Go + Gin + GORM实现的图书管理系统,包含用户注册/登录、JWT认证、分类-图书一对多管理、分页查询功能
// @termsOfService http://swagger.io/terms/

// @contact.name 开发者
// @contact.url http://example.com/support
// @contact.email support@example.com

// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html

// @host localhost:8080
// @BasePath /
// @schemes http

// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
package main

import (
	"errors"
	_ "nandn/docs" // 替换为你的docs文件夹绝对路径(Windows)
	"net/http"
	"strconv"
	"time"

	"nandn/model" // 导入model包,用于业务逻辑处理

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/swaggo/files"
	"github.com/swaggo/gin-swagger"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
)

// ---------------------- 本地结构体定义(新增分页相关) ----------------------
// BaseResponse 统一接口响应结构体
type BaseResponse struct {
	Code int         `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}

// PaginationReq 分页请求参数(通用)
type PaginationReq struct {
	Page     int `form:"page" binding:"omitempty,gte=1"`      // 页码,默认1
	PageSize int `form:"page_size" binding:"omitempty,gte=1"` // 页大小,默认10
}

// PaginationRes 分页响应结构体(通用)
type PaginationRes struct {
	Total     int         `json:"total"`      // 总条数
	Page      int         `json:"page"`       // 当前页码
	PageSize  int         `json:"page_size"`  // 页大小
	TotalPage int         `json:"total_page"` // 总页数
	List      interface{} `json:"list"`       // 数据列表
}

// RegisterReq 用户注册请求结构体
type RegisterReq struct {
	Username string `json:"username" binding:"required,min=3,max=30"`
	Password string `json:"password" binding:"required,min=6,max=20"`
	Email    string `json:"email" binding:"omitempty,email"`
}

// RegisterResData 用户注册响应数据结构体
type RegisterResData struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Email    string `json:"email"`
}

// LoginReq 用户登录请求结构体
type LoginReq struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

// LoginResData 用户登录响应数据结构体
type LoginResData struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Token    string `json:"token"`
	ExpireAt string `json:"expire_at"`
}

// Category 分类结构体(本地定义,Swagger可解析)
type Category struct {
	ID        uint   `json:"id"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
	Name      string `json:"name"`
	Books     []Book `json:"books,omitempty"`
}

// Book 图书结构体(本地定义,Swagger可解析)
type Book struct {
	ID         uint     `json:"id"`
	CreatedAt  string   `json:"created_at"`
	UpdatedAt  string   `json:"updated_at"`
	Title      string   `json:"title"`
	Author     string   `json:"author"`
	Price      float64  `json:"price"`
	CategoryID uint     `json:"category_id"`
	Category   Category `json:"category,omitempty"`
}

// CategoryCreateReq 分类创建请求结构体
type CategoryCreateReq struct {
	Name string `json:"name" binding:"required,min=2,max=50"`
}

// BookCreateReq 图书创建请求结构体
type BookCreateReq struct {
	Title      string  `json:"title" binding:"required,min=2,max=100"`
	Author     string  `json:"author" binding:"required,min=2,max=50"`
	Price      float64 `json:"price" binding:"required,gte=0"`
	CategoryID uint    `json:"category_id" binding:"required,gte=1"`
}

// ---------------------- 全局配置与JWT声明 ----------------------
// JWT密钥(生产环境请存储在环境变量中)
var jwtSecret = []byte("book_manage_2026_jwt_secret_key")

// Claims JWT自定义声明
type Claims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	jwt.RegisteredClaims
}

func main() {
	// 1. 初始化数据库
	model.InitDB()

	// 2. 创建Gin引擎
	r := gin.Default()

	// 3. 注册Swagger路由(公开访问)
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// 4. 公开路由(无需登录,用户注册/登录)
	publicGroup := r.Group("/api/public")
	{
		publicGroup.POST("/register", userRegister)
		publicGroup.POST("/login", userLogin)
	}

	// 5. 受保护路由(需要登录验证,图书/分类相关)
	protectedGroup := r.Group("/api")
	protectedGroup.Use(JWTAuthMiddleware()) // 全局添加JWT权限中间件
	{
		// 分类相关
		categoryGroup := protectedGroup.Group("/categories")
		{
			categoryGroup.POST("", createCategory)
			categoryGroup.GET("/:id", getCategoryWithBooks)
			categoryGroup.GET("", getAllCategories) // 支持分页
		}

		// 图书相关
		bookGroup := protectedGroup.Group("/books")
		{
			bookGroup.POST("", createBook)
			bookGroup.GET("", getAllBooks) // 支持分页
			bookGroup.GET("/:id", getBookWithCategory)
		}
	}

	// 6. 启动HTTP服务
	err := r.Run(":8080")
	if err != nil {
		panic("服务启动失败:" + err.Error())
	}
}

// ---------------------- 通用工具函数 ----------------------
// getPaginationParams 解析分页参数(默认page=1,page_size=10)
func getPaginationParams(c *gin.Context) (page, pageSize int) {
	// 解析页码,默认1
	pageStr := c.DefaultQuery("page", "1")
	page, _ = strconv.Atoi(pageStr)
	if page < 1 {
		page = 1
	}

	// 解析页大小,默认10,最大限制50(防止一次性查询过多数据)
	pageSizeStr := c.DefaultQuery("page_size", "10")
	pageSize, _ = strconv.Atoi(pageSizeStr)
	if pageSize < 1 {
		pageSize = 10
	} else if pageSize > 50 {
		pageSize = 50
	}

	return page, pageSize
}

// calcPagination 计算分页参数(总条数→总页数)
func calcPagination(total, page, pageSize int) PaginationRes {
	totalPage := total / pageSize
	if total%pageSize != 0 {
		totalPage += 1
	}
	return PaginationRes{
		Total:     total,
		Page:      page,
		PageSize:  pageSize,
		TotalPage: totalPage,
	}
}

// ---------------------- JWT工具函数 ----------------------
// generateToken 生成JWT令牌
func generateToken(userID uint, username string) (string, error) {
	// 1. 设置令牌过期时间(2小时)
	expirationTime := time.Now().Add(2 * time.Hour)

	// 2. 构建自定义声明
	claims := &Claims{
		UserID:   userID,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "book-management-system",
		},
	}

	// 3. 创建JWT令牌(HS256算法)
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// 4. 签名并生成字符串令牌
	tokenString, err := token.SignedString(jwtSecret)
	if err != nil {
		return "", err
	}

	return tokenString, nil
}

// JWTAuthMiddleware JWT权限校验中间件
func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 1. 从请求头中获取Token(格式:Bearer xxx)
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, BaseResponse{
				Code: 401,
				Msg:  "未携带登录令牌,请先登录",
				Data: nil,
			})
			c.Abort()
			return
		}

		// 2. 校验Token格式(是否以Bearer开头)
		var tokenString string
		parts := []rune(authHeader)
		if len(parts) > 7 && string(parts[:7]) == "Bearer " {
			tokenString = string(parts[7:])
		} else {
			c.JSON(http.StatusUnauthorized, BaseResponse{
				Code: 401,
				Msg:  "令牌格式错误,应为 Bearer + 令牌字符串",
				Data: nil,
			})
			c.Abort()
			return
		}

		// 3. 解析Token并校验有效性
		claims := &Claims{}
		token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
			// 校验签名算法
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, errors.New("不支持的令牌签名算法")
			}
			return jwtSecret, nil
		})

		// 4. 处理解析错误
		if err != nil || !token.Valid {
			c.JSON(http.StatusUnauthorized, BaseResponse{
				Code: 401,
				Msg:  "令牌无效或已过期,请重新登录",
				Data: nil,
			})
			c.Abort()
			return
		}

		// 5. 令牌有效,将用户信息存入Gin上下文
		c.Set("userID", claims.UserID)
		c.Set("username", claims.Username)

		// 6. 继续执行后续接口逻辑
		c.Next()
	}
}

// ---------------------- 用户相关业务逻辑 ----------------------
// userRegister 用户注册
// @Summary 用户注册
// @Description 注册新用户,用户名唯一,密码加密存储,无需授权
// @Tags 公开接口-用户管理
// @Accept json
// @Produce json
// @Param req body RegisterReq true "注册请求参数"
// @Success 201 {object} BaseResponse{data=RegisterResData} "注册成功"
// @Failure 400 {object} BaseResponse "参数校验失败"
// @Failure 409 {object} BaseResponse "用户名已被注册"
// @Failure 500 {object} BaseResponse "服务器内部错误"
// @Router /api/public/register [post]
func userRegister(c *gin.Context) {
	// 1. 绑定并校验请求参数
	var req RegisterReq
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, BaseResponse{
			Code: 400,
			Msg:  "参数校验失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 2. 校验用户名是否已存在
	var existingUser model.User
	if err := model.DB.Where("username = ?", req.Username).First(&existingUser).Error; err != gorm.ErrRecordNotFound {
		c.JSON(http.StatusConflict, BaseResponse{
			Code: 409,
			Msg:  "用户名已被注册,请更换用户名",
			Data: nil,
		})
		return
	}

	// 3. 密码加密(bcrypt算法)
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "密码加密失败,请重试",
			Data: nil,
		})
		return
	}

	// 4. 构建用户对象并写入数据库
	user := model.User{
		Username: req.Username,
		Password: string(hashedPassword),
		Email:    req.Email,
	}

	if err := model.DB.Create(&user).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "注册失败,请重试:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 5. 构建响应数据并返回
	resData := RegisterResData{
		UserID:   user.ID,
		Username: user.Username,
		Email:    user.Email,
	}

	c.JSON(http.StatusCreated, BaseResponse{
		Code: 200,
		Msg:  "用户注册成功,请登录",
		Data: resData,
	})
}

// userLogin 用户登录
// @Summary 用户登录
// @Description 用户登录并生成JWT令牌,用于访问受保护接口,无需授权
// @Tags 公开接口-用户管理
// @Accept json
// @Produce json
// @Param req body LoginReq true "登录请求参数"
// @Success 200 {object} BaseResponse{data=LoginResData} "登录成功,返回JWT令牌"
// @Failure 400 {object} BaseResponse "参数校验失败"
// @Failure 401 {object} BaseResponse "用户名或密码错误"
// @Failure 500 {object} BaseResponse "服务器内部错误/生成令牌失败"
// @Router /api/public/login [post]
func userLogin(c *gin.Context) {
	// 1. 绑定并校验请求参数
	var req LoginReq
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, BaseResponse{
			Code: 400,
			Msg:  "参数校验失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 2. 查询用户是否存在
	var user model.User
	if err := model.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusUnauthorized, BaseResponse{
				Code: 401,
				Msg:  "用户名或密码错误",
				Data: nil,
			})
			return
		}
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询用户失败,请重试:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 校验密码(对比明文密码与加密密码)
	err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
	if err != nil {
		c.JSON(http.StatusUnauthorized, BaseResponse{
			Code: 401,
			Msg:  "用户名或密码错误",
			Data: nil,
		})
		return
	}

	// 4. 生成JWT令牌
	tokenString, err := generateToken(user.ID, user.Username)
	if err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "生成登录令牌失败,请重试",
			Data: nil,
		})
		return
	}

	// 5. 构建响应数据并返回
	resData := LoginResData{
		UserID:   user.ID,
		Username: user.Username,
		Token:    tokenString,
		ExpireAt: time.Now().Add(2 * time.Hour).Format("2006-01-02 15:04:05"),
	}

	c.JSON(http.StatusOK, BaseResponse{
		Code: 200,
		Msg:  "登录成功",
		Data: resData,
	})
}

// ---------------------- 分类相关业务逻辑 ----------------------
// createCategory 创建分类
// @Summary 创建图书分类
// @Description 创建新的图书分类,分类名称唯一,需携带有效JWT令牌
// @Tags 受保护接口-分类管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param req body CategoryCreateReq true "分类创建请求参数"
// @Success 201 {object} BaseResponse{data=Category} "分类创建成功"
// @Failure 400 {object} BaseResponse "参数校验失败"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 500 {object} BaseResponse "服务器内部错误/分类创建失败"
// @Router /api/categories [post]
func createCategory(c *gin.Context) {
	// 1. 绑定并校验请求参数
	var req CategoryCreateReq
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, BaseResponse{
			Code: 400,
			Msg:  "参数校验失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 2. 构建分类模型并写入数据库
	categoryModel := model.Category{Name: req.Name}
	if err := model.DB.Create(&categoryModel).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "创建分类失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 转换为本地Category结构体,构建响应数据
	categoryRes := Category{
		ID:        categoryModel.ID,
		CreatedAt: categoryModel.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt: categoryModel.UpdatedAt.Format("2006-01-02 15:04:05"),
		Name:      categoryModel.Name,
	}

	// 4. 返回响应结果
	c.JSON(http.StatusCreated, BaseResponse{
		Code: 200,
		Msg:  "分类创建成功",
		Data: categoryRes,
	})
}

// getCategoryWithBooks 查询单个分类(包含关联的图书)
// @Summary 查询单个分类
// @Description 查询指定ID的分类,并返回该分类下的所有图书,需携带有效JWT令牌
// @Tags 受保护接口-分类管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "分类ID"
// @Success 200 {object} BaseResponse{data=Category} "查询成功"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 404 {object} BaseResponse "分类不存在"
// @Failure 500 {object} BaseResponse "服务器内部错误/查询失败"
// @Router /api/categories/{id} [get]
func getCategoryWithBooks(c *gin.Context) {
	// 1. 获取路径参数中的分类ID
	categoryID := c.Param("id")

	// 2. 查询分类(预加载关联的图书)
	var categoryModel model.Category
	if err := model.DB.Preload("Books").First(&categoryModel, categoryID).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, BaseResponse{
				Code: 404,
				Msg:  "分类不存在",
				Data: nil,
			})
			return
		}
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询分类失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 转换图书列表为本地Book结构体
	var bookResList []Book
	for _, bookModel := range categoryModel.Books {
		bookRes := Book{
			ID:         bookModel.ID,
			CreatedAt:  bookModel.CreatedAt.Format("2006-01-02 15:04:05"),
			UpdatedAt:  bookModel.UpdatedAt.Format("2006-01-02 15:04:05"),
			Title:      bookModel.Title,
			Author:     bookModel.Author,
			Price:      bookModel.Price,
			CategoryID: bookModel.CategoryID,
		}
		bookResList = append(bookResList, bookRes)
	}

	// 4. 转换分类为本地Category结构体
	categoryRes := Category{
		ID:        categoryModel.ID,
		CreatedAt: categoryModel.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt: categoryModel.UpdatedAt.Format("2006-01-02 15:04:05"),
		Name:      categoryModel.Name,
		Books:     bookResList,
	}

	// 5. 返回响应结果
	c.JSON(http.StatusOK, BaseResponse{
		Code: 200,
		Msg:  "查询成功",
		Data: categoryRes,
	})
}

// getAllCategories 查询所有分类(支持分页)
// @Summary 查询所有分类
// @Description 查询系统中所有的图书分类,支持分页(page=页码,page_size=页大小),需携带有效JWT令牌
// @Tags 受保护接口-分类管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码(默认1)" minimum(1)
// @Param page_size query int false "页大小(默认10,最大50)" minimum(1) maximum(50)
// @Success 200 {object} BaseResponse{data=PaginationRes{list=[]Category}} "查询成功,返回分页分类列表"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 500 {object} BaseResponse "服务器内部错误/查询失败"
// @Router /api/categories [get]
func getAllCategories(c *gin.Context) {
	// 1. 获取分页参数
	page, pageSize := getPaginationParams(c)

	// 2. 查询总条数
	var total int64
	if err := model.DB.Model(&model.Category{}).Count(&total).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询分类总数失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 分页查询分类列表
	var categoryModels []model.Category
	offset := (page - 1) * pageSize
	if err := model.DB.Offset(offset).Limit(pageSize).Find(&categoryModels).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询分类列表失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 4. 转换为本地Category结构体列表
	var categoryResList []Category
	for _, categoryModel := range categoryModels {
		categoryRes := Category{
			ID:        categoryModel.ID,
			CreatedAt: categoryModel.CreatedAt.Format("2006-01-02 15:04:05"),
			UpdatedAt: categoryModel.UpdatedAt.Format("2006-01-02 15:04:05"),
			Name:      categoryModel.Name,
		}
		categoryResList = append(categoryResList, categoryRes)
	}

	// 5. 构建分页响应
	paginationRes := calcPagination(int(total), page, pageSize)
	paginationRes.List = categoryResList

	// 6. 返回响应结果
	c.JSON(http.StatusOK, BaseResponse{
		Code: 200,
		Msg:  "查询成功",
		Data: paginationRes,
	})
}

// ---------------------- 图书相关业务逻辑 ----------------------
// createBook 创建图书(关联指定分类)
// @Summary 创建图书
// @Description 创建新的图书,并关联到指定分类,需携带有效JWT令牌
// @Tags 受保护接口-图书管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param req body BookCreateReq true "图书创建请求参数"
// @Success 201 {object} BaseResponse{data=Book} "图书创建成功"
// @Failure 400 {object} BaseResponse "参数校验失败"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 404 {object} BaseResponse "关联的分类不存在"
// @Failure 500 {object} BaseResponse "服务器内部错误/图书创建失败"
// @Router /api/books [post]
func createBook(c *gin.Context) {
	// 1. 绑定并校验请求参数
	var req BookCreateReq
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, BaseResponse{
			Code: 400,
			Msg:  "参数校验失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 2. 校验分类是否存在
	var categoryModel model.Category
	if err := model.DB.First(&categoryModel, req.CategoryID).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, BaseResponse{
				Code: 404,
				Msg:  "关联的分类不存在",
				Data: nil,
			})
			return
		}
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "校验分类失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 构建图书模型并写入数据库
	bookModel := model.Book{
		Title:      req.Title,
		Author:     req.Author,
		Price:      req.Price,
		CategoryID: req.CategoryID,
	}
	if err := model.DB.Create(&bookModel).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "创建图书失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 4. 转换为本地Book结构体,构建响应数据
	bookRes := Book{
		ID:         bookModel.ID,
		CreatedAt:  bookModel.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt:  bookModel.UpdatedAt.Format("2006-01-02 15:04:05"),
		Title:      bookModel.Title,
		Author:     bookModel.Author,
		Price:      bookModel.Price,
		CategoryID: bookModel.CategoryID,
	}

	// 5. 返回响应结果
	c.JSON(http.StatusCreated, BaseResponse{
		Code: 200,
		Msg:  "图书创建成功",
		Data: bookRes,
	})
}

// getAllBooks 查询所有图书(支持分页)
// @Summary 查询所有图书
// @Description 查询系统中所有的图书,支持分页(page=页码,page_size=页大小),需携带有效JWT令牌
// @Tags 受保护接口-图书管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码(默认1)" minimum(1)
// @Param page_size query int false "页大小(默认10,最大50)" minimum(1) maximum(50)
// @Success 200 {object} BaseResponse{data=PaginationRes{list=[]Book}} "查询成功,返回分页图书列表"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 500 {object} BaseResponse "服务器内部错误/查询失败"
// @Router /api/books [get]
func getAllBooks(c *gin.Context) {
	// 1. 获取分页参数
	page, pageSize := getPaginationParams(c)

	// 2. 查询总条数
	var total int64
	if err := model.DB.Model(&model.Book{}).Count(&total).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询图书总数失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 分页查询图书列表
	var bookModels []model.Book
	offset := (page - 1) * pageSize
	if err := model.DB.Offset(offset).Limit(pageSize).Find(&bookModels).Error; err != nil {
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询图书列表失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 4. 转换为本地Book结构体列表
	var bookResList []Book
	for _, bookModel := range bookModels {
		bookRes := Book{
			ID:         bookModel.ID,
			CreatedAt:  bookModel.CreatedAt.Format("2006-01-02 15:04:05"),
			UpdatedAt:  bookModel.UpdatedAt.Format("2006-01-02 15:04:05"),
			Title:      bookModel.Title,
			Author:     bookModel.Author,
			Price:      bookModel.Price,
			CategoryID: bookModel.CategoryID,
		}
		bookResList = append(bookResList, bookRes)
	}

	// 5. 构建分页响应
	paginationRes := calcPagination(int(total), page, pageSize)
	paginationRes.List = bookResList

	// 6. 返回响应结果
	c.JSON(http.StatusOK, BaseResponse{
		Code: 200,
		Msg:  "查询成功",
		Data: paginationRes,
	})
}

// getBookWithCategory 查询单本图书(包含关联的分类)
// @Summary 查询单本图书
// @Description 查询指定ID的图书,并返回该图书所属的分类,需携带有效JWT令牌
// @Tags 受保护接口-图书管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "图书ID"
// @Success 200 {object} BaseResponse{data=Book} "查询成功"
// @Failure 401 {object} BaseResponse "未授权或令牌无效"
// @Failure 404 {object} BaseResponse "图书不存在"
// @Failure 500 {object} BaseResponse "服务器内部错误/查询失败"
// @Router /api/books/{id} [get]
func getBookWithCategory(c *gin.Context) {
	// 1. 获取路径参数中的图书ID
	bookID := c.Param("id")

	// 2. 查询图书(预加载关联的分类)
	var bookModel model.Book
	if err := model.DB.Preload("Category").First(&bookModel, bookID).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, BaseResponse{
				Code: 404,
				Msg:  "图书不存在",
				Data: nil,
			})
			return
		}
		c.JSON(http.StatusInternalServerError, BaseResponse{
			Code: 500,
			Msg:  "查询图书失败:" + err.Error(),
			Data: nil,
		})
		return
	}

	// 3. 转换关联分类为本地Category结构体
	categoryRes := Category{
		ID:        bookModel.Category.ID,
		CreatedAt: bookModel.Category.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt: bookModel.Category.UpdatedAt.Format("2006-01-02 15:04:05"),
		Name:      bookModel.Category.Name,
	}

	// 4. 转换图书为本地Book结构体
	bookRes := Book{
		ID:         bookModel.ID,
		CreatedAt:  bookModel.CreatedAt.Format("2006-01-02 15:04:05"),
		UpdatedAt:  bookModel.UpdatedAt.Format("2006-01-02 15:04:05"),
		Title:      bookModel.Title,
		Author:     bookModel.Author,
		Price:      bookModel.Price,
		CategoryID: bookModel.CategoryID,
		Category:   categoryRes,
	}

	// 5. 返回响应结果
	c.JSON(http.StatusOK, BaseResponse{
		Code: 200,
		Msg:  "查询成功",
		Data: bookRes,
	})
}

model.go

Go 复制代码
package model

import (
	"time"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

// 全局DB对象,供外部调用
var DB *gorm.DB

// 1. 分类模型(移除所有GORM自定义类型,仅使用Go原生类型,兼容GORM软删除)
type Category struct {
	ID        uint      `gorm:"primaryKey;autoIncrement" json:"id"` // 主键自增
	CreatedAt time.Time `json:"created_at"`                         // 创建时间(Go原生类型)
	UpdatedAt time.Time `json:"updated_at"`                         // 更新时间(Go原生类型)
	// 移除gorm.DeletedAt,使用gorm:"softDelete:unix"标签实现软删除(数据库层生效,Swagger无需解析)
	// 字段类型使用int64,存储unix时间戳,json:"-" 表示不返回给前端,也不被Swagger解析
	DeletedAt int64  `gorm:"softDelete:unix;index" json:"-"`               // 软删除字段(仅数据库层生效,隐藏Swagger和前端响应)
	Name      string `gorm:"type:varchar(50);not null;unique" json:"name"` // 分类名称
	Books     []Book `gorm:"foreignKey:CategoryID" json:"books"`           // 关联图书
}

// 2. 图书模型(移除所有GORM自定义类型,兼容软删除)
type Book struct {
	ID         uint      `gorm:"primaryKey;autoIncrement" json:"id"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
	DeletedAt  int64     `gorm:"softDelete:unix;index" json:"-"`           // 软删除字段(隐藏,仅数据库生效)
	Title      string    `gorm:"type:varchar(100);not null" json:"title"`  // 图书标题
	Author     string    `gorm:"type:varchar(50);not null" json:"author"`  // 作者
	Price      float64   `gorm:"type:decimal(10,2);not null" json:"price"` // 价格
	CategoryID uint      `gorm:"not null" json:"category_id"`              // 外键
	Category   Category  `gorm:"foreignKey:CategoryID" json:"category"`    // 反向关联分类
}

// 3. 用户模型(移除所有GORM自定义类型,兼容软删除)
type User struct {
	ID        uint      `gorm:"primaryKey;autoIncrement" json:"id"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	DeletedAt int64     `gorm:"softDelete:unix;index" json:"-"`                   // 软删除字段(隐藏,仅数据库生效)
	Username  string    `gorm:"type:varchar(30);not null;unique" json:"username"` // 用户名
	Password  string    `gorm:"type:varchar(100);not null" json:"-"`              // 密码(隐藏返回)
	Email     string    `gorm:"type:varchar(50);unique" json:"email"`             // 邮箱
}

// 初始化数据库连接并自动迁移表结构
func InitDB() {
	// 1. 配置MySQL连接信息(替换为你的数据库账号、密码、数据库名)
	dsn := "root:Xe293358@tcp(127.0.0.1:3306)/book_manage?charset=utf8mb4&parseTime=True&loc=Local"

	// 2. 连接数据库
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info), // 打印SQL日志(开发环境)
	})
	if err != nil {
		panic("数据库连接失败:" + err.Error())
	}

	// 3. 自动迁移表结构(新增User表,原有表结构保持不变)
	err = db.AutoMigrate(&Category{}, &Book{}, &User{})
	if err != nil {
		panic("表结构迁移失败:" + err.Error())
	}

	// 4. 赋值全局DB对象
	sqlDB, _ := db.DB()
	// 设置数据库连接池参数
	sqlDB.SetMaxIdleConns(10)                  // 最大空闲连接数
	sqlDB.SetMaxOpenConns(100)                 // 最大打开连接数
	sqlDB.SetConnMaxLifetime(10 * time.Minute) // 连接最大存活时间

	DB = db
}
相关推荐
不会聊天真君6472 天前
介绍(gin笔记第一期)
笔记·gin
ZHENGZJM3 天前
Server-Sent Events (SSE) 接口实现
架构·go·gin
ZHENGZJM3 天前
统一响应封装与 API 错误处理
react.js·go·gin
ZHENGZJM3 天前
仓库抓取与内容提取
go·gin
GDAL4 天前
gin.H 深入全面讲解
gin·h
呆萌很5 天前
【Gin】参数处理练习题
gin
GDAL5 天前
gin.Default() 深入全面讲解
golang·go·gin
GDAL6 天前
为什么选择gin?
golang·gin
ZHENGZJM10 天前
Gin 鉴权中间件设计与实现
中间件·gin
ZHENGZJM10 天前
认证增强:图形验证码、邮箱验证与账户安全
安全·react.js·go·gin