diff --git a/api/v1/comment.proto b/api/v1/comment.proto new file mode 100644 index 0000000..f9451fb --- /dev/null +++ b/api/v1/comment.proto @@ -0,0 +1,113 @@ +syntax="proto3"; + +import "google/protobuf/timestamp.proto"; +import "gogoproto/gogo.proto"; +// import "validate/validate.proto"; +import "google/api/annotations.proto"; +import "google/protobuf/empty.proto"; +import "api/v1/user.proto"; + +package api.v1; + +option go_package = "moredoc/api/v1;v1"; +option java_multiple_files = true; +option java_package = "api.v1"; + +message Comment{ + google.protobuf.Timestamp created_at = 1 [ (gogoproto.stdtime) = true ]; + google.protobuf.Timestamp updated_at = 2 [ (gogoproto.stdtime) = true ]; + int64 id = 3; + int64 parent_id = 4; + string content = 5; + int64 document_id = 6; + int32 status = 7; + int32 comment_count = 8; + int64 user_id = 9; + User user = 10; + string document_title=11; +} + +message CheckCommentRequest { + repeated int64 id = 1; + int32 status = 2; +} + +message DeleteCommentRequest { + repeated int64 id = 1; +} + +message GetCommentRequest { + int64 id = 1; +} + +message ListCommentRequest { + int64 page = 1; + int64 size = 2; + string wd = 3; + repeated string field = 4; + string order = 5; + repeated int32 status = 6; + int64 document_id = 7; + int64 user_id = 8; + repeated int64 parent_id = 9; + bool with_document_title = 10; +} + +message ListCommentReply { + int64 total = 1; + repeated Comment comment = 2; +} + +message CreateCommentRequest{ + int64 document_id = 1; + int64 parent_id = 2; + string content = 3; + string captcha_id = 4; + string captcha = 5; +} + +service CommentAPI{ + rpc CreateComment (CreateCommentRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: '/api/v1/comment', + body: '*', + }; + } + + // 更新评论,仅限管理员操作 + rpc UpdateComment (Comment) returns (google.protobuf.Empty){ + option (google.api.http) = { + put: '/api/v1/comment', + body: '*', + }; + } + + // 管理员或用户自己删除自己的评论 + rpc DeleteComment (DeleteCommentRequest) returns (google.protobuf.Empty){ + option (google.api.http) = { + delete: '/api/v1/comment', + }; + } + + // 获取单个评论 + rpc GetComment (GetCommentRequest) returns (Comment){ + option (google.api.http) = { + get: '/api/v1/comment', + }; + } + + // 获取评论列表 + rpc ListComment (ListCommentRequest) returns (ListCommentReply){ + option (google.api.http) = { + get: '/api/v1/comment/list', + }; + } + + // 审核评论 + rpc CheckComment (CheckCommentRequest) returns (google.protobuf.Empty){ + option (google.api.http) = { + post: '/api/v1/comment/check', + body: '*', + }; + } +} \ No newline at end of file diff --git a/api/v1/group.proto b/api/v1/group.proto index f70a4e6..bb27d55 100644 --- a/api/v1/group.proto +++ b/api/v1/group.proto @@ -22,6 +22,7 @@ message Group { int32 user_count = 7; int32 sort = 8; bool enable_upload = 11; + bool enable_comment_approval = 12; google.protobuf.Timestamp created_at = 9 [ (gogoproto.stdtime) = true ]; google.protobuf.Timestamp updated_at = 10 [ (gogoproto.stdtime) = true ]; } diff --git a/biz/comment.go b/biz/comment.go new file mode 100644 index 0000000..dfc01ad --- /dev/null +++ b/biz/comment.go @@ -0,0 +1,264 @@ +package biz + +import ( + "context" + + pb "moredoc/api/v1" + "moredoc/middleware/auth" + "moredoc/model" + "moredoc/util" + "moredoc/util/captcha" + + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "gorm.io/gorm" +) + +type CommentAPIService struct { + pb.UnimplementedCommentAPIServer + dbModel *model.DBModel + logger *zap.Logger +} + +func NewCommentAPIService(dbModel *model.DBModel, logger *zap.Logger) (service *CommentAPIService) { + return &CommentAPIService{dbModel: dbModel, logger: logger.Named("CommentAPIService")} +} + +func (s *CommentAPIService) checkLogin(ctx context.Context) (*auth.UserClaims, error) { + return checkGRPCLogin(s.dbModel, ctx) +} + +func (s *CommentAPIService) checkPermission(ctx context.Context) (*auth.UserClaims, error) { + return checkGRPCPermission(s.dbModel, ctx) +} + +// 发表评论 +func (s *CommentAPIService) CreateComment(ctx context.Context, req *pb.CreateCommentRequest) (*emptypb.Empty, error) { + userClaims, err := checkGRPCLogin(s.dbModel, ctx) + if err != nil { + return nil, err + } + + // 评论验证码错误 + cfg := s.dbModel.GetConfigOfSecurity(model.ConfigSecurityEnableCaptchaComment) + if cfg.EnableCaptchaComment && !captcha.VerifyCaptcha(req.CaptchaId, req.Captcha) { + return nil, status.Errorf(codes.InvalidArgument, "验证码错误") + } + + comment := &model.Comment{} + err = util.CopyStruct(req, comment) + if err != nil { + s.logger.Error("CreateDocument", zap.Error(err)) + return nil, status.Errorf(codes.Internal, "发布评论失败:"+err.Error()) + } + + if comment.DocumentId <= 0 { + return nil, status.Errorf(codes.InvalidArgument, "文档id不能为空") + } + + if comment.Content == "" { + return nil, status.Errorf(codes.InvalidArgument, "评论内容不能为空") + } + + defaultStatus, err := s.dbModel.CanIPublishComment(userClaims.UserId) + if err != nil { + return nil, status.Error(codes.PermissionDenied, err.Error()) + } + + comment.Status = defaultStatus + comment.UserId = userClaims.UserId + err = s.dbModel.CreateComment(comment) + if err != nil { + s.logger.Error("CreateComment", zap.Error(err)) + return nil, status.Errorf(codes.Internal, "发布评论失败:"+err.Error()) + } + + return &emptypb.Empty{}, nil +} + +// 更新评论,仅限管理员 +func (s *CommentAPIService) UpdateComment(ctx context.Context, req *pb.Comment) (*emptypb.Empty, error) { + userClaims, err := s.checkPermission(ctx) + if err != nil { + return nil, err + } + + s.logger.Debug("UpdateComment", zap.Any("user", userClaims), zap.Any("req", req)) + + // 只允许更新评论状态和内容 + updateFields := []string{"content", "status"} + comment := &model.Comment{} + util.CopyStruct(req, comment) + err = s.dbModel.UpdateComment(comment, updateFields...) + if err != nil { + return nil, status.Errorf(codes.Internal, "更新评论失败") + } + return &emptypb.Empty{}, nil +} + +// 登录的用户,可删除自己的评论 +// 管理员,可删除任意评论 +func (s *CommentAPIService) DeleteComment(ctx context.Context, req *pb.DeleteCommentRequest) (*emptypb.Empty, error) { + var userIds []int64 + userClaims, err := s.checkPermission(ctx) + if err != nil && userClaims == nil { + // 未登录用户 + return nil, err + } + // 已登录用户,判断有管理员权限 + isAdmin := userClaims.UserId > 0 && err == nil + if !isAdmin { + // 非管理员,限定只能删除自己的评论 + userIds = append(userIds, userClaims.UserId) + } + + err = s.dbModel.DeleteComment(req.Id, userIds...) + if err != nil { + return nil, status.Errorf(codes.Internal, "删除评论失败") + } + + return &emptypb.Empty{}, nil +} + +func (s *CommentAPIService) GetComment(ctx context.Context, req *pb.GetCommentRequest) (*pb.Comment, error) { + _, err := s.checkPermission(ctx) + if err != nil { + return nil, err + } + s.logger.Debug("GetComment", zap.Any("req", req)) + comment, err := s.dbModel.GetComment(req.Id) + if err != nil { + return nil, status.Errorf(codes.Internal, "获取评论失败:"+err.Error()) + } + user, _ := s.dbModel.GetUser(comment.UserId, model.UserPublicFields...) + + pbComment := &pb.Comment{} + util.CopyStruct(comment, pbComment) + util.CopyStruct(user, pbComment.User) + + return pbComment, nil +} + +func (s *CommentAPIService) ListComment(ctx context.Context, req *pb.ListCommentRequest) (*pb.ListCommentReply, error) { + var ( + isLogin bool + isAdmin bool + ) + + userClaims, err := s.checkPermission(ctx) + if err != nil && userClaims == nil { + // 未登录用户 + isLogin = false + } else { + // 已登录用户,判断有管理员权限 + isLogin = true + isAdmin = userClaims.UserId > 0 && err == nil + } + + opt := &model.OptionGetCommentList{ + Page: int(req.Page), + Size: int(req.Size_), + WithCount: true, + QueryIn: make(map[string][]interface{}), + QueryLike: make(map[string][]interface{}), + Sort: []string{req.Order}, + } + + // default status + opt.QueryIn["status"] = []interface{}{model.CommentStatusApproved} + + if req.DocumentId > 0 { + opt.QueryIn["document_id"] = []interface{}{req.DocumentId} + opt.Page = 1 + opt.Size = 1000000 + } + + if len(req.ParentId) > 0 { + opt.QueryIn["parent_id"] = util.Slice2Interface(req.ParentId) + } + + if isAdmin && req.Wd != "" { + opt.QueryLike["content"] = []interface{}{req.Wd} + } + + if (isLogin && req.UserId == userClaims.UserId) || isAdmin { + delete(opt.QueryIn, "status") + if len(req.Status) > 0 { + opt.QueryIn["status"] = util.Slice2Interface(req.Status) + } + } + + if req.UserId > 0 { + opt.QueryIn["user_id"] = []interface{}{req.UserId} + } + + comments, total, err := s.dbModel.GetCommentList(opt) + if err != nil && err != gorm.ErrRecordNotFound { + return nil, status.Errorf(codes.Internal, "获取评论列表失败") + } + + resp := &pb.ListCommentReply{ + Total: total, + } + util.CopyStruct(comments, &resp.Comment) + + var ( + userIds []int64 + documentIds []int64 + userIdMapCommentIdx = make(map[int64][]int) + documentIdMapCommentIdx = make(map[int64][]int) + ) + for idx, comment := range comments { + userIds = append(userIds, comment.UserId) + documentIds = append(documentIds, comment.DocumentId) + userIdMapCommentIdx[comment.UserId] = append(userIdMapCommentIdx[comment.UserId], idx) + documentIdMapCommentIdx[comment.DocumentId] = append(documentIdMapCommentIdx[comment.DocumentId], idx) + } + + if len(userIds) > 0 { + users, _, _ := s.dbModel.GetUserList(&model.OptionGetUserList{ + SelectFields: model.UserPublicFields, + WithCount: false, + QueryIn: map[string][]interface{}{"id": util.Slice2Interface(userIds)}, + }) + + for _, user := range users { + indexes := userIdMapCommentIdx[user.Id] + for _, idx := range indexes { + util.CopyStruct(user, &resp.Comment[idx].User) + } + } + } + + if len(documentIds) > 0 { + documents, _, _ := s.dbModel.GetDocumentList(&model.OptionGetDocumentList{ + SelectFields: []string{"id", "title"}, + WithCount: false, + QueryIn: map[string][]interface{}{"id": util.Slice2Interface(documentIds)}, + }) + + for _, document := range documents { + indexes := documentIdMapCommentIdx[document.Id] + for _, idx := range indexes { + resp.Comment[idx].DocumentTitle = document.Title + } + } + } + + return resp, nil +} + +func (s *CommentAPIService) CheckComment(ctx context.Context, req *pb.CheckCommentRequest) (*emptypb.Empty, error) { + _, err := s.checkPermission(ctx) + if err != nil { + return nil, err + } + + err = s.dbModel.UpdateCommentStatus(req.Id, req.Status) + if err != nil { + return nil, status.Errorf(codes.Internal, err.Error()) + } + return &emptypb.Empty{}, nil +} diff --git a/model/comment.go b/model/comment.go new file mode 100644 index 0000000..47f1142 --- /dev/null +++ b/model/comment.go @@ -0,0 +1,227 @@ +package model + +import ( + // "fmt" + // "strings" + "strings" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +const ( + CommentStatusPending = iota // 待审核 + CommentStatusApproved // 已审核 + CommentStatusRejected // 已拒绝 +) + +type Comment struct { + Id int64 `form:"id" json:"id,omitempty" gorm:"primaryKey;autoIncrement;column:id;comment:;"` + UserId int64 `form:"user_id" json:"user_id,omitempty" gorm:"column:user_id;type:bigint(20);size:20;index:idx_user_id;comment:发布评论的用户;"` + ParentId int64 `form:"parent_id" json:"parent_id,omitempty" gorm:"column:parent_id;type:bigint(20);size:20;default:0;comment:上级ID;index:idx_parent_id;"` + Content string `form:"content" json:"content,omitempty" gorm:"column:content;type:text;comment:评论内容;"` + DocumentId int64 `form:"document_id" json:"document_id,omitempty" gorm:"column:document_id;type:bigint(20);size:20;default:0;comment:文档ID;index:idx_document_id;"` + Status int8 `form:"status" json:"status,omitempty" gorm:"column:status;type:tinyint(4);size:4;default:0;comment:0 待审,1过审,2拒绝;"` + CommentCount int `form:"comment_count" json:"comment_count,omitempty" gorm:"column:comment_count;type:int(11);size:11;default:0;comment:评论数量;"` + CreatedAt *time.Time `form:"created_at" json:"created_at,omitempty" gorm:"column:created_at;type:datetime;comment:评论时间;"` + UpdatedAt *time.Time `form:"updated_at" json:"updated_at,omitempty" gorm:"column:updated_at;type:datetime;comment:评论更新时间;"` +} + +func (Comment) TableName() string { + return tablePrefix + "comment" +} + +// CreateComment 创建Comment +func (m *DBModel) CreateComment(comment *Comment) (err error) { + tx := m.db.Begin() + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + err = tx.Create(comment).Error + if err != nil { + m.logger.Error("CreateComment", zap.Error(err)) + return + } + + // 文档评论数+1 + err = tx.Model(&Document{}).Where("id = ?", comment.DocumentId).Update("comment_count", gorm.Expr("comment_count + ?", 1)).Error + if err != nil { + m.logger.Error("CreateComment", zap.Error(err)) + return + } + + // 用户评论数+1 + err = tx.Model(&User{}).Where("id = ?", comment.UserId).Update("comment_count", gorm.Expr("comment_count + ?", 1)).Error + if err != nil { + m.logger.Error("CreateComment", zap.Error(err)) + return + } + + // 更新上级评论的评论数 + if comment.ParentId > 0 { + err = tx.Model(&Comment{}).Where("id = ?", comment.ParentId).Update("comment_count", gorm.Expr("comment_count + ?", 1)).Error + if err != nil { + m.logger.Error("CreateComment", zap.Error(err)) + return + } + } + + return +} + +// UpdateComment 更新Comment,如果需要更新指定字段,则请指定updateFields参数 +func (m *DBModel) UpdateComment(comment *Comment, updateFields ...string) (err error) { + db := m.db.Model(comment) + tableName := Comment{}.TableName() + + updateFields = m.FilterValidFields(tableName, updateFields...) + if len(updateFields) > 0 { // 更新指定字段 + db = db.Select(updateFields) + } else { // 更新全部字段,包括零值字段 + db = db.Select(m.GetTableFields(tableName)) + } + + err = db.Where("id = ?", comment.Id).Updates(comment).Error + if err != nil { + m.logger.Error("UpdateComment", zap.Error(err)) + } + return +} + +// GetComment 根据id获取Comment +func (m *DBModel) GetComment(id interface{}, fields ...string) (comment Comment, err error) { + db := m.db + + fields = m.FilterValidFields(Comment{}.TableName(), fields...) + if len(fields) > 0 { + db = db.Select(fields) + } + + err = db.Where("id = ?", id).First(&comment).Error + return +} + +type OptionGetCommentList struct { + Page int + Size int + WithCount bool // 是否返回总数 + Ids []interface{} // id列表 + SelectFields []string // 查询字段 + QueryRange map[string][2]interface{} // map[field][]{min,max} + QueryIn map[string][]interface{} // map[field][]{value1,value2,...} + QueryLike map[string][]interface{} // map[field][]{value1,value2,...} + Sort []string +} + +// GetCommentList 获取Comment列表 +func (m *DBModel) GetCommentList(opt *OptionGetCommentList) (commentList []Comment, total int64, err error) { + tableName := Comment{}.TableName() + db := m.db.Model(&Comment{}) + db = m.generateQueryRange(db, tableName, opt.QueryRange) + db = m.generateQueryIn(db, tableName, opt.QueryIn) + db = m.generateQueryLike(db, tableName, opt.QueryLike) + + if len(opt.Ids) > 0 { + db = db.Where("id in (?)", opt.Ids) + } + + if opt.WithCount { + err = db.Count(&total).Error + if err != nil { + m.logger.Error("GetCommentList", zap.Error(err)) + return + } + } + + opt.SelectFields = m.FilterValidFields(tableName, opt.SelectFields...) + if len(opt.SelectFields) > 0 { + db = db.Select(opt.SelectFields) + } + + db = m.generateQuerySort(db, tableName, opt.Sort) + + db = db.Offset((opt.Page - 1) * opt.Size).Limit(opt.Size) + + err = db.Find(&commentList).Error + if err != nil && err != gorm.ErrRecordNotFound { + m.logger.Error("GetCommentList", zap.Error(err)) + } + return +} + +// DeleteComment 删除数据 +// 删除评论之后,对应文档的评论数量也要减少,对应的父级文档评论数量也要减少,用户评论数量也要减少 +func (m *DBModel) DeleteComment(ids []int64, limitUserId ...int64) (err error) { + tx := m.db.Begin() + defer func() { + if err != nil { + tx.Rollback() + } else { + tx.Commit() + } + }() + + var ( + comments []Comment + user = &User{} + document = &Document{} + ) + cond := []string{"id in (?)"} + args := []interface{}{ids} + if len(limitUserId) > 0 { + cond = append(cond, "user_id in (?)") + args = append(args, limitUserId) + } + condStr := strings.Join(cond, " and ") + tx.Where(condStr, args...).Select("id", "parent_id", "document_id", "user_id").Find(&comments) + if len(comments) == 0 { + return + } + + err = tx.Where(condStr, args...).Delete(&Comment{}).Error + if err != nil { + m.logger.Error("DeleteComment", zap.Error(err)) + return + } + + for _, comment := range comments { + // 更新文档评论数 + err = tx.Model(document).Where("id = ?", comment.DocumentId).UpdateColumn("comment_count", gorm.Expr("comment_count - ?", 1)).Error + if err != nil { + m.logger.Error("DeleteComment", zap.Error(err)) + return + } + + // 更新父级评论数 + if comment.ParentId > 0 { + err = tx.Model(&comment).Where("id = ?", comment.ParentId).UpdateColumn("comment_count", gorm.Expr("comment_count - ?", 1)).Error + if err != nil { + m.logger.Error("DeleteComment", zap.Error(err)) + return + } + } + + // 更新用户评论数 + err = tx.Model(user).Where("id = ?", comment.UserId).UpdateColumn("comment_count", gorm.Expr("comment_count - ?", 1)).Error + if err != nil { + m.logger.Error("DeleteComment", zap.Error(err)) + return + } + } + + return +} + +func (m *DBModel) UpdateCommentStatus(ids []int64, status int32) (err error) { + err = m.db.Model(&Comment{}).Where("id in (?) and status != ?", ids, status).Update("status", status).Error + if err != nil { + m.logger.Error("UpdateCommentStatus", zap.Error(err)) + } + return +} diff --git a/model/config.go b/model/config.go index 7136a9d..da6d2d5 100644 --- a/model/config.go +++ b/model/config.go @@ -203,6 +203,7 @@ type ConfigSystem struct { const ( ConfigSecurityMaxDocumentSize = "max_document_size" // 是否关闭注册 + ConfigSecurityCommentInterval = "comment_interval" // 评论时间间隔 ConfigSecurityIsClose = "is_close" // 是否关闭注册 ConfigSecurityCloseStatement = "close_statement" // 闭站说明 ConfigSecurityEnableRegister = "enable_register" // 是否允许注册 @@ -214,6 +215,7 @@ const ( type ConfigSecurity struct { MaxDocumentSize int32 `json:"max_document_size"` // 允许上传的最大文档大小 + CommentInterval int32 `json:"comment_interval"` // 评论时间间隔, 单位秒 IsClose bool `json:"is_close"` // 是否闭站 CloseStatement string `json:"close_statement"` // 闭站说明 EnableRegister bool `json:"enable_register"` // 是否启用注册 @@ -346,7 +348,7 @@ func (m *DBModel) GetConfigOfSecurity(name ...string) (config ConfigSecurity) { case "is_close", "enable_register", "enable_captcha_login", "enable_captcha_register", "enable_captcha_comment", "enable_captcha_find_password", "enable_captcha_upload": value, _ := strconv.ParseBool(cfg.Value) data[cfg.Name] = value - case "max_document_size": + case "max_document_size", "comment_interval": data[cfg.Name], _ = strconv.Atoi(cfg.Value) default: data[cfg.Name] = cfg.Value @@ -409,6 +411,7 @@ func (m *DBModel) initConfig() (err error) { // 安全配置项 {Category: ConfigCategorySecurity, Name: ConfigSecurityMaxDocumentSize, Label: "最大文档大小(MB)", Value: "50", Placeholder: "允许用户上传的最大文档大小,默认为50,即50MB", InputType: "number", Sort: 15, Options: ""}, + {Category: ConfigCategorySecurity, Name: ConfigSecurityCommentInterval, Label: "评论时间间隔", Value: "10", Placeholder: "用户评论时间间隔,单位为秒。0表示不限制。", InputType: "number", Sort: 15, Options: ""}, {Category: ConfigCategorySecurity, Name: ConfigSecurityIsClose, Label: "是否关闭网站", Value: "false", Placeholder: "请选择是否关闭网站", InputType: "switch", Sort: 16, Options: ""}, {Category: ConfigCategorySecurity, Name: ConfigSecurityCloseStatement, Label: "闭站说明", Value: "false", Placeholder: "关闭网站后,页面提示的内容", InputType: "textarea", Sort: 17, Options: ""}, {Category: ConfigCategorySecurity, Name: ConfigSecurityEnableRegister, Label: "是否允许注册", Value: "true", Placeholder: "请选择是否允许用户注册", InputType: "switch", Sort: 18, Options: ""}, diff --git a/model/data.go b/model/data.go index 6bead13..37df4af 100644 --- a/model/data.go +++ b/model/data.go @@ -1,3 +1,4 @@ + package model func getPermissions() (permissions []Permission) { @@ -54,6 +55,11 @@ func getPermissions() (permissions []Permission) { {Title: "上传文章图片和音视频", Description: "", Method: "POST", Path: "/api/v1/upload/article"}, {Title: "上传文档分类封面", Description: "", Method: "POST", Path: "/api/v1/upload/category"}, {Title: "上传配置图片文件", Description: "", Method: "POST", Path: "/api/v1/upload/config"}, + {Title: "获取评论列表", Description: "", Method: "GRPC", Path: "/api.v1.CommentAPI/ListComment"}, + {Title: "获取单个评论", Description: "", Method: "GRPC", Path: "/api.v1.CommentAPI/GetComment"}, + {Title: "批量审核评论", Description: "", Method: "GRPC", Path: "/api.v1.CommentAPI/CheckComment"}, + {Title: "删除评论", Description: "", Method: "GRPC", Path: "/api.v1.CommentAPI/DeleteComment"}, + } return } diff --git a/model/group.go b/model/group.go index 8493def..b677116 100644 --- a/model/group.go +++ b/model/group.go @@ -9,17 +9,18 @@ import ( ) type Group struct { - Id int64 `form:"id" json:"id,omitempty" gorm:"primaryKey;autoIncrement;column:id;comment:用户组 id;"` - Title string `form:"title" json:"title,omitempty" gorm:"column:title;type:varchar(64);size:64;index:title,unique;comment:用户组名称;"` - Color string `form:"color" json:"color,omitempty" gorm:"column:color;type:varchar(20);size:20;comment:颜色;"` - IsDefault bool `form:"is_default" json:"is_default,omitempty" gorm:"column:is_default;type:tinyint(3);default:0;index:is_default;comment:是否默认;"` - IsDisplay bool `form:"is_display" json:"is_display,omitempty" gorm:"column:is_display;type:tinyint(3);default:0;comment:是否显示在用户名后;"` - Description string `form:"description" json:"description,omitempty" gorm:"column:description;type:varchar(255);size:255;comment:用户组描述;"` - UserCount int `form:"user_count" json:"user_count,omitempty" gorm:"column:user_count;type:int(11);size:11;default:0;comment:用户数量;"` - Sort int `form:"sort" json:"sort,omitempty" gorm:"column:sort;type:int(11);size:11;default:0;comment:排序,值越大越靠前;"` - EnableUpload bool `form:"enable_upload" json:"enable_upload,omitempty" gorm:"column:enable_upload;type:tinyint(3);default:0;comment:是否允许上传文档;"` - CreatedAt *time.Time `form:"created_at" json:"created_at,omitempty" gorm:"column:created_at;type:datetime;comment:创建时间;"` - UpdatedAt *time.Time `form:"updated_at" json:"updated_at,omitempty" gorm:"column:updated_at;type:datetime;comment:更新时间;"` + Id int64 `form:"id" json:"id,omitempty" gorm:"primaryKey;autoIncrement;column:id;comment:用户组 id;"` + Title string `form:"title" json:"title,omitempty" gorm:"column:title;type:varchar(64);size:64;index:title,unique;comment:用户组名称;"` + Color string `form:"color" json:"color,omitempty" gorm:"column:color;type:varchar(20);size:20;comment:颜色;"` + IsDefault bool `form:"is_default" json:"is_default,omitempty" gorm:"column:is_default;type:tinyint(3);default:0;index:is_default;comment:是否默认;"` + IsDisplay bool `form:"is_display" json:"is_display,omitempty" gorm:"column:is_display;type:tinyint(3);default:0;comment:是否显示在用户名后;"` + Description string `form:"description" json:"description,omitempty" gorm:"column:description;type:varchar(255);size:255;comment:用户组描述;"` + UserCount int `form:"user_count" json:"user_count,omitempty" gorm:"column:user_count;type:int(11);size:11;default:0;comment:用户数量;"` + Sort int `form:"sort" json:"sort,omitempty" gorm:"column:sort;type:int(11);size:11;default:0;comment:排序,值越大越靠前;"` + EnableUpload bool `form:"enable_upload" json:"enable_upload,omitempty" gorm:"column:enable_upload;type:tinyint(3);default:0;comment:是否允许上传文档;"` + EnableCommentApproval bool `form:"enable_comment_approval" json:"enable_comment_approval,omitempty" gorm:"column:enable_comment_approval;type:tinyint(3);default:0;comment:评论是否需要审核;"` + CreatedAt *time.Time `form:"created_at" json:"created_at,omitempty" gorm:"column:created_at;type:datetime;comment:创建时间;"` + UpdatedAt *time.Time `form:"updated_at" json:"updated_at,omitempty" gorm:"column:updated_at;type:datetime;comment:更新时间;"` } func (Group) TableName() string { @@ -27,7 +28,6 @@ func (Group) TableName() string { } // CreateGroup 创建Group -// TODO: 创建成功之后,注意相关表统计字段数值的增减 func (m *DBModel) CreateGroup(group *Group) (err error) { sess := m.db.Begin() defer func() { @@ -94,7 +94,7 @@ func (m *DBModel) UpdateGroup(group *Group, updateFields ...string) (err error) } // GetGroup 根据id获取Group -func (m *DBModel) GetGroup(id interface{}, fields ...string) (group Group, err error) { +func (m *DBModel) GetGroup(id int64, fields ...string) (group Group, err error) { db := m.db fields = m.FilterValidFields(Group{}.TableName(), fields...) diff --git a/model/init.go b/model/init.go index 9a47eb6..885144a 100644 --- a/model/init.go +++ b/model/init.go @@ -171,6 +171,7 @@ func (m *DBModel) SyncDB() (err error) { &Logout{}, &Article{}, &Favorite{}, + &Comment{}, } if err = m.db.AutoMigrate(tableModels...); err != nil { m.logger.Fatal("SyncDB", zap.Error(err)) @@ -277,9 +278,6 @@ func (m *DBModel) initGroupAndPermission() (err error) { // 如果没有任何用户组,则初始化 var existGroup Group m.db.First(&existGroup) - if existGroup.Id > 0 { - return - } sess := m.db.Begin() defer func() { @@ -290,10 +288,13 @@ func (m *DBModel) initGroupAndPermission() (err error) { } }() - err = sess.Create(&groups).Error - if err != nil { - m.logger.Error("initGroup", zap.Error(err)) - return + if existGroup.Id == 0 { + // 用户组还不存在,则创建初始用户组 + err = sess.Create(&groups).Error + if err != nil { + m.logger.Error("initGroup", zap.Error(err)) + return + } } // 初始化权限 diff --git a/model/permission.go b/model/permission.go index 1427636..be7341e 100644 --- a/model/permission.go +++ b/model/permission.go @@ -110,7 +110,7 @@ func (m *DBModel) DeletePermission(ids []interface{}) (err error) { return } -// CheckPermissionByUserId 根据用户ID,检查用户是否有权限 +// CheckPermissionByUserId 根据用户ID,检查用户是否有权限 func (m *DBModel) CheckPermissionByUserId(userId int64, path string, httpMethod ...string) (permission Permission, yes bool) { var ( userGroups []UserGroup @@ -118,12 +118,6 @@ func (m *DBModel) CheckPermissionByUserId(userId int64, path string, httpMethod method string ) - // NOTE: ID为1的用户,拥有所有权限,可以理解为类似linux的root用户 - if userId == 1 { - yes = true - return - } - if len(httpMethod) > 0 { method = httpMethod[0] } @@ -136,10 +130,16 @@ func (m *DBModel) CheckPermissionByUserId(userId int64, path string, httpMethod } permission, yes = m.CheckPermissionByGroupId(groupId, method, path) + // NOTE: ID为1的用户,拥有所有权限,可以理解为类似linux的root用户 + if !yes && userId == 1 { + yes = true + return + } + return permission, yes } -// CheckPermissionByGroupId 根据用户所属用户组ID,检查用户是否有权限 +// CheckPermissionByGroupId 根据用户所属用户组ID,检查用户是否有权限 func (m *DBModel) CheckPermissionByGroupId(groupId []int64, method, path string) (permission Permission, yes bool) { var err error fields := []string{"id", "method", "path", "title"} diff --git a/model/user.go b/model/user.go index e45e113..6be2ee4 100644 --- a/model/user.go +++ b/model/user.go @@ -430,3 +430,48 @@ func (m *DBModel) CanIUploadDocument(userId int64) (yes bool) { } return group.Id > 0 } + +// 用户是否发表评论 +func (m *DBModel) CanIPublishComment(userId int64) (defaultCommentStatus int8, err error) { + if userId <= 0 { + return + } + + var ( + group Group + userGroup UserGroup + comment Comment + ) + + m.db.Select("g.id").Table(group.TableName()+" g").Joins( + "left join "+userGroup.TableName()+" ug on g.id=ug.group_id", + ).Where("ug.user_id = ? and g.enable_comment_approval = ?", userId, false).Find(&group) + + // 评论不需要审核 + if group.Id > 0 { + defaultCommentStatus = CommentStatusApproved + } else { + defaultCommentStatus = CommentStatusPending + } + + // 评论时间间隔 + commentInterval := m.GetConfigOfSecurity(ConfigSecurityCommentInterval).CommentInterval + if commentInterval <= 0 { + return + } + + // 获取用户最新的一条评论信息 + m.db.Select("id", "created_at").Where("user_id = ?", userId).Order("id desc").Find(&comment) + if comment.Id <= 0 { + return + } + + seconds := int32(time.Since(*comment.CreatedAt).Seconds()) + left := commentInterval - seconds + if left > 0 { + err = fmt.Errorf("您的评论太快了,请等待 %d 秒后再试", left) + return + } + + return +} diff --git a/service/serve/registerGRPCService.go b/service/serve/registerGRPCService.go index 1986468..7909764 100644 --- a/service/serve/registerGRPCService.go +++ b/service/serve/registerGRPCService.go @@ -121,5 +121,14 @@ func RegisterGRPCService(dbModel *model.DBModel, logger *zap.Logger, endpoint st return } + // 评论服务 + commentAPIService := biz.NewCommentAPIService(dbModel, logger) + v1.RegisterCommentAPIServer(grpcServer, commentAPIService) + err = v1.RegisterCommentAPIHandlerFromEndpoint(context.Background(), gwmux, endpoint, dialOpts) + if err != nil { + logger.Error("RegisterCommentAPIHandlerFromEndpoint", zap.Error(err)) + return + } + return } diff --git a/web/api/comment.js b/web/api/comment.js new file mode 100644 index 0000000..1088e65 --- /dev/null +++ b/web/api/comment.js @@ -0,0 +1,49 @@ +import service from '~/utils/request' + +export const createComment = (data) => { + return service({ + url: '/api/v1/comment', + method: 'post', + data, + }) +} + +export const updateComment = (data) => { + return service({ + url: '/api/v1/comment', + method: 'put', + data, + }) +} + +export const deleteComment = (params) => { + return service({ + url: '/api/v1/comment', + method: 'delete', + params, + }) +} + +export const getComment = (params) => { + return service({ + url: '/api/v1/comment', + method: 'get', + params, + }) +} + +export const listComment = (params) => { + return service({ + url: '/api/v1/comment/list', + method: 'get', + params, + }) +} + +export const checkComment = (data) => { + return service({ + url: '/api/v1/comment/check', + method: 'post', + data, + }) +} diff --git a/web/components/CommentItem.vue b/web/components/CommentItem.vue new file mode 100644 index 0000000..36da568 --- /dev/null +++ b/web/components/CommentItem.vue @@ -0,0 +1,137 @@ + + + diff --git a/web/components/CommentList.vue b/web/components/CommentList.vue new file mode 100644 index 0000000..aacac46 --- /dev/null +++ b/web/components/CommentList.vue @@ -0,0 +1,122 @@ + + + diff --git a/web/components/FormComment.vue b/web/components/FormComment.vue new file mode 100644 index 0000000..7b19319 --- /dev/null +++ b/web/components/FormComment.vue @@ -0,0 +1,190 @@ + + + diff --git a/web/components/FormCommentCheck.vue b/web/components/FormCommentCheck.vue new file mode 100644 index 0000000..0e84102 --- /dev/null +++ b/web/components/FormCommentCheck.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/components/FormGroup.vue b/web/components/FormGroup.vue index fa1102a..e72cb8f 100644 --- a/web/components/FormGroup.vue +++ b/web/components/FormGroup.vue @@ -50,6 +50,17 @@ > + + + + +
+ + + +
+ + + diff --git a/web/components/UserCard.vue b/web/components/UserCard.vue index dd0ae2b..9d39694 100644 --- a/web/components/UserCard.vue +++ b/web/components/UserCard.vue @@ -3,10 +3,7 @@
- - - - +
@@ -46,7 +43,7 @@
-
个性签名
+
个性签名
{{ user.signature || '暂无个性签名' }}
@@ -93,7 +90,7 @@ export default { methods: {}, } - diff --git a/web/pages/admin/user/group.vue b/web/pages/admin/user/group.vue index 6081de5..777b8fd 100644 --- a/web/pages/admin/user/group.vue +++ b/web/pages/admin/user/group.vue @@ -227,6 +227,7 @@ export default { title: '', is_display: true, enable_upload: false, + enable_comment_approval: false, is_default: false, } }, @@ -254,6 +255,12 @@ export default { width: 120, type: 'bool', }, + { + prop: 'enable_comment_approval', + label: '评论需审核', + width: 120, + type: 'bool', + }, { prop: 'is_display', label: '是否展示', width: 80, type: 'bool' }, { prop: 'description', label: '描述', width: 250 }, { prop: 'created_at', label: '创建时间', width: 160, type: 'datetime' }, diff --git a/web/utils/permission.js b/web/utils/permission.js index 6ee5fff..77f0a83 100644 --- a/web/utils/permission.js +++ b/web/utils/permission.js @@ -67,6 +67,12 @@ const cumstomPermissionMap = { children: [], pages: ['/admin/article'], }, + 'api.v1.CommentAPI': { + label: '评论管理', + path: 'ListComment', + children: [], + pages: ['/admin/comment'], + }, upload: { id: 0, label: '上传管理',