MisakaOJ项目,第一天记录内容
第一天的表结构需要有所修改。
gotype 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"
}
提示
上面的外键是有问题的,外键指定的列和依赖的列数据类型不同,因此自动建表时必须这样连接数据库:
gofunc 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表修改如下:
gotype 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"
}
gotype 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"
}
提示
上面的表结构可以被简化为这样:
gotype 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
的表,默认在该表中存储两个表的主键以维护多对多关系。
这一次的目的是为该接口提供按分类查询的能力。
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层代码:
gofunc 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
}
该接口作用为通过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层:
gofunc GetQuestionDetail(identity string) *gorm.DB {
return DB.Model(new(Question)).
Preload("QuestionCategories").Preload("QuestionCategories.Category").
Where("identity = ?", identity)
}
该接口作用为通过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层
gofunc GetUserDetail(identity string) *gorm.DB {
return DB.Model(new(User)).Where("identity = ?", identity).Omit("password")
}
提示
避免返回的结果中有password,也可以通过修改json注解实现:
gotype User struct {
...
Password string `gorm:"column:password;type:varchar(32);" json:"-"` // 密码 加密后
...
}
这样返回的内容中不会有password了:
该接口作用和获取题目列表接口相近。
首先,修改提交记录的model,使其关联用户表和问题表。
gotype 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层的函数:
gofunc 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里面:
gofunc 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
}
jwt安装:
shellgo get -u github.com/golang-jwt/jwt/v5
该接口作用为给定用户名和密码,登录成功即返回一个Token。
先封装jwt:
goJwtExpiredTime = 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)
}
安装对应的库:
shellgo get github.com/jordan-wright/email
封装发送验证码的方法:
goEmailAddr = "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",
})
}
安装UUID包和go-redis包:
gogo get github.com/google/uuid
go get github.com/redis/go-redis/v9
生成UUID:
gofunc GenerateUUID() string {
// uuid.NewString() 等效 Must(NewRandom()).String() 如果有error直接panic NewRandom是v4标准
return uuid.NewString()
}
Redis初始化:
govar 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 许可协议。转载请注明出处!