init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
12
webapp-back/articles/doc.go
Normal file
12
webapp-back/articles/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
The article module containing the article CRUD operation and relationship CRUD.
|
||||
|
||||
model.go: definition of orm based data model
|
||||
|
||||
routers.go: router binding and core logic
|
||||
|
||||
serializers.go: definition the schema of return data
|
||||
|
||||
validators.go: definition the validator of form data
|
||||
*/
|
||||
package articles
|
||||
368
webapp-back/articles/models.go
Normal file
368
webapp-back/articles/models.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArticleModel struct {
|
||||
gorm.Model
|
||||
Slug string `gorm:"uniqueIndex"`
|
||||
Title string
|
||||
Description string `gorm:"size:2048"`
|
||||
Body string `gorm:"size:2048"`
|
||||
Author ArticleUserModel
|
||||
AuthorID uint
|
||||
Tags []TagModel `gorm:"many2many:article_tags;"`
|
||||
Comments []CommentModel `gorm:"ForeignKey:ArticleID"`
|
||||
}
|
||||
|
||||
type ArticleUserModel struct {
|
||||
gorm.Model
|
||||
UserModel users.UserModel
|
||||
UserModelID uint
|
||||
ArticleModels []ArticleModel `gorm:"ForeignKey:AuthorID"`
|
||||
FavoriteModels []FavoriteModel `gorm:"ForeignKey:FavoriteByID"`
|
||||
}
|
||||
|
||||
type FavoriteModel struct {
|
||||
gorm.Model
|
||||
Favorite ArticleModel
|
||||
FavoriteID uint
|
||||
FavoriteBy ArticleUserModel
|
||||
FavoriteByID uint
|
||||
}
|
||||
|
||||
type TagModel struct {
|
||||
gorm.Model
|
||||
Tag string `gorm:"uniqueIndex"`
|
||||
ArticleModels []ArticleModel `gorm:"many2many:article_tags;"`
|
||||
}
|
||||
|
||||
type CommentModel struct {
|
||||
gorm.Model
|
||||
Article ArticleModel
|
||||
ArticleID uint
|
||||
Author ArticleUserModel
|
||||
AuthorID uint
|
||||
Body string `gorm:"size:2048"`
|
||||
}
|
||||
|
||||
func GetArticleUserModel(userModel users.UserModel) ArticleUserModel {
|
||||
var articleUserModel ArticleUserModel
|
||||
if userModel.ID == 0 {
|
||||
return articleUserModel
|
||||
}
|
||||
db := common.GetDB()
|
||||
db.Where(&ArticleUserModel{
|
||||
UserModelID: userModel.ID,
|
||||
}).FirstOrCreate(&articleUserModel)
|
||||
articleUserModel.UserModel = userModel
|
||||
return articleUserModel
|
||||
}
|
||||
|
||||
func (article ArticleModel) favoritesCount() uint {
|
||||
db := common.GetDB()
|
||||
var count int64
|
||||
db.Model(&FavoriteModel{}).Where(FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
}).Count(&count)
|
||||
return uint(count)
|
||||
}
|
||||
|
||||
func (article ArticleModel) isFavoriteBy(user ArticleUserModel) bool {
|
||||
db := common.GetDB()
|
||||
var favorite FavoriteModel
|
||||
db.Where(FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
FavoriteByID: user.ID,
|
||||
}).First(&favorite)
|
||||
return favorite.ID != 0
|
||||
}
|
||||
|
||||
// BatchGetFavoriteCounts returns a map of article ID to favorite count
|
||||
func BatchGetFavoriteCounts(articleIDs []uint) map[uint]uint {
|
||||
if len(articleIDs) == 0 {
|
||||
return make(map[uint]uint)
|
||||
}
|
||||
db := common.GetDB()
|
||||
|
||||
type result struct {
|
||||
FavoriteID uint
|
||||
Count uint
|
||||
}
|
||||
var results []result
|
||||
db.Model(&FavoriteModel{}).
|
||||
Select("favorite_id, COUNT(*) as count").
|
||||
Where("favorite_id IN ?", articleIDs).
|
||||
Group("favorite_id").
|
||||
Find(&results)
|
||||
|
||||
countMap := make(map[uint]uint)
|
||||
for _, r := range results {
|
||||
countMap[r.FavoriteID] = r.Count
|
||||
}
|
||||
return countMap
|
||||
}
|
||||
|
||||
// BatchGetFavoriteStatus returns a map of article ID to whether the user favorited it
|
||||
func BatchGetFavoriteStatus(articleIDs []uint, userID uint) map[uint]bool {
|
||||
if len(articleIDs) == 0 || userID == 0 {
|
||||
return make(map[uint]bool)
|
||||
}
|
||||
db := common.GetDB()
|
||||
|
||||
var favorites []FavoriteModel
|
||||
db.Where("favorite_id IN ? AND favorite_by_id = ?", articleIDs, userID).Find(&favorites)
|
||||
|
||||
statusMap := make(map[uint]bool)
|
||||
for _, f := range favorites {
|
||||
statusMap[f.FavoriteID] = true
|
||||
}
|
||||
return statusMap
|
||||
}
|
||||
|
||||
func (article ArticleModel) favoriteBy(user ArticleUserModel) error {
|
||||
db := common.GetDB()
|
||||
var favorite FavoriteModel
|
||||
err := db.FirstOrCreate(&favorite, &FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
FavoriteByID: user.ID,
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (article ArticleModel) unFavoriteBy(user ArticleUserModel) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where("favorite_id = ? AND favorite_by_id = ?", article.ID, user.ID).Delete(&FavoriteModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func SaveOne(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Save(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func FindOneArticle(condition interface{}) (ArticleModel, error) {
|
||||
db := common.GetDB()
|
||||
var model ArticleModel
|
||||
err := db.Preload("Author.UserModel").Preload("Tags").Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
func FindOneComment(condition *CommentModel) (CommentModel, error) {
|
||||
db := common.GetDB()
|
||||
var model CommentModel
|
||||
err := db.Preload("Author.UserModel").Preload("Article").Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
func (self *ArticleModel) getComments() error {
|
||||
db := common.GetDB()
|
||||
err := db.Preload("Author.UserModel").Model(self).Association("Comments").Find(&self.Comments)
|
||||
return err
|
||||
}
|
||||
|
||||
func getAllTags() ([]TagModel, error) {
|
||||
db := common.GetDB()
|
||||
var models []TagModel
|
||||
err := db.Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
func FindManyArticle(tag, author, limit, offset, favorited string) ([]ArticleModel, int, error) {
|
||||
db := common.GetDB()
|
||||
var models []ArticleModel
|
||||
var count int
|
||||
|
||||
offset_int, errOffset := strconv.Atoi(offset)
|
||||
if errOffset != nil {
|
||||
offset_int = 0
|
||||
}
|
||||
|
||||
limit_int, errLimit := strconv.Atoi(limit)
|
||||
if errLimit != nil {
|
||||
limit_int = 20
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if tag != "" {
|
||||
var tagModel TagModel
|
||||
tx.Where(TagModel{Tag: tag}).First(&tagModel)
|
||||
if tagModel.ID != 0 {
|
||||
// Get article IDs via association
|
||||
var tempModels []ArticleModel
|
||||
if err := tx.Model(&tagModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
|
||||
tx.Rollback()
|
||||
return models, count, err
|
||||
}
|
||||
count = int(tx.Model(&tagModel).Association("ArticleModels").Count())
|
||||
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
|
||||
if len(tempModels) > 0 {
|
||||
var ids []uint
|
||||
for _, m := range tempModels {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else if author != "" {
|
||||
var userModel users.UserModel
|
||||
tx.Where(users.UserModel{Username: author}).First(&userModel)
|
||||
articleUserModel := GetArticleUserModel(userModel)
|
||||
|
||||
if articleUserModel.ID != 0 {
|
||||
count = int(tx.Model(&articleUserModel).Association("ArticleModels").Count())
|
||||
// Get article IDs via association
|
||||
var tempModels []ArticleModel
|
||||
if err := tx.Model(&articleUserModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
|
||||
tx.Rollback()
|
||||
return models, count, err
|
||||
}
|
||||
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
|
||||
if len(tempModels) > 0 {
|
||||
var ids []uint
|
||||
for _, m := range tempModels {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else if favorited != "" {
|
||||
var userModel users.UserModel
|
||||
tx.Where(users.UserModel{Username: favorited}).First(&userModel)
|
||||
articleUserModel := GetArticleUserModel(userModel)
|
||||
if articleUserModel.ID != 0 {
|
||||
var favoriteModels []FavoriteModel
|
||||
tx.Where(FavoriteModel{
|
||||
FavoriteByID: articleUserModel.ID,
|
||||
}).Offset(offset_int).Limit(limit_int).Find(&favoriteModels)
|
||||
|
||||
count = int(tx.Model(&articleUserModel).Association("FavoriteModels").Count())
|
||||
// Batch fetch articles to avoid N+1 query
|
||||
if len(favoriteModels) > 0 {
|
||||
var ids []uint
|
||||
for _, favorite := range favoriteModels {
|
||||
ids = append(ids, favorite.FavoriteID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var count64 int64
|
||||
tx.Model(&ArticleModel{}).Count(&count64)
|
||||
count = int(count64)
|
||||
tx.Offset(offset_int).Limit(limit_int).Preload("Author.UserModel").Preload("Tags").Find(&models)
|
||||
}
|
||||
|
||||
err := tx.Commit().Error
|
||||
return models, count, err
|
||||
}
|
||||
|
||||
func (self *ArticleUserModel) GetArticleFeed(limit, offset string) ([]ArticleModel, int, error) {
|
||||
db := common.GetDB()
|
||||
models := make([]ArticleModel, 0)
|
||||
var count int
|
||||
|
||||
offset_int, errOffset := strconv.Atoi(offset)
|
||||
if errOffset != nil {
|
||||
offset_int = 0
|
||||
}
|
||||
limit_int, errLimit := strconv.Atoi(limit)
|
||||
if errLimit != nil {
|
||||
limit_int = 20
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
followings := self.UserModel.GetFollowings()
|
||||
|
||||
// Batch get ArticleUserModel IDs to avoid N+1 query
|
||||
if len(followings) > 0 {
|
||||
var followingUserIDs []uint
|
||||
for _, following := range followings {
|
||||
followingUserIDs = append(followingUserIDs, following.ID)
|
||||
}
|
||||
|
||||
var articleUserModels []ArticleUserModel
|
||||
tx.Where("user_model_id IN ?", followingUserIDs).Find(&articleUserModels)
|
||||
|
||||
var authorIDs []uint
|
||||
for _, aum := range articleUserModels {
|
||||
authorIDs = append(authorIDs, aum.ID)
|
||||
}
|
||||
|
||||
if len(authorIDs) > 0 {
|
||||
var count64 int64
|
||||
tx.Model(&ArticleModel{}).Where("author_id IN ?", authorIDs).Count(&count64)
|
||||
count = int(count64)
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("author_id IN ?", authorIDs).Order("updated_at desc").Offset(offset_int).Limit(limit_int).Find(&models)
|
||||
}
|
||||
}
|
||||
|
||||
err := tx.Commit().Error
|
||||
return models, count, err
|
||||
}
|
||||
|
||||
func (model *ArticleModel) setTags(tags []string) error {
|
||||
if len(tags) == 0 {
|
||||
model.Tags = []TagModel{}
|
||||
return nil
|
||||
}
|
||||
|
||||
db := common.GetDB()
|
||||
|
||||
// Batch fetch existing tags
|
||||
var existingTags []TagModel
|
||||
db.Where("tag IN ?", tags).Find(&existingTags)
|
||||
|
||||
// Create a map for quick lookup
|
||||
existingTagMap := make(map[string]TagModel)
|
||||
for _, t := range existingTags {
|
||||
existingTagMap[t.Tag] = t
|
||||
}
|
||||
|
||||
// Create missing tags and build final list
|
||||
var tagList []TagModel
|
||||
for _, tag := range tags {
|
||||
if existing, ok := existingTagMap[tag]; ok {
|
||||
tagList = append(tagList, existing)
|
||||
} else {
|
||||
// Create new tag with race condition handling
|
||||
newTag := TagModel{Tag: tag}
|
||||
if err := db.Create(&newTag).Error; err != nil {
|
||||
// If creation failed (e.g., concurrent insert), try to fetch existing
|
||||
var existing TagModel
|
||||
if err2 := db.Where("tag = ?", tag).First(&existing).Error; err2 == nil {
|
||||
tagList = append(tagList, existing)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
tagList = append(tagList, newTag)
|
||||
}
|
||||
}
|
||||
model.Tags = tagList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (model *ArticleModel) Update(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Model(model).Updates(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteArticleModel(condition interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where(condition).Delete(&ArticleModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteCommentModel(condition interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where(condition).Delete(&CommentModel{}).Error
|
||||
return err
|
||||
}
|
||||
251
webapp-back/articles/routers.go
Normal file
251
webapp-back/articles/routers.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func ArticlesRegister(router *gin.RouterGroup) {
|
||||
router.GET("/feed", ArticleFeed)
|
||||
router.POST("", ArticleCreate)
|
||||
router.POST("/", ArticleCreate)
|
||||
router.PUT("/:slug", ArticleUpdate)
|
||||
router.PUT("/:slug/", ArticleUpdate)
|
||||
router.DELETE("/:slug", ArticleDelete)
|
||||
router.POST("/:slug/favorite", ArticleFavorite)
|
||||
router.DELETE("/:slug/favorite", ArticleUnfavorite)
|
||||
router.POST("/:slug/comments", ArticleCommentCreate)
|
||||
router.DELETE("/:slug/comments/:id", ArticleCommentDelete)
|
||||
}
|
||||
|
||||
func ArticlesAnonymousRegister(router *gin.RouterGroup) {
|
||||
router.GET("", ArticleList)
|
||||
router.GET("/", ArticleList)
|
||||
router.GET("/:slug", ArticleRetrieve)
|
||||
router.GET("/:slug/comments", ArticleCommentList)
|
||||
}
|
||||
|
||||
func TagsAnonymousRegister(router *gin.RouterGroup) {
|
||||
router.GET("", TagList)
|
||||
router.GET("/", TagList)
|
||||
}
|
||||
|
||||
func ArticleCreate(c *gin.Context) {
|
||||
articleModelValidator := NewArticleModelValidator()
|
||||
if err := articleModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
//fmt.Println(articleModelValidator.articleModel.Author.UserModel)
|
||||
|
||||
if err := SaveOne(&articleModelValidator.articleModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModelValidator.articleModel}
|
||||
c.JSON(http.StatusCreated, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleList(c *gin.Context) {
|
||||
//condition := ArticleModel{}
|
||||
tag := c.Query("tag")
|
||||
author := c.Query("author")
|
||||
favorited := c.Query("favorited")
|
||||
limit := c.Query("limit")
|
||||
offset := c.Query("offset")
|
||||
articleModels, modelCount, err := FindManyArticle(tag, author, limit, offset, favorited)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := ArticlesSerializer{c, articleModels}
|
||||
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
|
||||
}
|
||||
|
||||
func ArticleFeed(c *gin.Context) {
|
||||
limit := c.Query("limit")
|
||||
offset := c.Query("offset")
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if myUserModel.ID == 0 {
|
||||
c.AbortWithError(http.StatusUnauthorized, errors.New("{error : \"Require auth!\"}"))
|
||||
return
|
||||
}
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
articleModels, modelCount, err := articleUserModel.GetArticleFeed(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := ArticlesSerializer{c, articleModels}
|
||||
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
|
||||
}
|
||||
|
||||
func ArticleRetrieve(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleUpdate(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
// Check if current user is the author
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if articleModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
|
||||
articleModelValidator := NewArticleModelValidatorFillWith(articleModel)
|
||||
if err := articleModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
articleModelValidator.articleModel.ID = articleModel.ID
|
||||
if err := articleModel.Update(articleModelValidator.articleModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleDelete(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err == nil {
|
||||
// Article exists, check authorization
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if articleModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete regardless of existence (idempotent)
|
||||
if err := DeleteArticleModel(&ArticleModel{Slug: slug}); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"article": "delete success"})
|
||||
}
|
||||
|
||||
func ArticleFavorite(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if err = articleModel.favoriteBy(GetArticleUserModel(myUserModel)); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleUnfavorite(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if err = articleModel.unFavoriteBy(GetArticleUserModel(myUserModel)); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleCommentCreate(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
commentModelValidator := NewCommentModelValidator()
|
||||
if err := commentModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
commentModelValidator.commentModel.Article = articleModel
|
||||
|
||||
if err := SaveOne(&commentModelValidator.commentModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := CommentSerializer{c, commentModelValidator.commentModel}
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleCommentDelete(c *gin.Context) {
|
||||
id64, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid id")))
|
||||
return
|
||||
}
|
||||
id := uint(id64)
|
||||
commentModel, err := FindOneComment(&CommentModel{Model: gorm.Model{ID: id}})
|
||||
if err == nil {
|
||||
// Comment exists, check authorization
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if commentModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("comment", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete regardless of existence (idempotent)
|
||||
if err := DeleteCommentModel([]uint{id}); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comment": "delete success"})
|
||||
}
|
||||
|
||||
func ArticleCommentList(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
err = articleModel.getComments()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Database error")))
|
||||
return
|
||||
}
|
||||
serializer := CommentsSerializer{c, articleModel.Comments}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": serializer.Response()})
|
||||
}
|
||||
func TagList(c *gin.Context) {
|
||||
tagModels, err := getAllTags()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := TagsSerializer{c, tagModels}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": serializer.Response()})
|
||||
}
|
||||
180
webapp-back/articles/serializers.go
Normal file
180
webapp-back/articles/serializers.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
)
|
||||
|
||||
type TagSerializer struct {
|
||||
C *gin.Context
|
||||
TagModel
|
||||
}
|
||||
|
||||
type TagsSerializer struct {
|
||||
C *gin.Context
|
||||
Tags []TagModel
|
||||
}
|
||||
|
||||
func (s *TagSerializer) Response() string {
|
||||
return s.TagModel.Tag
|
||||
}
|
||||
|
||||
func (s *TagsSerializer) Response() []string {
|
||||
response := []string{}
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response = append(response, serializer.Response())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
type ArticleUserSerializer struct {
|
||||
C *gin.Context
|
||||
ArticleUserModel
|
||||
}
|
||||
|
||||
func (s *ArticleUserSerializer) Response() users.ProfileResponse {
|
||||
response := users.ProfileSerializer{C: s.C, UserModel: s.ArticleUserModel.UserModel}
|
||||
return response.Response()
|
||||
}
|
||||
|
||||
type ArticleSerializer struct {
|
||||
C *gin.Context
|
||||
ArticleModel
|
||||
}
|
||||
|
||||
type ArticleResponse struct {
|
||||
ID uint `json:"-"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Author users.ProfileResponse `json:"author"`
|
||||
Tags []string `json:"tagList"`
|
||||
Favorite bool `json:"favorited"`
|
||||
FavoritesCount uint `json:"favoritesCount"`
|
||||
}
|
||||
|
||||
type ArticlesSerializer struct {
|
||||
C *gin.Context
|
||||
Articles []ArticleModel
|
||||
}
|
||||
|
||||
func (s *ArticleSerializer) Response() ArticleResponse {
|
||||
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := ArticleResponse{
|
||||
ID: s.ID,
|
||||
Slug: s.Slug,
|
||||
Title: s.Title,
|
||||
Description: s.Description,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
//UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
Favorite: s.isFavoriteBy(GetArticleUserModel(myUserModel)),
|
||||
FavoritesCount: s.favoritesCount(),
|
||||
}
|
||||
response.Tags = make([]string, 0)
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response.Tags = append(response.Tags, serializer.Response())
|
||||
}
|
||||
sort.Strings(response.Tags)
|
||||
return response
|
||||
}
|
||||
|
||||
// ResponseWithPreloaded creates response using preloaded favorite data to avoid N+1 queries
|
||||
func (s *ArticleSerializer) ResponseWithPreloaded(favorited bool, favoritesCount uint) ArticleResponse {
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := ArticleResponse{
|
||||
ID: s.ID,
|
||||
Slug: s.Slug,
|
||||
Title: s.Title,
|
||||
Description: s.Description,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
Favorite: favorited,
|
||||
FavoritesCount: favoritesCount,
|
||||
}
|
||||
response.Tags = make([]string, 0)
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response.Tags = append(response.Tags, serializer.Response())
|
||||
}
|
||||
sort.Strings(response.Tags)
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *ArticlesSerializer) Response() []ArticleResponse {
|
||||
response := []ArticleResponse{}
|
||||
if len(s.Articles) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
// Batch fetch favorite counts and status
|
||||
var articleIDs []uint
|
||||
for _, article := range s.Articles {
|
||||
articleIDs = append(articleIDs, article.ID)
|
||||
}
|
||||
|
||||
favoriteCounts := BatchGetFavoriteCounts(articleIDs)
|
||||
|
||||
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
favoriteStatus := BatchGetFavoriteStatus(articleIDs, articleUserModel.ID)
|
||||
|
||||
for _, article := range s.Articles {
|
||||
serializer := ArticleSerializer{C: s.C, ArticleModel: article}
|
||||
favorited := favoriteStatus[article.ID]
|
||||
count := favoriteCounts[article.ID]
|
||||
response = append(response, serializer.ResponseWithPreloaded(favorited, count))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
type CommentSerializer struct {
|
||||
C *gin.Context
|
||||
CommentModel
|
||||
}
|
||||
|
||||
type CommentsSerializer struct {
|
||||
C *gin.Context
|
||||
Comments []CommentModel
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Author users.ProfileResponse `json:"author"`
|
||||
}
|
||||
|
||||
func (s *CommentSerializer) Response() CommentResponse {
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := CommentResponse{
|
||||
ID: s.ID,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *CommentsSerializer) Response() []CommentResponse {
|
||||
response := []CommentResponse{}
|
||||
for _, comment := range s.Comments {
|
||||
serializer := CommentSerializer{C: s.C, CommentModel: comment}
|
||||
response = append(response, serializer.Response())
|
||||
}
|
||||
return response
|
||||
}
|
||||
1605
webapp-back/articles/unit_test.go
Normal file
1605
webapp-back/articles/unit_test.go
Normal file
File diff suppressed because it is too large
Load Diff
72
webapp-back/articles/validators.go
Normal file
72
webapp-back/articles/validators.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gosimple/slug"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
)
|
||||
|
||||
type ArticleModelValidator struct {
|
||||
Article struct {
|
||||
Title string `form:"title" json:"title" binding:"required,min=4"`
|
||||
Description string `form:"description" json:"description" binding:"required,max=2048"`
|
||||
Body string `form:"body" json:"body" binding:"required,max=2048"`
|
||||
Tags []string `form:"tagList" json:"tagList"`
|
||||
} `json:"article"`
|
||||
articleModel ArticleModel `json:"-"`
|
||||
}
|
||||
|
||||
func NewArticleModelValidator() ArticleModelValidator {
|
||||
return ArticleModelValidator{}
|
||||
}
|
||||
|
||||
func NewArticleModelValidatorFillWith(articleModel ArticleModel) ArticleModelValidator {
|
||||
articleModelValidator := NewArticleModelValidator()
|
||||
articleModelValidator.Article.Title = articleModel.Title
|
||||
articleModelValidator.Article.Description = articleModel.Description
|
||||
articleModelValidator.Article.Body = articleModel.Body
|
||||
for _, tagModel := range articleModel.Tags {
|
||||
articleModelValidator.Article.Tags = append(articleModelValidator.Article.Tags, tagModel.Tag)
|
||||
}
|
||||
return articleModelValidator
|
||||
}
|
||||
|
||||
func (s *ArticleModelValidator) Bind(c *gin.Context) error {
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
|
||||
err := common.Bind(c, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.articleModel.Slug = slug.Make(s.Article.Title)
|
||||
s.articleModel.Title = s.Article.Title
|
||||
s.articleModel.Description = s.Article.Description
|
||||
s.articleModel.Body = s.Article.Body
|
||||
s.articleModel.Author = GetArticleUserModel(myUserModel)
|
||||
s.articleModel.setTags(s.Article.Tags)
|
||||
return nil
|
||||
}
|
||||
|
||||
type CommentModelValidator struct {
|
||||
Comment struct {
|
||||
Body string `form:"body" json:"body" binding:"required,max=2048"`
|
||||
} `json:"comment"`
|
||||
commentModel CommentModel `json:"-"`
|
||||
}
|
||||
|
||||
func NewCommentModelValidator() CommentModelValidator {
|
||||
return CommentModelValidator{}
|
||||
}
|
||||
|
||||
func (s *CommentModelValidator) Bind(c *gin.Context) error {
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
|
||||
err := common.Bind(c, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.commentModel.Body = s.Comment.Body
|
||||
s.commentModel.Author = GetArticleUserModel(myUserModel)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user