这是我参与「第三届青训营 -后端场」笔记创作活动的的第6篇笔记。
项目结构
数据库设计
注:该文档为最初始的文档,实际数据表以 common 中的 model.go 为准
用户表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | int64 | id,主键 |
| name | string | 用户昵称,index |
| username | string | 登录用户名 |
| password | string | 登录密码 |
| follow_count | int64 | 关注总数 |
| follower_count | int64 | 粉丝总数 |
视频表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | int64 | id,主键 |
| publish_id | int64 | 发布者id,外键对应于用户表id |
| play_url | string | 视频播放地址 |
| cover_url | string | 视频封面地址 |
| create_time | string | 创建时间,精确到秒 |
| favorite_count | int64 | 视频点赞总数 |
| comment_count | int64 | 视频评论总数 |
| title | string | 视频标题 |
评论表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | int64 | 评论id,主键 |
| user_id | int64 | 评论人id,外键 |
| content | string | 评论内容 |
| create_time | string | 评论时间,mm-dd |
| video_id | int64 | 评论视频id,外键 |
关注表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | int64 | id,主键 |
| user_id | int64 | 被关注人id,外键 |
| follow_id | int64 | 关注人id,外键 |
| create_time | string | 关注时间,精确到秒 |
视频点赞表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | int64 | id,主键 |
| user_id | int64 | 用户id,外键 |
| video_id | int64 | 视频id,外键 |
| create_time | int64 | 点赞时间 |
全局鉴权接口设计
- 思路:拦截所有请求,并获取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包下
res.R的设计借鉴了gin.H,使用过程变得更方便,返回结果符合文档要求。