diff --git a/.gitignore b/.gitignore index 723ef36..c48e205 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.idea \ No newline at end of file +.idea +l1nk4i/config.toml +.gitignore +l1nk4i/test \ No newline at end of file diff --git a/l1nk4i/Dockerfile b/l1nk4i/Dockerfile new file mode 100644 index 0000000..0ce0926 --- /dev/null +++ b/l1nk4i/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.23.1 AS builder + +WORKDIR /app + +COPY . . + +RUN go build -o myapp + +FROM ubuntu:latest + +WORKDIR /app + +COPY --from=builder /app/myapp /app/ +COPY config.toml . +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +ENTRYPOINT ["./entrypoint.sh"] + +EXPOSE 8080 \ No newline at end of file diff --git a/l1nk4i/api/admin/deleteAnswer.go b/l1nk4i/api/admin/deleteAnswer.go new file mode 100644 index 0000000..470b4e9 --- /dev/null +++ b/l1nk4i/api/admin/deleteAnswer.go @@ -0,0 +1,32 @@ +package admin + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func DeleteAnswer(c *gin.Context) { + answerID := c.Param("answer-id") + + session := sessions.Default(c) + role, exists := session.Get("role").(string) + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + if role != "admin" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + err := db.DeleteAnswer(answerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer-id"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "delete answer successful!"}) +} diff --git a/l1nk4i/api/admin/deleteQuestion.go b/l1nk4i/api/admin/deleteQuestion.go new file mode 100644 index 0000000..511a339 --- /dev/null +++ b/l1nk4i/api/admin/deleteQuestion.go @@ -0,0 +1,32 @@ +package admin + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func DeleteQuestion(c *gin.Context) { + questionID := c.Param("question-id") + + session := sessions.Default(c) + role, exists := session.Get("role").(string) + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + if role != "admin" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + err := db.DeleteQuestion(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question-id"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "delete question successful!"}) +} diff --git a/l1nk4i/api/answer/create.go b/l1nk4i/api/answer/create.go new file mode 100644 index 0000000..a8590fe --- /dev/null +++ b/l1nk4i/api/answer/create.go @@ -0,0 +1,50 @@ +package answer + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "l1nk4i/db" + "net/http" +) + +func Create(c *gin.Context) { + questionID := c.Param("question-id") + + var answerInfo struct { + Content string `json:"content"` + } + + if err := c.ShouldBind(&answerInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + session := sessions.Default(c) + userID, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + // Verify that the question exists + _, err := db.GetQuestionByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question_id"}) + return + } + + answer := db.Answer{ + AnswerID: uuid.New().String(), + UserID: userID, + QuestionID: questionID, + Content: answerInfo.Content, + } + if err := db.CreateAnswer(&answer); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "create answer error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"answer_id": answer.AnswerID}) + return +} diff --git a/l1nk4i/api/answer/delete.go b/l1nk4i/api/answer/delete.go new file mode 100644 index 0000000..77bb250 --- /dev/null +++ b/l1nk4i/api/answer/delete.go @@ -0,0 +1,40 @@ +package answer + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Delete(c *gin.Context) { + answerID := c.Param("answer-id") + + // Verify user identity + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + answer, err := db.GetAnswerByAnswerID(answerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer_id"}) + return + } + + if answer.UserID != userid { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + // Delete answer + err = db.DeleteAnswer(answerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete answer error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "delete answer successful!"}) +} diff --git a/l1nk4i/api/answer/get.go b/l1nk4i/api/answer/get.go new file mode 100644 index 0000000..ede658c --- /dev/null +++ b/l1nk4i/api/answer/get.go @@ -0,0 +1,20 @@ +package answer + +import ( + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +// Get gets answers to the question by question_id +func Get(c *gin.Context) { + questionID := c.Param("question-id") + + answers, err := db.GetAnswersByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question_id"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": answers}) +} diff --git a/l1nk4i/api/answer/update.go b/l1nk4i/api/answer/update.go new file mode 100644 index 0000000..48b4fd6 --- /dev/null +++ b/l1nk4i/api/answer/update.go @@ -0,0 +1,49 @@ +package answer + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Update(c *gin.Context) { + answerID := c.Param("answer-id") + + var answerInfo struct { + Content string `json:"content"` + } + + if err := c.ShouldBindJSON(&answerInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + // Verify user identity + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + answer, err := db.GetAnswerByAnswerID(answerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer_id"}) + return + } + + if answer.UserID != userid { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + // Update answer + err = db.UpdateAnswer(answerID, answerInfo.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "update answer successful!"}) +} diff --git a/l1nk4i/api/question/best.go b/l1nk4i/api/question/best.go new file mode 100644 index 0000000..231a1c5 --- /dev/null +++ b/l1nk4i/api/question/best.go @@ -0,0 +1,53 @@ +package question + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Best(c *gin.Context) { + answerID := c.Param("answer-id") + questionID := c.Param("question-id") + + // Verify user identity + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + question, err := db.GetQuestionByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question-id"}) + return + } + + if question.UserID != userid { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + // Validate answer-id + answer, err := db.GetAnswerByAnswerID(answerID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer-id"}) + return + } + + if answer.QuestionID != questionID { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid answer-id"}) + return + } + + // Set the best answer + err = db.UpdateBestAnswer(questionID, answerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "update best answer failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "update best answer successful!"}) +} diff --git a/l1nk4i/api/question/create.go b/l1nk4i/api/question/create.go new file mode 100644 index 0000000..6cbbbbe --- /dev/null +++ b/l1nk4i/api/question/create.go @@ -0,0 +1,42 @@ +package question + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "l1nk4i/db" + "net/http" +) + +func Create(c *gin.Context) { + var questionInfo struct { + Title string `json:"title"` + Content string `json:"content"` + } + + if err := c.ShouldBind(&questionInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + session := sessions.Default(c) + userID, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + question := db.Question{ + QuestionID: uuid.New().String(), + UserID: userID, + Title: questionInfo.Title, + Content: questionInfo.Content, + } + if err := db.CreateQuestion(&question); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "create question error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"question_id": question.QuestionID}) + return +} diff --git a/l1nk4i/api/question/delete.go b/l1nk4i/api/question/delete.go new file mode 100644 index 0000000..6394260 --- /dev/null +++ b/l1nk4i/api/question/delete.go @@ -0,0 +1,40 @@ +package question + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Delete(c *gin.Context) { + questionID := c.Param("question-id") + + // Verify user identity + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + question, err := db.GetQuestionByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question_id"}) + return + } + + if question.UserID != userid { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + // Delete question + err = db.DeleteQuestion(questionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete question error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "delete question successful!"}) +} diff --git a/l1nk4i/api/question/get.go b/l1nk4i/api/question/get.go new file mode 100644 index 0000000..f372bd4 --- /dev/null +++ b/l1nk4i/api/question/get.go @@ -0,0 +1,20 @@ +package question + +import ( + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +// Get gets question by question_id +func Get(c *gin.Context) { + questionID := c.Param("question-id") + + question, err := db.GetQuestionByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question_id"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": question}) +} diff --git a/l1nk4i/api/question/list.go b/l1nk4i/api/question/list.go new file mode 100644 index 0000000..43d8468 --- /dev/null +++ b/l1nk4i/api/question/list.go @@ -0,0 +1,31 @@ +package question + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +// List lists user's all question_id +func List(c *gin.Context) { + session := sessions.Default(c) + userID, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + questions, err := db.GetQuestionByUserID(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + var questionIDs []string + for _, question := range *questions { + questionIDs = append(questionIDs, question.QuestionID) + } + + c.JSON(http.StatusOK, gin.H{"question_id": questionIDs}) +} diff --git a/l1nk4i/api/question/search.go b/l1nk4i/api/question/search.go new file mode 100644 index 0000000..0af64bf --- /dev/null +++ b/l1nk4i/api/question/search.go @@ -0,0 +1,18 @@ +package question + +import ( + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Search(c *gin.Context) { + searchContent := c.Query("content") + + questions, err := db.SearchQuestions(searchContent) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content"}) + } + + c.JSON(http.StatusOK, gin.H{"data": questions}) +} diff --git a/l1nk4i/api/question/update.go b/l1nk4i/api/question/update.go new file mode 100644 index 0000000..5bc6d71 --- /dev/null +++ b/l1nk4i/api/question/update.go @@ -0,0 +1,50 @@ +package question + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "net/http" +) + +func Update(c *gin.Context) { + questionID := c.Param("question-id") + + var questionInfo struct { + Title string `json:"title"` + Content string `json:"content"` + } + + if err := c.ShouldBind(&questionInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + // Verify user identity + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + + question, err := db.GetQuestionByQuestionID(questionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid question_id"}) + return + } + + if question.UserID != userid { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + + // Update question + err = db.UpdateQuestion(questionID, questionInfo.Title, questionInfo.Content) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "update question error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "update question successful!"}) +} diff --git a/l1nk4i/api/user/login.go b/l1nk4i/api/user/login.go new file mode 100644 index 0000000..cbe3bff --- /dev/null +++ b/l1nk4i/api/user/login.go @@ -0,0 +1,51 @@ +package user + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "l1nk4i/db" + "l1nk4i/utils" + "net/http" +) + +func Login(c *gin.Context) { + var loginInfo struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := c.ShouldBind(&loginInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + if !validateUsername(loginInfo.Username) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Username"}) + return + } + + if !validatePassword(loginInfo.Password) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Password"}) + return + } + + if user, err := db.GetUserByUsername(loginInfo.Username); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid Username"}) + return + } else { + if utils.CheckPasswordHash(loginInfo.Password, user.Password) { + session := sessions.Default(c) + session.Clear() + session.Set("user_id", user.UserID) + session.Set("role", user.Role) + session.Save() + + c.JSON(http.StatusOK, gin.H{"msg": "login successful!"}) + return + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid Password"}) + return + } + } + +} diff --git a/l1nk4i/api/user/logout.go b/l1nk4i/api/user/logout.go new file mode 100644 index 0000000..058eabf --- /dev/null +++ b/l1nk4i/api/user/logout.go @@ -0,0 +1,16 @@ +package user + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "net/http" +) + +func Logout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + session.Set("role", "guest") + session.Save() + c.JSON(http.StatusOK, gin.H{"msg": "Logout successful!"}) + return +} diff --git a/l1nk4i/api/user/register.go b/l1nk4i/api/user/register.go new file mode 100644 index 0000000..8b184ef --- /dev/null +++ b/l1nk4i/api/user/register.go @@ -0,0 +1,50 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "l1nk4i/db" + "l1nk4i/utils" + "net/http" +) + +func Register(c *gin.Context) { + var registerInfo struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := c.ShouldBindJSON(®isterInfo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + if !validateUsername(registerInfo.Username) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Username"}) + return + } + + if !validatePassword(registerInfo.Password) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Password"}) + return + } + + exists, _ := db.GetUserByUsername(registerInfo.Username) + if exists != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "username exists"}) + return + } + + user := db.User{ + UserID: uuid.New().String(), + Username: registerInfo.Username, + Password: utils.HashPassword(registerInfo.Password), + Role: "user", + } + if err := db.CreateUser(&user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Create user error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "register successful!"}) +} diff --git a/l1nk4i/api/user/userinfo.go b/l1nk4i/api/user/userinfo.go new file mode 100644 index 0000000..a277afa --- /dev/null +++ b/l1nk4i/api/user/userinfo.go @@ -0,0 +1,36 @@ +package user + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "l1nk4i/db" + "net/http" +) + +// UserInfo get Username by session +func UserInfo(c *gin.Context) { + session := sessions.Default(c) + userid, exists := session.Get("user_id").(string) + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + if _, err := uuid.Parse(userid); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session"}) + return + } + + user, err := db.GetUserByUUID(userid) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user_id": user.UserID, + "username": user.Username, + "role": user.Role, + }) +} diff --git a/l1nk4i/api/user/utils.go b/l1nk4i/api/user/utils.go new file mode 100644 index 0000000..5c7fb0a --- /dev/null +++ b/l1nk4i/api/user/utils.go @@ -0,0 +1,25 @@ +package user + +import "regexp" + +// 2 <= length <= 20 +// ^[a-zA-Z0-9]+$ +func validateUsername(username string) bool { + if len(username) < 2 || len(username) > 20 { + return false + } + + re := regexp.MustCompile(`^[a-zA-Z0-9]+$`) + return re.MatchString(username) +} + +// 8 <= length <= 30 +// ^[a-zA-Z0-9\W_]+$ +func validatePassword(password string) bool { + if len(password) < 8 || len(password) > 30 { + return false + } + + re := regexp.MustCompile(`^[a-zA-Z0-9\W_]+$`) + return re.MatchString(password) +} diff --git a/l1nk4i/config/config.go b/l1nk4i/config/config.go new file mode 100644 index 0000000..295c611 --- /dev/null +++ b/l1nk4i/config/config.go @@ -0,0 +1,24 @@ +package config + +import "github.com/pelletier/go-toml" + +var Mysql struct { + Username string `toml:"username"` + Password string `toml:"password"` + Host string `toml:"host"` + Port string `toml:"port"` + Dbname string `toml:"dbname"` +} + +var configFile = "config.toml" + +func init() { + conf, err := toml.LoadFile(configFile) + if err != nil { + panic("load config file failed: " + err.Error()) + } + + if err := conf.Get("mysql").(*toml.Tree).Unmarshal(&Mysql); err != nil { + panic("unmarshal config file failed: " + err.Error()) + } +} diff --git a/l1nk4i/db/answer.go b/l1nk4i/db/answer.go new file mode 100644 index 0000000..137f428 --- /dev/null +++ b/l1nk4i/db/answer.go @@ -0,0 +1,50 @@ +package db + +import "log" + +func CreateAnswer(answer *Answer) error { + err := db.Create(answer).Error + if err != nil { + log.Printf("[ERROR] Create answer error:%s\n", err.Error()) + return err + } + return nil +} + +func GetAnswerByAnswerID(answerID string) (*Answer, error) { + var answer Answer + err := db.Where("answer_id = ?", answerID).First(&answer).Error + if err != nil { + log.Printf("[ERROR] Get answer by answerID error:%s\n", err.Error()) + return nil, err + } + return &answer, nil +} + +func GetAnswersByQuestionID(questionID string) (*[]Answer, error) { + var answers []Answer + err := db.Where("question_id = ?", questionID).Limit(100).Find(&answers).Error + if err != nil { + log.Printf("[ERROR] Get answer by questionID error:%s\n", err.Error()) + return nil, err + } + return &answers, nil +} + +func DeleteAnswer(answerID string) error { + err := db.Unscoped().Where("answer_id = ?", answerID).Delete(&Answer{}).Error + if err != nil { + log.Printf("[ERROR] Delete answer error:%s\n", err.Error()) + return err + } + return nil +} + +func UpdateAnswer(answerID, content string) error { + err := db.Model(&Answer{}).Where("answer_id = ?", answerID).Update("content", content).Error + if err != nil { + log.Printf("[ERROR] Update answer error:%s\n", err.Error()) + return err + } + return nil +} diff --git a/l1nk4i/db/mysql.go b/l1nk4i/db/mysql.go new file mode 100644 index 0000000..e449827 --- /dev/null +++ b/l1nk4i/db/mysql.go @@ -0,0 +1,63 @@ +package db + +import ( + "fmt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "l1nk4i/config" + "log" +) + +type User struct { + gorm.Model + UserID string `gorm:"not null;column:user_id;unique;type:varchar(36)"` + + Username string `gorm:"not null;column:username;unique"` + Password string `gorm:"not null;column:password"` + Role string `gorm:"not null;column:role"` +} + +type Question struct { + gorm.Model + QuestionID string `gorm:"not null;column:question_id;unique;type:varchar(36)"` + UserID string `gorm:"not null;column:user_id;type:varchar(36)"` + + BestAnswerID string `gorm:"column:best_answer_id;type:varchar(36)"` + //IsAccessible bool `gorm:"not null;column:is_accessible;type:bool;default:false"` + + Title string `gorm:"not null;column:title"` + Content string `gorm:"not null;column:content"` +} + +type Answer struct { + gorm.Model + AnswerID string `gorm:"not null;column:answer_id;unique;type:varchar(36)"` + UserID string `gorm:"not null;column:user_id;type:varchar(36)"` + QuestionID string `gorm:"not null;column:question_id;"` + + Content string `gorm:"not null;column:content"` +} + +var db *gorm.DB + +func init() { + username := config.Mysql.Username + password := config.Mysql.Password + host := config.Mysql.Host + port := config.Mysql.Port + dbname := config.Mysql.Dbname + params := "charset=utf8mb4&parseTime=True&loc=Local" + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s", username, password, host, port, dbname, params) + conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + panic("Cannot connect to mysql: " + err.Error()) + } + log.Printf("[INFO] Connect to mysql successfully\n") + + if err = conn.AutoMigrate(&User{}, &Question{}, &Answer{}); err != nil { + panic("AutoMigrate failed: " + err.Error()) + } + + db = conn +} diff --git a/l1nk4i/db/question.go b/l1nk4i/db/question.go new file mode 100644 index 0000000..5ab2dfc --- /dev/null +++ b/l1nk4i/db/question.go @@ -0,0 +1,77 @@ +package db + +import "log" + +func CreateQuestion(question *Question) error { + err := db.Create(question).Error + if err != nil { + log.Printf("[ERROR] Create question failed %s\n", err.Error()) + return err + } + return nil +} + +func GetQuestionByQuestionID(questionID string) (*Question, error) { + var question Question + err := db.Where("question_id = ?", questionID).First(&question).Error + if err != nil { + log.Printf("[ERROR] Get question by question_id failed %s\n", err.Error()) + return nil, err + } + return &question, nil +} + +func GetQuestionByUserID(userID string) (*[]Question, error) { + var questions []Question + err := db.Where("user_id = ?", userID).Limit(100).Find(&questions).Error + if err != nil { + log.Printf("[ERROR] Get Questions by user_id failed %s\n", err.Error()) + return nil, err + } + return &questions, nil +} + +func DeleteQuestion(questionID string) error { + err := db.Unscoped().Where("question_id = ?", questionID).Delete(&Question{}).Error + if err != nil { + log.Printf("[ERROR] Delete question failed %s\n", err.Error()) + return err + } + + // Delete answers + err = db.Unscoped().Where("question_id = ?", questionID).Delete(&Answer{}).Error + if err != nil { + log.Printf("[ERROR] Delete answers error:%s\n", err.Error()) + return err + } + return nil +} + +func UpdateQuestion(questionID, title, content string) error { + err := db.Model(&Question{}).Where("question_id = ?", questionID).Updates(Question{Title: title, Content: content}).Error + if err != nil { + log.Printf("[ERROR] Update question failed %s\n", err.Error()) + return err + } + return nil +} + +func SearchQuestions(content string) (*[]Question, error) { + var questions []Question + searchPattern := "%" + content + "%" + err := db.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern).Limit(20).Find(&questions).Error + if err != nil { + log.Printf("[ERROR] Search questions failed %s\n", err.Error()) + return nil, err + } + return &questions, nil +} + +func UpdateBestAnswer(questionID, answerID string) error { + err := db.Model(&Question{}).Where("question_id = ?", questionID).Updates(Question{BestAnswerID: answerID}).Error + if err != nil { + log.Printf("[ERROR] Update question benst answer failed %s\n", err.Error()) + return err + } + return nil +} diff --git a/l1nk4i/db/user.go b/l1nk4i/db/user.go new file mode 100644 index 0000000..ddc4ed5 --- /dev/null +++ b/l1nk4i/db/user.go @@ -0,0 +1,32 @@ +package db + +import "log" + +func CreateUser(user *User) error { + err := db.Create(user).Error + if err != nil { + log.Printf("[ERROR] Create user failed: %s\n", err.Error()) + return err + } + return nil +} + +func GetUserByUsername(username string) (*User, error) { + var user User + err := db.Where("username = ?", username).First(&user).Error + if err != nil { + log.Printf("[ERROR] Get user failed: %s\n", err.Error()) + return nil, err + } + return &user, nil +} + +func GetUserByUUID(uuid string) (*User, error) { + var user User + err := db.Where("user_id = ?", uuid).First(&user).Error + if err != nil { + log.Printf("[ERROR] Get user failed: %s\n", err.Error()) + return nil, err + } + return &user, nil +} diff --git a/l1nk4i/docker-compose.yml b/l1nk4i/docker-compose.yml new file mode 100644 index 0000000..f6acf33 --- /dev/null +++ b/l1nk4i/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + db: + image: mysql:latest + environment: + MYSQL_RANDOM_ROOT_PASSWORD: true + volumes: + - /home/l1nk/mysql_data:/var/lib/mysql + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + expose: + - "3306" + - "33060" + ports: + - "3306:3306" # debug + + + app: + build: . + ports: + - "8080:8080" + depends_on: + - db diff --git a/l1nk4i/docs/apiDoc.md b/l1nk4i/docs/apiDoc.md new file mode 100644 index 0000000..6a849ca --- /dev/null +++ b/l1nk4i/docs/apiDoc.md @@ -0,0 +1,890 @@ +--- +title: hduhelp_backend_task +language_tabs: + - shell: Shell + - http: HTTP + - javascript: JavaScript + - ruby: Ruby + - python: Python + - php: PHP + - java: Java + - go: Go +toc_footers: [] +includes: [] +search: true +code_clipboard: true +highlight_theme: darkula +headingLevel: 2 +generator: "@tarslib/widdershins v4.0.23" + +--- + +# hduhelp_backend_task + +Base URLs: + +# Authentication + +# users + +## POST 用户注册 + +POST /api/users/register + +注册 + +> Body 请求参数 + +```json +{ + "username": "l1nkQAQ", + "password": "vidarteam" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|object| 否 |none| +|» username|body|string| 是 |none| +|» password|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|none|Inline| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +状态码 **400** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» error|string|true|none||none| + +状态码 **500** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» error|string|true|none||none| + +## GET 获取当前用户信息 + +GET /api/users/userinfo + +查询当前用户信息 + +> 返回示例 + +> 200 Response + +```json +{ + "role": "string", + "user_id": "string", + "username": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» role|string|true|none||none| +|» user_id|string|true|none||none| +|» username|string|true|none||none| + +状态码 **400** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» error|string|true|none||none| + +## POST 用户登录 + +POST /api/users/login + +登录 + +> Body 请求参数 + +```json +{ + "username": "l1nkQAQ", + "password": "vidarteam" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|object| 否 |none| +|» username|body|string| 是 |none| +|» password|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## POST 用户登出 + +POST /api/users/logout + +登出 + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +# questions + +## POST 创建问题 + +POST /api/questions + +创建问题 + +> Body 请求参数 + +```json +{ + "title": "man", + "content": "what can i say" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|object| 否 |none| +|» title|body|string| 是 |none| +|» content|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## GET 列出用户发表的所有问题 + +GET /api/questions + +列出用户发表的所有问题 + +> 返回示例 + +> 200 Response + +```json +{ + "question_id": [ + "string" + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» question_id|[string]|true|none||none| + +## POST 创建指定问题的回答 + +POST /api/questions/{question-id}/answers + +发送回答 + +> Body 请求参数 + +```json +{ + "content": "manman" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| +|body|body|object| 否 |none| +|» question_id|body|string| 是 |none| +|» content|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "answer_id": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» answer_id|string|true|none||none| + +## GET 获取指定问题的所有回答 + +GET /api/questions/{question-id}/answers + +获得问题对应的所有回答 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "data": [ + { + "ID": 0, + "CreatedAt": "string", + "UpdatedAt": "string", + "DeletedAt": null, + "AnswerID": "string", + "UserID": "string", + "QuestionID": "string", + "Content": "string" + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» data|[object]|true|none||none| +|»» ID|integer|true|none||none| +|»» CreatedAt|string|true|none||none| +|»» UpdatedAt|string|true|none||none| +|»» DeletedAt|null|true|none||none| +|»» AnswerID|string|true|none||none| +|»» UserID|string|true|none||none| +|»» QuestionID|string|true|none||none| +|»» Content|string|true|none||none| + +## DELETE 删除指定问题 + +DELETE /api/questions/{question-id} + +删除问题 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## PUT 更新指定问题 + +PUT /api/questions/{question-id} + +更改问题的标题或者内容 + +> Body 请求参数 + +```json +{ + "title": "manba", + "content": "out" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| +|body|body|object| 否 |none| +|» question_id|body|string| 是 |none| +|» title|body|string| 是 |none| +|» content|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## GET 获取指定问题 + +GET /api/questions/{question-id} + +通过question_id查找对应问题对象 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "data": { + "ID": 0, + "CreatedAt": "string", + "UpdatedAt": "string", + "DeletedAt": null, + "QuestionID": "string", + "UserID": "string", + "BestAnswerID": "string", + "Title": "string", + "Content": "string" + } +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» data|object|true|none||none| +|»» ID|integer|true|none||none| +|»» CreatedAt|string|true|none||none| +|»» UpdatedAt|string|true|none||none| +|»» DeletedAt|null|true|none||none| +|»» QuestionID|string|true|none||none| +|»» UserID|string|true|none||none| +|»» BestAnswerID|string|true|none||none| +|»» Title|string|true|none||none| +|»» Content|string|true|none||none| + +## GET 搜索问题 + +GET /api/questions/search + +通过输入匹配问题标题和内容,搜索问题(无鉴权) + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|content|query|string| 否 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "data": [ + { + "ID": 0, + "CreatedAt": "string", + "UpdatedAt": "string", + "DeletedAt": null, + "QuestionID": "string", + "UserID": "string", + "BestAnswerID": "string", + "Title": "string", + "Content": "string" + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» data|[object]|true|none||none| +|»» ID|integer|true|none||none| +|»» CreatedAt|string|true|none||none| +|»» UpdatedAt|string|true|none||none| +|»» DeletedAt|null|true|none||none| +|»» QuestionID|string|true|none||none| +|»» UserID|string|true|none||none| +|»» BestAnswerID|string|true|none||none| +|»» Title|string|true|none||none| +|»» Content|string|true|none||none| + +## PUT 更新最佳答案 + +PUT /api/questions/{question-id}/best/{answer-id} + +更新最佳答案 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| +|answer-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +# answers + +## DELETE 删除指定回答 + +DELETE /api/answers/{answer-id} + +删除回答 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|answer-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## PUT 更新指定回答 + +PUT /api/answers/{answer-id} + +更改回答内容 + +> Body 请求参数 + +```json +{ + "content": "manba out" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|answer-id|path|string| 是 |none| +|body|body|object| 否 |none| +|» answer_id|body|string| 是 |none| +|» content|body|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +# admin + +## DELETE 删除问题 + +DELETE /api/admin/questions/{question-id} + +删除问题 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|question-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +## DELETE 删除答案 + +DELETE /api/admin/answers/{answer-id} + +删除答案 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|answer-id|path|string| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "msg": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|» msg|string|true|none||none| + +# 数据模型 + +