抖声——青训营项目核心点介绍 | 青训营笔记

424 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第6篇笔记。

项目结构

1.png

数据库设计

注:该文档为最初始的文档,实际数据表以 common 中的 model.go 为准

用户表

字段类型注释
idint64id,主键
namestring用户昵称,index
usernamestring登录用户名
passwordstring登录密码
follow_countint64关注总数
follower_countint64粉丝总数

视频表

字段类型注释
idint64id,主键
publish_idint64发布者id,外键对应于用户表id
play_urlstring视频播放地址
cover_urlstring视频封面地址
create_timestring创建时间,精确到秒
favorite_countint64视频点赞总数
comment_countint64视频评论总数
titlestring视频标题

评论表

字段类型注释
idint64评论id,主键
user_idint64评论人id,外键
contentstring评论内容
create_timestring评论时间,mm-dd
video_idint64评论视频id,外键

关注表

字段类型注释
idint64id,主键
user_idint64被关注人id,外键
follow_idint64关注人id,外键
create_timestring关注时间,精确到秒

视频点赞表

字段类型注释
idint64id,主键
user_idint64用户id,外键
video_idint64视频id,外键
create_timeint64点赞时间

全局鉴权接口设计

  • 思路:拦截所有请求,并获取get请求或post参数的token,这时候获取的是access token,调用jwt的校验token方法判断是否过期,由于登录接口会创建access token和refresh token,at有效期为两个小时,rt有效期为三十天,并且以at为redis的key,rt为value存入redis中,所以此时可以查询其rt,若rt也过期,代表用户三十天未登录,则需要重新登录;若未过期,则根据rt刷新at和rt,并删除redis的key,刷新token后需要传给前端,这时候采用给login接口发送post请求并携带token,login收到会直接return,就实现了前后台刷新token的方法。这种鉴权方法可以有效防止token被窃取,token频繁刷新,提高了web项目的安全性。
  • session、cookie、token的区别:我是倾向于存放token的, cookie不是很安全,别人可以分析存放在本地的cookie并进行cookie欺骗,session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能,综合起来使用token安全性更高。
  • 核心代码:
func Auth() gin.HandlerFunc {
   //先判断请求头是否为空,为空则为游客状态
   return func(c *gin.Context) {
      token := ""
      token = c.DefaultQuery("token", "")
      if token == "" {
         token = c.PostForm("token")
      }
      if token == "" {
         c.Set("userId", "")
         c.Next()
         return
      }

      //有token,判断是否过期: 2h
      timeOut, err := util.ValidToken(token)

      if err != nil || timeOut {
         //token过期或者解析token发生错误
         log.Logger.Info("token expire or parse token error")
         log.Logger.Debug("valid token err", zap.Error(err))
         log.Logger.Info("valid refreshToken")

         // 30d token 能否取出
         value := db.Redis.Get(context.Background(), token)
         refreshToken, err := value.Result()
         if err != nil {
            // debug
            log.Logger.Debug("get refreshToken from redis err", zap.Error(err))

            log.Logger.Error("token不合法,请确认你是否登录")
            c.JSON(200, gin.H{
               "status_code": 400,
               "msg":         "token不合法,请确认你操作是否有误",
            })
            c.Abort()
            return
         }

         // 可以取出30d token, 检查是否过期
         timeOut, err := util.ValidToken(refreshToken)
         if err != nil || timeOut {
            log.Logger.Debug("valid refreshToken err:", zap.Error(err))
            //refreshToken出问题,表明用户三十天未登录,需要重新登录
            log.Logger.Info("user need login again")
            db.Redis.Del(context.Background(), token)
            c.Set("userId", "")
            c.Next()
            return
         }

         // refresh token 没过期
         userId, err := util.GetUserIDFormToken(refreshToken)
         if err != nil {
            log.Logger.Error("parse token to get uid error:", zap.Error(err))
            //token解析不了的情况一般很少,暂时panic一下
            panic(err)
         }

         //根据refreshToken 更新 accessToken
         accessToken, err := util.CreateAccessToken(userId)
         if err != nil {
            log.Logger.Error("create acc token error:", zap.Error(err))
            //token解析不了的情况一般很少,暂时panic一下
            panic(err)
         }

         //更新后,重新设置redis的key
         newRefreshToken, err := util.CreateRefreshToken(userId)
         if err != nil {
            log.Logger.Error("creat ref token error:", zap.Error(err))
            panic(err)
         }

         if err := db.Redis.Set(context.Background(), token, newRefreshToken, 30*24*time.Hour).Err(); err != nil {
            log.Logger.Error("create redis acc token error", zap.Error(err))
         } else {
            log.Logger.Debug("redis set success")
         }

         //后台登录更新token,本质上就是给login接口发送请求
         req := BackendLoginReq{}
         data, err := json.MarshalIndent(&req, "", "\t")
         if err != nil {
            log.Logger.Error("json parse error")
            c.Abort()
            return
         }
         request, err := http.NewRequest("POST", "http://localhost:8090/douyin/user/login?token="+accessToken, bytes.NewBuffer(data))
         if err != nil {
            log.Logger.Error("login move forward error")
            c.Abort()
            return
         }
         request.Header.Set("Content-Type", "application/json")
         client := &http.Client{}
         post, err := client.Do(request)
         if post.StatusCode == 200 {
            //发送登录请求成功
            c.Set("userId", userId)
            c.Next()
            return
         } else {
            log.Logger.Error("login move forward error")
            c.Abort()
            return
         }
      }
      //未过期
      userId, err := util.GetUserIDFormToken(token)
      if err != nil {
         panic(err)
      }
      c.Set("userId", userId)
      c.Next()
   }

}

