2024-08-15
Go & 后端
00

目录

1 修改表结构
2 修改查询问题列表接口
3 获取问题详情接口
4 获取用户信息接口
5 获取提交记录列表接口
5 用户登录接口
6 验证码发送接口
7 用户注册接口

MisakaOJ项目,第一天记录内容

1 修改表结构

第一天的表结构需要有所修改。

  • 问题和分类之间是多对多关系,需要新建一个表来维护多对多关系。
go
type QuestionCategory struct { gorm.Model ProblemId string `gorm:"column:problem_id;type:varchar(255);" json:"problem_id"` // 问题ID CategoryId string `gorm:"column:category_id;type:varchar(255);" json:"category_id"` // 分类ID Category *Category `gorm:"foreignKey:id;references:category_id"` // 设置外键 } func (table *QuestionCategory) TableName() string { return "question_category" }

提示

上面的外键是有问题的,外键指定的列和依赖的列数据类型不同,因此自动建表时必须这样连接数据库:

go
func Init() *gorm.DB { db, e := gorm.Open(mysql.Open("root:password@tcp(127.0.0.1:3306)/misaka_oj?charset=utf8mb4&parseTime=True&loc=Local"), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, // 禁止自动创建关联对应的外键 }) if e != nil { log.Println("Mysql Init Failed: ", e) return nil } return db }

Gorm支持在数据库中没有外键的基础上维护关联关系。

对应的Category表和Question表修改如下:

go
type Category struct { gorm.Model Identity string `gorm:"column:identity;type:varchar(36);" json:"identity"` // 分类唯一标识 Name string `gorm:"column:name;type:varchar(100);" json:"name"` // 分类名称 ParentId int `gorm:"column:parent_id;type:int(11);default:0;" json:"parent_id"` // 父级ID 自身为顶级是默认为0 } func (table *Category) TableName() string { return "category" }
go
type Question struct { gorm.Model Identity string `gorm:"column:identity;type:varchar(36);" json:"identity"` // 题目唯一标识 QuestionCategories []*QuestionCategory `gorm:"foreignKey:question_id;references:id"` // 关联问题分类表 Title string `gorm:"column:title;type:varchar(255);" json:"title"` // 题目标题 Content string `gorm:"column:content;type:text;" json:"content"` // 题目正文 MaxRuntime int `gorm:"column:max_runtime;type:int(11)" json:"max_runtime"` // 最大运行时长 MaxMem int `gorm:"column:max_memory;type:int(11)" json:"max_mem"` // 最大允许内存 } func (table *Question) TableName() string { return "question" }

提示

上面的表结构可以被简化为这样:

go
type Question struct { gorm.Model Identity string `gorm:"column:identity;type:varchar(36);" json:"identity"` // 题目唯一标识 Categories []Category `gorm:"many2many:question_category;"` // 多对多关联到分类上 Title string `gorm:"column:title;type:varchar(255);" json:"title"` // 题目标题 Content string `gorm:"column:content;type:text;" json:"content"` // 题目正文 MaxRuntime int `gorm:"column:max_runtime;type:int(11)" json:"max_runtime"` // 最大运行时长 MaxMem int `gorm:"column:max_memory;type:int(11)" json:"max_mem"` // 最大允许内存 } type Category struct { gorm.Model Identity string `gorm:"column:identity;type:varchar(36);" json:"identity"` // 分类唯一标识 Name string `gorm:"column:name;type:varchar(100);" json:"name"` // 分类名称 ParentId int `gorm:"column:parent_id;type:int(11);default:0;" json:"parent_id"` // 父级ID 自身为顶级是默认为0 Questions []Question `gorm:"many2many:question_category;"` // 多对多关联到问题上 }

这样,Gorm在通过AutoMigrate创建表时,会自动创建一个名为question_category的表,默认在该表中存储两个表的主键以维护多对多关系。

2 修改查询问题列表接口

这一次的目的是为该接口提供按分类查询的能力。

Handler层代码:

go
// GetQuestionList // @Tags 公共方法 // @Summary 获取问题列表 // @Param page query int false "page" // @Param size query int false "size" // @Param keyword query string false "keyword" // @Param category_identity query string false "category_identity" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /question-list [get] func GetQuestionList(c *gin.Context) { page, err := strconv.Atoi(c.DefaultQuery("page", constants.DefaultPage)) if err != nil { log.Println("Get Param Error: ", err) return } size, err := strconv.Atoi(c.DefaultQuery("size", constants.DefaultSize)) if err != nil { log.Println("Get Param Error: ", err) return } page = (page - 1) * size // page到offset的转换 keyword := c.Query("keyword") categoryIdentity := c.Query("category_identity") var count int64 // tx是transaction的缩写 tx := models.GetQuestionList(keyword, categoryIdentity) result := make([]*models.Question, 0) err = tx.Count(&count).Omit("content").Offset(page).Limit(size).Find(&result).Error // Omit 指定一行要忽略的数据 if err != nil { log.Println("Get Problem List Error: ", err) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": gin.H{ "list": result, "count": count, }, }) }

model层代码:

go
func GetQuestionList(keyword, categoryIdentity string) *gorm.DB { // 先按keyword查问题 tx := DB.Model(new(Question)).Preload("QuestionCategories").Preload("QuestionCategories.Category"). Where("title like ? OR content like ?", "%"+keyword+"%", "%"+keyword+"%") // Preload即将关联的表数据加载进来 // 再按分类筛选 if categoryIdentity != "" { tx.Joins("RIGHT JOIN question_category qc on qc.question_id = question.id"). Where("qc.category_id = (SELECT c.id FROM category c WHERE c.identity = ?)", categoryIdentity) } return tx }

3 获取问题详情接口

该接口作用为通过identity拿到一个问题的所有信息。

Handler层:

go
// GetQuestionDetail // @Tags 公共方法 // @Summary 获取问题详情 // @Param identity query string true "question_identity" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /question-detail [get] func GetQuestionDetail(c *gin.Context) { identity := c.Query("identity") if identity == "" { // 如果没传identity c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "Parameter identity is missed! ", }) return } tx := models.GetQuestionDetail(identity) data := &models.Question{} e := tx.First(data).Error if e != nil { if errors.Is(e, gorm.ErrRecordNotFound) { // 该错误是First独有 如果First没查出来数据 就会返回该错误 说明数据不存在 c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "Question is not Exist! ", }) return } c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "Error: " + e.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": data, }) return }

Model层:

go
func GetQuestionDetail(identity string) *gorm.DB { return DB.Model(new(Question)). Preload("QuestionCategories").Preload("QuestionCategories.Category"). Where("identity = ?", identity) }

4 获取用户信息接口

该接口作用为通过identity获取用户的所有信息。

Handler层:

go
// GetUserDetail // @Tags 公共方法 // @Summary 获取用户信息 // @Param identity query string true "user_identity" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /user_detail [get] func GetUserDetail(c *gin.Context) { identity := c.Query("identity") if identity == "" { // 如果没传identity c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "Parameter identity is missed! ", }) return } data := &models.User{} tx := models.GetUserDetail(identity) e := tx.First(data).Error if e != nil { if errors.Is(e, gorm.ErrRecordNotFound) { // 该错误是First独有 如果First没查出来数据 就会返回该错误 说明数据不存在 c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "User is not Exist! ", }) return } c.JSON(http.StatusOK, gin.H{ "code": -1, "message": "Error: " + e.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": data, }) return }

Model层

go
func GetUserDetail(identity string) *gorm.DB { return DB.Model(new(User)).Where("identity = ?", identity).Omit("password") }

提示

避免返回的结果中有password,也可以通过修改json注解实现:

go
type User struct { ... Password string `gorm:"column:password;type:varchar(32);" json:"-"` // 密码 加密后 ... }

这样返回的内容中不会有password了:

image.png

5 获取提交记录列表接口

该接口作用和获取题目列表接口相近。

首先,修改提交记录的model,使其关联用户表和问题表。

go
type Submit struct { gorm.Model Identity string `gorm:"column:identity;type:varchar(36);" json:"identity"` // 提交记录唯一标识 QuestionIdentity string `gorm:"column:question_identity;type:varchar(36);" json:"question_identity"` // 问题唯一标识 Question *Question `gorm:"foreignKey:identity;reference:question_identity"` // 到问题表的关联 UserIdentity string `gorm:"column:user_identity;type:varchar(36);" json:"user_identity"` // 用户唯一标识 User *User `gorm:"foreignKey:identity;references:user_identity"` // 到用户表的关联 Path string `gorm:"column:path;type:varchar(255);" json:"path"` // 保存提交代码位置的路径 Status int `gorm:"column:status;type:tinyint(1);default:-1;" json:"status"` // 提交记录状态 // status默认为-1, 0为待判断,1为答案正确,2为答案错误,3运行超时,4运行超过最大内存 }

之后,写对应的Model层的函数:

go
func GetSubmitList(questionIdentity, userIdentity string, status int) *gorm.DB { tx := DB.Model(new(Submit)).Preload("Question").Preload("User") if questionIdentity != "" { tx.Where("question_identity = ?", questionIdentity) } if userIdentity != "" { tx.Where("user_identity = ?", userIdentity) } if status != -1 { // status不为-1时有效 tx.Where("status = ?", status) } return tx }

最后,写Handler层。

go
// GetSubmitList // @Tags 公共方法 // @Summary 获取提交记录列表 // @Param page query int false "page" // @Param size query int false "size" // @Param user_identity query string false "user_identity" // @Param question_identity query string false "question_identity" // @Param status query int false "status" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /submit_list [get] func GetSubmitList(c *gin.Context) { page, e := strconv.Atoi(c.DefaultQuery("page", constants.DefaultPage)) if e != nil { ErrorHandler(c, "Get Param page Error: "+e.Error()) return } size, e := strconv.Atoi(c.DefaultQuery("size", constants.DefaultSize)) if e != nil { ErrorHandler(c, "Get Param size Error: "+e.Error()) return } page = (page - 1) * size // page到offset的转换 status, e := strconv.Atoi(c.DefaultQuery("status", "-1")) if e != nil { ErrorHandler(c, "Get Param status Error: "+e.Error()) return } userIdentity := c.Query("user_identity") questionIdentity := c.Query("question_identity") var count int64 data := make([]*models.Submit, 0) tx := models.GetSubmitList(questionIdentity, userIdentity, status) e = tx.Count(&count).Omit("path").Offset(page).Limit(size).Find(&data).Error if e != nil { ErrorHandler(c, "Query Database Error: "+e.Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": map[string]any{ "count": count, "list": data, }, }) }

提示

这次的Omit我放到了Handler中,其实也可以通过这种写法放到Preload里面:

go
func GetSubmitList(questionIdentity, userIdentity string, status int) *gorm.DB { tx := DB.Model(new(Submit)).Preload("Question", func(db *gorm.DB) *gorm.DB { return db.Omit("content") }).Preload("User") ... return tx }

5 用户登录接口

jwt安装:

shell
go get -u github.com/golang-jwt/jwt/v5

该接口作用为给定用户名和密码,登录成功即返回一个Token。

先封装jwt:

go
JwtExpiredTime = time.Now().Add(30 * time.Minute) // Token默认过期时间 过期时间30分钟 JwtKey = []byte("Misaka19327") // jwt所用的密钥 type UserClaim struct { Identity string jwt.RegisteredClaims } // GenerateToken 根据给定的用户Identity 生成一个Token 默认情况下过期时间为30分钟 func GenerateToken(Identity string) (string, error) { userClaim := &UserClaim{ Identity: Identity, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(constants.JwtExpiredTime), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, userClaim) tokenString, e := token.SignedString(constants.JwtKey) if e != nil { return "", errors.Join(constants.TokenGenerateErr, e) } return tokenString, nil } // ParseToken 根据给定的token进行解析 返回用户Identity 并且判断是否过期 func ParseToken(token string) (string, error) { userClaim := new(UserClaim) claim, e := jwt.ParseWithClaims(token, userClaim, func(token *jwt.Token) (interface{}, error) { return constants.JwtKey, nil }) if e != nil { return "", errors.Join(constants.TokenParseErr, e) } if claim.Valid { // 是否可用 return userClaim.Identity, nil } // 已过期 return "", constants.TokenIsExpired }

提示

之后的我来捉个bug,ExpiredAt要求一个动态生成的时间值,所以这块还得改,否则过期时间是绝对固定的一个时间点。

go
... RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.JwtExpiredTime)), }, var JwtExpiredTime = 1 * time.Hour ...

然后是密码计算md5值:

go
// GetStringMd5 计算传入的字符串的md5值 并且按字符串返回 func GetStringMd5(s string) string { return fmt.Sprintf("%x", md5.Sum([]byte(s))) }

之后写Handler层:

go
// Login // @Tags 公共方法 // @Summary 用户登录入口 // @Param username formData string true "username" // @Param password formData string true "password" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /login [post] func Login(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") if username == "" || password == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"username or password") return } password = util.GetStringMd5(password) userDetail := &models.User{} tx := models.GetUserDetailByUsername(username) e := tx.First(userDetail).Error if e != nil { if errors.Is(e, gorm.ErrRecordNotFound) { ErrorHandler(c, constants.UserNotFoundErr.Error()+"username") return } ErrorHandler(c, errors.Join(constants.DataBaseQueryErr, e).Error()) return } if password != userDetail.Password { ErrorHandler(c, constants.LoginPwdErr.Error()) return } token, e := util.GenerateToken(userDetail.Identity) if e != nil { ErrorHandler(c, e.Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "token": token, }) }

对应的Model层:

go
// GetUserDetailByIdent是之前改名的GetUserDetail func GetUserDetailByIdentity(identity string) *gorm.DB { return DB.Model(new(User)).Where("identity = ?", identity) } func GetUserDetailByUsername(username string) *gorm.DB { return DB.Model(new(User)).Where("username = ?", username) }

6 验证码发送接口

安装对应的库:

shell
go get github.com/jordan-wright/email

封装发送验证码的方法:

go
EmailAddr = "misaka19327@qq.com" // 使用的邮箱帐号 EmailSender = "Misaka19327" EmailSMTPHostName = "smtp.qq.com" // QQ邮箱的SMTP服务器域名 EmailSMTPAuthCode = "xxxxxxxxxxxxxxxx" // SMTP授权码 在QQ邮箱中当密码用 EmailSMTPServerPort = ":465" // SMTP服务器端口 587端口也可用 func SendVerifyCode(destAddr, code string) error { e := email.NewEmail() e.From = constants.EmailSender + " <" + constants.EmailAddr + ">" e.To = []string{destAddr} e.Subject = "MisakaOJ验证码" e.HTML = []byte("您的验证码为:<b>" + code + "<b>") return e.SendWithTLS( constants.EmailSMTPHostName+constants.EmailSMTPServerPort, smtp.PlainAuth(constants.EmailAddr, constants.EmailAddr, constants.EmailSMTPAuthCode, constants.EmailSMTPHostName), &tls.Config{ InsecureSkipVerify: true, // 跳过证书验证 否则会报EOF错误 无法发送邮件 ServerName: constants.EmailSMTPHostName, }) }

写Handler:

go
// SendVerifyCode // @Tags 公共方法 // @Summary 发送验证码 // @Param email_addr formData string true "email_addr" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /send_verify_code [post] func SendVerifyCode(c *gin.Context) { emailAddr := c.PostForm("email_addr") if emailAddr == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"email_addr") return } verifyCode := "123456" // 示例验证码 e := util.SendVerifyCode(emailAddr, verifyCode) if e != nil { ErrorHandler(c, errors.Join(constants.EmailSendErr, e).Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "message": "Verify Code Send Successfully", }) }

验证码生成:

go
// GenerateVerifyCode 根据给定长度生成验证码字符串 func GenerateVerifyCode(length int) string { r := rand.New(rand.NewSource(time.Now().UnixNano())) result := "" for i := 0; i < length; i++ { result += strconv.Itoa(r.Intn(10)) } return result }

为了防止邮箱多次注册,需要检查邮箱是否已经存在:

Model层

go
// GetUserDetailByColumn 通过指定列名和比较内容来查表 列名有identity username password mail phone func GetUserDetailByColumn(column, content string) *gorm.DB { return DB.Model(new(User)).Where(fmt.Sprintf("%s = ?", column), content) }

Handler层

go
// SendVerifyCode // @Tags 公共方法 // @Summary 发送验证码 // @Param email_addr formData string true "email_addr" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /send_verify_code [post] func SendVerifyCode(c *gin.Context) { emailAddr := c.PostForm("email_addr") if emailAddr == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"email_addr") return } var count int64 e := models.GetUserDetailByColumn("mail", emailAddr).Count(&count).Error if count != 0 { // 说明已被注册 ErrorHandler(c, constants.DataIsDuplicate.Error()+"mail ") return } verifyCode := util.GenerateVerifyCode(6) e = util.SendVerifyCode(emailAddr, verifyCode) if e != nil { ErrorHandler(c, errors.Join(constants.EmailSendErr, e).Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "message": "Verify Code Send Successfully", }) }

7 用户注册接口

安装UUID包和go-redis包:

go
go get github.com/google/uuid go get github.com/redis/go-redis/v9

生成UUID:

go
func GenerateUUID() string { // uuid.NewString() 等效 Must(NewRandom()).String() 如果有error直接panic NewRandom是v4标准 return uuid.NewString() }

Redis初始化:

go
var RDB = InitRedis() func InitRedis() *redis.Client { return redis.NewClient(&redis.Options{ Addr: "127.0.0.1:23456", Password: "", DB: 0, }) }

发送验证码接口修改,需要将发送的验证码存进Redis:

go
// SendVerifyCode // @Tags 公共方法 // @Summary 发送验证码 // @Param email_addr formData string true "email_addr" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /send_verify_code [post] func SendVerifyCode(c *gin.Context) { emailAddr := c.PostForm("email_addr") if emailAddr == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"email_addr") return } var count int64 e := models.GetUserDetailByColumn("mail", emailAddr).Count(&count).Error if count != 0 { // 说明已被注册 ErrorHandler(c, constants.DataIsDuplicate.Error()+"mail ") return } verifyCode := util.GenerateVerifyCode(6) e = models.RDB.Set(c, emailAddr, verifyCode, time.Minute*5).Err() if e != nil { ErrorHandler(c, errors.Join(constants.RedisSetErr, e).Error()) return } e = util.SendVerifyCode(emailAddr, verifyCode) if e != nil { ErrorHandler(c, errors.Join(constants.EmailSendErr, e).Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "message": "Verify Code Send Successfully", }) }

之后是用户注册流程:把所有参数取出来,然后先校验验证码是否正确,然后查用户名是否重复,最后存进数据库。

go
// Register // @Tags 公共方法 // @Summary 用户注册 // @Param mail formData string true "mail" // @Param username formData string true "username" // @Param password formData string true "password" // @Param verify_code formData string true "verify_code" // @Param phone formData string false "phone" // @Success 200 {data} json "{"code": "200", "data": ""}" // @Router /register [post] func Register(c *gin.Context) { mail := c.PostForm("mail") if mail == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"mail") return } username := c.PostForm("username") if username == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"username") return } password := c.PostForm("password") if password == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"password") return } userVerifyCode := c.PostForm("verify_code") if userVerifyCode == "" { ErrorHandler(c, constants.ParameterMissingErr.Error()+"verify_code") return } phone := c.PostForm("phone") // 检查验证码是否正确 verifyCode, e := models.RDB.Get(c, mail).Result() if e != nil { if errors.Is(e, redis.Nil) { // 没有值 ErrorHandler(c, constants.VCodeNotExistErr.Error()) } ErrorHandler(c, errors.Join(constants.RedisGetErr, e).Error()) return } if verifyCode != userVerifyCode { // 验证码不正确 ErrorHandler(c, constants.VCodeCheckErr.Error()) return } // 检验用户名是否重复 var count int64 e = models.GetUserDetailByColumn("username", username).Count(&count).Error if count != 0 { // 说明已被注册 ErrorHandler(c, constants.DataIsDuplicate.Error()+"username ") return } data := &models.User{ Name: username, Identity: util.GenerateUUID(), Password: util.GetStringMd5(password), Mail: mail, Phone: phone, } e = models.NewUser(data) if e != nil { ErrorHandler(c, errors.Join(constants.DataBaseInsertErr, e).Error()) return } var token string token, e = util.GenerateToken(data.Identity) if e != nil { ErrorHandler(c, e.Error()) return } c.JSON(http.StatusOK, gin.H{ "code": 200, "token": token, }) }

本文作者:御坂19327号

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!