项目的目录结构:
1、分页插件:
创建一个plugins的文件夹:
page_plugin.go:
package plugins
import (
"fmt"
"github.com/jinzhu/gorm"
"spider-video-go/wrappers"
"strconv"
)
type PageGorm[T any] struct {
// 页码
PageIndex int
// 页面容量
PageSize int
// Keyword 搜索的关键字
Keyword string `json:"keyword" description:"搜索的关键字"`
}
type PageAdmin[T any] struct {
PageGorm[T]
// id
Id string `json:"id"`
}
// PageGormToPageAdmin 将PageGorm转化为PageAdmin
func PageGormToPageAdmin[T any](p *PageGorm[T]) *PageAdmin[T] {
return &PageAdmin[T]{PageGorm: *p}
}
// NewPageGormByStr 创建分页实例
func NewPageGormByStr[T any](pageIndexStr string, pageSizeStr string) *PageGorm[T] {
if pageIndexStr == "" {
pageIndexStr = "1"
}
if pageSizeStr == "" {
pageSizeStr = "10"
}
pageIndex, err := strconv.Atoi(pageIndexStr)
if err != nil {
fmt.Println("转换错误:", err)
return &PageGorm[T]{PageIndex: 1, PageSize: 10}
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
fmt.Println("转换错误:", err)
return &PageGorm[T]{PageIndex: 1, PageSize: 10}
}
if pageIndex <= 0 {
return &PageGorm[T]{PageIndex: 1, PageSize: pageSize}
}
return &PageGorm[T]{PageIndex: pageIndex, PageSize: pageSize}
}
func (p *PageGorm[T]) PageFunc(db *gorm.DB, queryFn func(*gorm.DB) *gorm.DB) ([]T, error) {
// 计算偏移量
offset := (p.PageIndex - 1) * p.PageSize
// 执行分页查询
var results []T
db = queryFn(db)
if err := db.Offset(offset).Limit(p.PageSize).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// PageBean 定义一个泛型分页结构体
type PageBean[T any] struct {
// PageNumber 页码
PageNumber int `json:"currentPage" description:"页码"`
// PageSize 页面记录数
PageSize int `json:"pageSize" description:"页面记录数"`
// TotalPage 总页数
TotalPage int `json:"totalPage" description:"总页数"`
// TotalCount 总记录数
TotalCount int `json:"totalCount" description:"总记录数"`
// Keyword 搜索的关键字
Keyword string `json:"keyword" description:"搜索的关键字"`
// List 分页记录
List []T `json:"list" description:"分页记录"`
}
// NewPageBean 创建一个新的PageBean实例
func NewPageBean[T any]() PageBean[T] {
return PageBean[T]{}
}
// CalculateTotalPage 计算总页数
func (pb *PageBean[T]) CalculateTotalPage() {
if pb.PageSize == 0 {
pb.TotalPage = 0 // 防止除以0的情况
return
}
pb.TotalPage = pb.TotalCount / pb.PageSize
if pb.TotalCount%pb.PageSize != 0 {
pb.TotalPage++ // 如果有余数,总页数加1
}
}
func (p *PageGorm[T]) PageInfoFunc(db *gorm.DB, queryFn func(*gorm.DB) *gorm.DB) (PageBean[T], error) {
pBean := NewPageBean[T]()
// 查询的关键字
pBean.Keyword = p.Keyword
// 页码
pBean.PageNumber = p.PageIndex
// 页面记录数
pBean.PageSize = p.PageSize
// 计算偏移量
offset := (p.PageIndex - 1) * p.PageSize
// 执行分页查询
var results []T
db = queryFn(db)
if err := db.Offset(offset).Limit(p.PageSize).Find(&results).Error; err != nil {
return pBean, err
}
// 分页记录
pBean.List = results
var total int
err := db.Model(new(T)).Count(&total).Error
if err != nil {
return pBean, err
}
// 总记录数
pBean.TotalCount = total
// 总页数
pBean.CalculateTotalPage()
return pBean, nil
}
// PageBeanRes 数据返回的结构体
type PageBeanRes[U any] struct {
// PageNumber 页码
PageNumber int `json:"currentPage" description:"页码"`
// PageSize 页面记录数
PageSize int `json:"pageSize" description:"页面记录数"`
// TotalPage 总页数
TotalPage int `json:"totalPage" description:"总页数"`
// TotalCount 总记录数
TotalCount int `json:"totalCount" description:"总记录数"`
// Keyword 搜索的关键字
Keyword string `json:"keyword" description:"搜索的关键字"`
// List 分页记录
List []U `json:"list" description:"分页记录"`
}
// NewPageBeanRes 创建一个新的PageBeanRes实例
func NewPageBeanRes[T, U any](pageBean PageBean[T], converter func(T) U) PageBeanRes[U] {
res := PageBeanRes[U]{}
res.PageNumber = pageBean.PageNumber
res.PageSize = pageBean.PageSize
res.TotalPage = pageBean.TotalPage
res.TotalCount = pageBean.TotalCount
res.Keyword = pageBean.Keyword
res.List = wrappers.Convert[T, U](pageBean.List, converter)
return res
}
2、转换包装器:
创建一个wrappers的文件夹
base_wrapper.go:
package wrappers
// Convert 包装器函数
func Convert[T, U any](items []T, converter func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = converter(item)
}
return result
}
3、模型实例:
创建一个models的文件夹
video_mode.go:
package models
import (
"time"
)
type Video struct {
ID int `json:"id"`
VideoName string `json:"video_name"`
VideoIcon string `json:"video_icon"`
VideoSearchID int `json:"video_search_id"`
CreateTime *time.Time `json:"create_time"`
UpdateTime *time.Time `json:"update_time"`
DetailURL string `json:"-"`
}
func (Video) TableName() string {
return "spider_video"
}
// VideoRes 响应返回的结构体
type VideoRes struct {
ID int `json:"id"`
VideoName string `json:"videoName"`
VideoIcon string `json:"videoIcon"`
CreateTime FormattedTime `json:"createTime"`
}
// VideoConvertVideoRes 将Video对象包装为VideoRes对象
func VideoConvertVideoRes(video Video) VideoRes {
var videoRes VideoRes
err := CopyTimeFields(&videoRes, &video)
if err != nil {
return VideoRes{}
}
return videoRes
}
common_model.go:
package models
import (
"encoding/json"
"reflect"
"time"
)
// FormattedTime 格式化后的时间类型
type FormattedTime struct {
*time.Time
}
// MarshalJSON 自定义JSON序列化方法
func (t FormattedTime) MarshalJSON() ([]byte, error) {
formatted := t.Format("2006-01-02 15:04:05")
return json.Marshal(formatted)
}
// CopyFields 复制src中与dst相同类型的字段到dst
func CopyFields(dst interface{}, src interface{}) error {
dstValue := reflect.ValueOf(dst).Elem()
srcValue := reflect.ValueOf(src).Elem()
dstType := dstValue.Type()
srcType := srcValue.Type()
for i := 0; i < dstValue.NumField(); i++ {
fieldName := dstType.Field(i).Name
srcField, exists := srcType.FieldByName(fieldName)
if exists {
srcFieldValue := srcValue.FieldByName(fieldName)
if srcField.Type == dstValue.Field(i).Type() && dstValue.Field(i).CanSet() {
dstValue.Field(i).Set(srcFieldValue)
}
}
}
return nil
}
// CopyTimeFields 复制值,特殊处理time类型
func CopyTimeFields(dst interface{}, src interface{}) error {
dstValue := reflect.ValueOf(dst).Elem()
srcValue := reflect.ValueOf(src).Elem()
dstType := dstValue.Type()
srcType := srcValue.Type()
for i := 0; i < dstValue.NumField(); i++ {
fieldName := dstType.Field(i).Name
srcField, exists := srcType.FieldByName(fieldName)
if !exists {
continue
}
srcFieldValue := srcValue.FieldByName(fieldName)
if srcField.Type != dstValue.Field(i).Type() {
if isTimeType(srcField.Type) && isFormattedTimeType(dstValue.Field(i).Type()) {
// 特殊处理time.Time转FormattedTime
newFormattedTime := reflect.New(dstValue.Field(i).Type()).Elem()
t := newFormattedTime.Addr().Interface().(*FormattedTime)
t.Time = srcFieldValue.Interface().(*time.Time)
dstValue.Field(i).Set(newFormattedTime)
} else {
continue
}
} else if dstValue.Field(i).CanSet() {
dstValue.Field(i).Set(srcFieldValue)
}
}
return nil
}
// 判断是否为time.Time类型
func isTimeType(t reflect.Type) bool {
return t.String() == "*time.Time" || t.String() == "time.Time"
}
// 判断是否为FormattedTime类型
func isFormattedTimeType(t reflect.Type) bool {
return t.String() == "models.FormattedTime" || t.String() == "*models.FormattedTime"
}
result_model.go:
package models
// Result 返回结果的结构体
type Result[T any] struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
// NewSuccessResult 创建一个表示成功的Result实例
func NewSuccessResult[T any](data T) Result[T] {
return Result[T]{Code: "0", Msg: "成功", Data: data}
}
// NewFailureResult 创建一个表示失败的Result实例
func NewFailureResult[T any](msg string) Result[T] {
return Result[T]{Code: "-1", Msg: msg, Data: *new(T)} // 使用new(T)来初始化T类型的零值
}
4、业务查询
创建一个controllers文件夹
admin_video_controller.go:
package controllers
import (
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"log"
"net/http"
"spider-video-go/configs"
"spider-video-go/models"
"spider-video-go/plugins"
"strings"
)
// 根据id过滤
func withIDFilter(id string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if id != "" {
return db.Where("id = ?", id)
}
return db
}
}
// 根据name过滤
func withNameFilter(name string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if strings.TrimSpace(name) != "" {
return db.Where("video_name LIKE ?", "%"+name+"%")
}
return db
}
}
// 根据时间范围过滤
func withTimeRangeFilter(startTime, endTime string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if startTime != "" {
db = db.Where("create_time >= ?", startTime)
}
if endTime != "" {
db = db.Where("create_time <= ?", endTime)
}
return db
}
}
// AdminPageVideo 搜索并分页
func AdminPageVideo(c *gin.Context) {
// 获取搜索的参数
name := c.Query("keyword")
// 页码
page := c.Query("page")
// 页面容量
pageSize := c.Query("pagesize")
// id
id := c.Query("id")
// 开始时间
startTime := c.Query("startTime")
// 结束时间
endTime := c.Query("endTime")
var pBean plugins.PageBean[models.Video]
// 基类
p := plugins.NewPageGormByStr[models.Video](page, pageSize)
// 派生类
pa := plugins.PageGormToPageAdmin(p)
pa.Keyword = name
pa.Id = id
queryFn := func(db *gorm.DB) *gorm.DB {
return db.Scopes(
withIDFilter(id),
withNameFilter(name),
withTimeRangeFilter(startTime, endTime),
).Order("create_time desc")
}
// 执行分页查询
pBean, err := pa.PageInfoFunc(configs.Db, queryFn)
if err != nil {
log.Println(err)
}
// 分页包装返回的数据
pageRes := plugins.NewPageBeanRes[models.Video, models.VideoRes](pBean, models.VideoConvertVideoRes)
// 结果分页返回的数据
result := models.NewSuccessResult[plugins.PageBeanRes[models.VideoRes]](pageRes)
c.JSON(http.StatusOK,
result,
) //返回状态到客户端
}
user_controller.go:
package controllers
import (
"github.com/gin-gonic/gin"
"log"
"net/http"
"spider-video-go/configs"
"spider-video-go/middlewares"
"spider-video-go/models"
"strings"
)
type LoginForm struct {
Username string `form:"username"`
Password string `form:"password"`
CaptchaId string `json:"captchaId"`
CaptchaValue string `json:"captchaValue"`
}
type TokenRes struct {
Token string `json:"token"`
}
func Login(c *gin.Context) {
var form LoginForm
if err := c.ShouldBind(&form); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, models.NewFailureResult[TokenRes]("表单数据绑定失败,请检查输入信息"))
return
}
if form.Username == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, models.NewFailureResult[TokenRes]("用户名不能为空"))
return
}
if form.Password == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, models.NewFailureResult[TokenRes]("密码不能为空"))
return
}
if form.CaptchaId == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, models.NewFailureResult[TokenRes]("验证码id不能为空"))
return
}
if form.CaptchaValue == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, models.NewFailureResult[TokenRes]("验证码不能为空"))
return
}
if !VerifyCaptcha(form.CaptchaId, form.CaptchaValue) {
c.AbortWithStatusJSON(http.StatusOK, models.NewFailureResult[TokenRes]("验证码检验失败"))
return
}
user := models.User{}
if err := configs.DbUser.Where("name = ? and password = ?", form.Username, form.Password).First(&user).Error; err != nil {
log.Println(err)
c.AbortWithStatusJSON(http.StatusInternalServerError, models.NewFailureResult[string]("用户名或者密码错误"))
return
}
// 创建Token
token, err := middlewares.GenerateJWTToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.NewFailureResult[string]("token创建失败"))
return
}
tokenRes := TokenRes{
token,
}
c.JSON(http.StatusOK, models.NewSuccessResult[TokenRes](tokenRes))
}
// VerifyCaptcha 验证验证码
func VerifyCaptcha(captchaId string, captchaValue string) bool {
captchaValue = strings.ToLower(captchaValue)
// 验证验证码
if Store.Verify(captchaId, captchaValue, true) {
return true
} else {
return false
}
}
// CheckToken 校验token并返回用户信息
func CheckToken(c *gin.Context) {
// 查询我们之前在日志中间件,注入的键值数据
userId := c.MustGet("user_id").(string)
user := models.User{}
if err := configs.DbUser.Where("id = ?", userId).First(&user).Error; err != nil {
log.Println(err)
c.AbortWithStatusJSON(http.StatusInternalServerError, models.NewFailureResult[string]("token存在错误"))
return
}
// UserRep 用户响应
type UserRep struct {
// 用户名
AvatarName string `json:"avatarName"`
// 用户头像
Avatar string `json:"avatar"`
}
userRep := UserRep{
user.Name,
"http://www.haijin.xyz:8688/HaijinWeblogPhoto/uploadFiles/avatar.jpg",
}
c.JSON(http.StatusOK, models.NewSuccessResult[UserRep](userRep))
}
captcha_controller.go:
package controllers
import (
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
"image/color"
"log"
"net/http"
"spider-video-go/models"
"strings"
)
// Store 定义一个全局的验证码存储器
var Store = base64Captcha.DefaultMemStore
// CaptchaRes 验证码返回
type CaptchaRes struct {
CaptchaId string `json:"captchaId"`
CaptchaImage string `json:"captchaImage"`
}
// Captcha 获取图形验证码
func Captcha(c *gin.Context) {
// 设置字符验证码的配置
driver := base64Captcha.DriverString{
Height: 80,
Width: 240,
NoiseCount: 0,
ShowLineOptions: base64Captcha.OptionShowHollowLine,
Length: 4,
Source: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
BgColor: &color.RGBA{R: 255, G: 255, B: 255, A: 255},
Fonts: []string{"wqy-microhei.ttc"},
}
captcha := base64Captcha.NewCaptcha(&driver, Store)
// 生成验证码
id, b64s, answer, err := captcha.Generate()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"message": "Could not generate captcha",
})
return
}
// 将验证码答案转换为小写
answer = strings.ToLower(answer)
_ = Store.Set(id, answer)
log.Println("验证码:", answer)
captchaRes := CaptchaRes{
CaptchaId: id,
CaptchaImage: b64s,
}
result := models.NewSuccessResult[CaptchaRes](captchaRes)
// 返回验证码 ID 和图像的 Base64 编码
c.JSON(http.StatusOK, result)
}
5、设置路由(静态资源的部署):
创建一个routers的文件夹
router.go:
package routers
import (
"github.com/gin-gonic/gin"
"github.com/gobuffalo/packr/v2"
"net/http"
"spider-video-go/controllers"
"spider-video-go/middlewares"
)
func SetRouter(router *gin.Engine) {
//box := packr.NewBox("../static")
box := packr.New("staticBox", "../static")
//router.Static("/assets", "../static/assets")
router.StaticFS("/assets", gin.Dir("./static/assets", true))
router.GET("/", func(c *gin.Context) {
indexFileBytes, err := box.Find("index.html")
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", indexFileBytes)
})
v2 := router.Group("/admin/")
{
// 视频分页
v2.GET("video/page", middlewares.AuthMiddleware(), controllers.AdminPageVideo)
}
}
6、程序入口:
main.go:
package main
import (
"github.com/gin-gonic/gin"
"spider-video-go/routers"
)
func main() {
router := gin.Default()
// 设置路由
routers.SetRouter(router)
router.Run("127.0.0.1:8894")
}
7、数据库配置:
创建一个configs的文件夹:
conf_db.go:
package configs
import (
_ "github.com/go-sql-driver/mysql"
"github.com/jinzhu/gorm"
"log"
)
var (
// 第一个数据库
Db *gorm.DB
sqlConnection = "root:123456@tcp(127.0.0.1:3306)/ggg?" +
"charset=utf8&parseTime=true"
// 第二个数据库
DbUser *gorm.DB
sqlConnectionUser = "root:123456@tcp(127.0.0.1:3306)/bbbb?" +
"charset=utf8&parseTime=true"
)
// 初始化
func init() {
//打开数据库连接
var err error
Db, err = gorm.Open("mysql", sqlConnection)
if err != nil {
log.Println(err)
panic("failed to connect database")
}
// 开启 SQL 语句打印
Db.LogMode(true)
//Db.AutoMigrate(&models.Video{})
//打开数据库连接
var errUser error
DbUser, errUser = gorm.Open("mysql", sqlConnectionUser)
if errUser != nil {
log.Println(errUser)
panic("failed to connect database")
}
// 开启 SQL 语句打印
DbUser.LogMode(true)
//Db.AutoMigrate(&models.Video{})
}
8、中间件
创建一个middlewares的文件夹
auth_middleware.go:
package middlewares
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"net/http"
"spider-video-go/models"
"strconv"
"strings"
"time"
)
// jwt密钥
var jwtSecret = []byte("123456")
// 超时的时间
var timeDay time.Duration = 1
type JWTClaim struct {
UserId string `json:"id"`
jwt.RegisteredClaims
}
// GenerateJWTToken 生成token
func GenerateJWTToken(userId uint) (string, error) {
expirationTime := time.Now().Add(timeDay * 24 * time.Hour)
claims := &JWTClaim{
UserId: strconv.Itoa(int(userId)),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// AuthMiddleware 鉴权中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// 使用 NewFailureResult 返回无授权头错误
resp := models.NewFailureResult[string]("没有提供授权头")
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
// 使用 NewFailureResult 返回无效授权头格式错误
resp := models.NewFailureResult[string]("授权头格式无效")
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
return
}
tokenStr := parts[1]
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaim{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil {
var ve *jwt.ValidationError
if ok := errors.As(err, &ve); ok {
switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0:
resp := models.NewFailureResult[string]("token无效")
c.AbortWithStatusJSON(http.StatusBadRequest, resp)
case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
resp := models.NewFailureResult[string]("令牌过期或尚未激活")
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
default:
resp := models.NewFailureResult[string]("无法处理这个令牌: " + err.Error())
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
}
return
}
// 使用 NewFailureResult 返回未知令牌错误
resp := models.NewFailureResult[string]("未知令牌错误")
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
return
}
if claims, ok := token.Claims.(*JWTClaim); ok && token.Valid {
c.Set("user_id", claims.UserId)
c.Next()
} else {
// 使用 NewFailureResult 返回无效令牌错误
resp := models.NewFailureResult[string]("无效令牌")
c.AbortWithStatusJSON(http.StatusUnauthorized, resp)
}
}
}