日志系统

采用zap高性能日志系统,全局日志打印并输出到文件,并以不同的命名区分不同的日志级别,可自定义日志输出格式以及时间格式

对象存储

由于需要有个地方存储视频,故采用阿里云oss来存储发布的视频,调用阿里云oss接口并返回视频url,封面url只需要在视频url后面加上?x-oss-process=video/snapshot,t_0,f_jpg,w_0,h_0,m_fast,ar_auto即可。并且前端传入文件后后台会进行文件类型判断,判断是否是视频,如果不是则会直接打回,提高安全性。

1. 通过文件后缀名:修改文件的后缀名即可实现欺骗系统 ❌
1. 通过Content-Type判断,也能被篡改 ❌
1. 通过文件流判断  ✅

采用文件流来判断,值得注意的是,调用一次Reader读取文件之后会损坏文件,从而无法上传正常的文件,因此需要data.open两次,分别判断文件类型和用于上传。

func (v Video) PublishAction(data *multipart.FileHeader, title string, publishId int64) error {
   //oss.CreateBucket(BucketName)
   // 获取文件
   file, err := data.Open()
   if err != nil {
      return err
   }
   defer func(file multipart.File) {
      err := file.Close()
      if err != nil {

      }
   }(file)
   checkFile, err := data.Open()
   if err != nil {
      return err
   }
   defer func(file multipart.File) {
      err := file.Close()
      if err != nil {

      }
   }(checkFile)
   // 判断是否为视频
   buf := bytes.NewBuffer(nil)
   if _, err := io.Copy(buf, checkFile); err != nil {
      logrus.Error("copy file error", err)
      return err
   }
   if filetype.IsVideo(buf.Bytes()) == false {
      logrus.Error("file is not video")
      return errors.New("not a video")
   }

   // 存储到oss
   ok, err := oss.UploadVideoToOss(BucketName, data.Filename, file)
   if err != nil {
      return err
   }
   if !ok {
      return errors.New("upload video error")
   }

   // 获取url 存储到数据库
   videoUrl, imgUrl, err := oss.GetOssVideoUrlAndImgUrl(BucketName, data.Filename)
   if err != nil {
      return err
   }
   video := model.Video{
      PublishId:     publishId,
      PlayUrl:       videoUrl,
      CoverUrl:      imgUrl,
      FavoriteCount: 0,
      CommentCount:  0,
      Title:         title,
      CreateTime:    time.Now().Unix(),
   }
   err = db.MySQL.Model(&model.Video{}).Create(&video).Error
   if err != nil {
      return err
   }
   return nil
}

通用result设计,方便统一代码规范

返回错误json示例

res.Error(c, res.Status{
         StatusCode: res.LoginErrorStatus.StatusCode,
         StatusMsg:  res.LoginErrorStatus.StatusMsg,
      })
      return

返回成功json示例

res.Success(c, res.R{
      "userid": data.UserId,
      "token":  data.Token,
   })

添加错误码示例

在result包下

image.png res.R的设计借鉴了gin.H,使用过程变得更方便,返回结果符合文档要求。