Go Web 小项目实战-视频、后台、爬虫

我爱海鲸 2024-07-03 19:39:54 go语言学习

简介分页查询、日期的处理、静态资源的部署

项目的目录结构:

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)
		}
	}
}

你好:我的2025