507 lines
17 KiB
Go
507 lines
17 KiB
Go
package commentser
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
"videoweb/app/api/proto"
|
|
"videoweb/app/service/configser"
|
|
"videoweb/app/service/moreser"
|
|
"videoweb/app/service/reportser"
|
|
"videoweb/pub/common/enum/redisconst"
|
|
"videoweb/pub/common/log"
|
|
"videoweb/pub/common/netutil"
|
|
"videoweb/pub/common/response"
|
|
"videoweb/pub/common/srvdef"
|
|
"videoweb/pub/common/useragent"
|
|
"videoweb/pub/models/commentlikemod"
|
|
"videoweb/pub/models/commentmod"
|
|
"videoweb/pub/models/playletchaptermod"
|
|
"videoweb/pub/models/playletmod"
|
|
"videoweb/pub/models/usermod"
|
|
"videoweb/pub/models/videoinformationmod"
|
|
"videoweb/pub/mongos"
|
|
"videoweb/pub/pubdef"
|
|
"videoweb/pub/redis"
|
|
|
|
"go.mongodb.org/mongo-driver/bson"
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
)
|
|
|
|
const (
|
|
commentListLimit int64 = 200
|
|
subCommentListLimit int64 = 400
|
|
commentTreeDefaultPageSize int64 = 10
|
|
commentTreeMaxPageSize int64 = 100
|
|
commentTreeMaxDepth int = 5
|
|
)
|
|
|
|
type commentTreeNode struct {
|
|
info proto.CommentInfo
|
|
children []*commentTreeNode
|
|
}
|
|
|
|
// ListCommentTree 分页查询指定对象评论列表,并按父子关系组装树形结构
|
|
func ListCommentTree(ctx context.Context, userId int64, req *proto.CommentListReq) (*proto.CommentListResp, *srvdef.SrvErr) {
|
|
if req.ObjectId <= 0 {
|
|
return nil, srvdef.New(srvdef.ErrParamError, "对象ID不能为空")
|
|
}
|
|
if !pubdef.ValidCommentObjectType(req.ObjectType) {
|
|
return nil, srvdef.New(srvdef.ErrParamError, "评论类型有误")
|
|
}
|
|
if req.SortOrder != 0 && req.SortOrder != 1 && req.SortOrder != 2 {
|
|
return nil, srvdef.New(srvdef.ErrParamError, "排序方式有误")
|
|
}
|
|
pageNum, pageSize := moreser.NormalizePage(req.PageNum, req.PageSize, commentTreeDefaultPageSize, commentTreeMaxPageSize)
|
|
filter := bson.M{
|
|
commentmod.FN.Comment.ObjectId: req.ObjectId,
|
|
commentmod.FN.Comment.ObjectType: req.ObjectType,
|
|
commentmod.FN.Comment.ReviewStatus: 1,
|
|
commentmod.FN.Comment.Level: 1,
|
|
}
|
|
total, err := commentmod.CountComments(ctx, filter)
|
|
if err != nil {
|
|
log.Warn("ListCommentTree.CountComments", log.Any("filter", filter), log.E(err))
|
|
return nil, err
|
|
}
|
|
resp := &proto.CommentListResp{BasePageResp: response.BasePageResp{
|
|
TotalCount: total,
|
|
PageSize: int(pageSize),
|
|
PageNum: int(pageNum),
|
|
List: make([]proto.CommentInfo, 0),
|
|
}}
|
|
if total == 0 {
|
|
return resp, nil
|
|
}
|
|
imgCdn := configser.ImageCdnFromCtx(ctx)
|
|
topList, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
|
SetProjection(commentListProjection()).
|
|
SetSort(commentListSort(req.SortOrder)).
|
|
SetSkip((pageNum-1)*pageSize).
|
|
SetLimit(pageSize))
|
|
if err != nil {
|
|
log.Warn("ListCommentTree.FindManyComment", log.Any("filter", filter), log.E(err))
|
|
return nil, err
|
|
}
|
|
children, err := commentTreeChildren(ctx, req.ObjectId, req.ObjectType, collectCommentIds(topList))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := append(collectCommentIds(topList), collectCommentIds(children)...)
|
|
likes, err := commentlikemod.FindByUserAndComments(ctx, userId, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
liked := make(map[int64]bool, len(likes))
|
|
for _, like := range likes {
|
|
liked[like.CommentId] = true
|
|
}
|
|
resp.List = buildCommentTree(topList, children, imgCdn, liked)
|
|
return resp, nil
|
|
}
|
|
|
|
// commentListSort 热门按点赞数排序,默认按发布时间排序
|
|
func commentListSort(sortOrder int64) bson.D {
|
|
if sortOrder == 1 {
|
|
return bson.D{{Key: commentmod.FN.Comment.CountLike, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}}
|
|
}
|
|
return bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}}
|
|
}
|
|
|
|
// GetCommentList 查询指定对象的首屏评论列表,并缓存短时间内重复读取
|
|
func GetCommentList(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType) (*proto.GetCommentListResp, *srvdef.SrvErr) {
|
|
cacheKey := redisconst.CommentListCacheKey(objectId, objectType)
|
|
cacheData, cacheErr := redis.Get(cacheKey)
|
|
if cacheErr != nil {
|
|
log.Warn("获取缓存失败", log.Any("cacheKey", cacheKey), log.E(cacheErr))
|
|
}
|
|
if cacheData != nil {
|
|
var commentListResp *proto.GetCommentListResp
|
|
if err := json.Unmarshal([]byte(*cacheData), &commentListResp); err != nil {
|
|
log.Warn("解析缓存失败", log.Any("cacheKey", cacheKey), log.E(err))
|
|
} else if commentListResp != nil {
|
|
configser.RewriteImageCdn(ctx, commentListResp)
|
|
return commentListResp, nil
|
|
}
|
|
}
|
|
data, err := commentList(ctx, objectId, objectType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bytes, marshalErr := json.Marshal(data)
|
|
if marshalErr != nil {
|
|
log.Warn("解析缓存失败", log.Any("cacheKey", cacheKey), log.E(marshalErr))
|
|
configser.RewriteImageCdn(ctx, data)
|
|
return data, nil
|
|
}
|
|
if setErr := redis.Set(cacheKey, string(bytes), redisconst.CommentListKeyExpire); setErr != nil {
|
|
log.Warn("设置缓存失败", log.Any("cacheKey", cacheKey), log.E(setErr))
|
|
}
|
|
configser.RewriteImageCdn(ctx, data)
|
|
return data, nil
|
|
}
|
|
|
|
// commentList 查询顶级评论,并批量补齐二级回复,避免逐条查询子评论
|
|
func commentList(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType) (*proto.GetCommentListResp, *srvdef.SrvErr) {
|
|
filter := bson.M{
|
|
commentmod.FN.Comment.ObjectId: objectId,
|
|
commentmod.FN.Comment.ObjectType: objectType,
|
|
commentmod.FN.Comment.ReviewStatus: 1,
|
|
commentmod.FN.Comment.Level: 1,
|
|
}
|
|
total, err := commentmod.CountComments(ctx, filter)
|
|
if err != nil {
|
|
log.Warn("commentList.CountComments", log.Any("filter", filter), log.E(err))
|
|
return nil, err
|
|
}
|
|
resp := &proto.GetCommentListResp{
|
|
ObjectId: objectId,
|
|
Total: total,
|
|
List: make([]proto.CommentInfo, 0),
|
|
}
|
|
if total == 0 {
|
|
return resp, nil
|
|
}
|
|
imgCdn := configser.GetConfigInfo().ImgCdn
|
|
list, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
|
SetProjection(commentListProjection()).
|
|
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}).
|
|
SetLimit(commentListLimit))
|
|
if err != nil {
|
|
log.Warn("commentList.FindManyComment", log.Any("filter", filter), log.E(err))
|
|
return nil, err
|
|
}
|
|
parentIds := make([]int64, 0, len(list))
|
|
for _, item := range list {
|
|
parentIds = append(parentIds, item.Id)
|
|
}
|
|
subCommentMap, err := subCommentList(ctx, parentIds, imgCdn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range list {
|
|
info := commentInfoData(item, imgCdn)
|
|
info.SubComment = subCommentMap[item.Id]
|
|
resp.List = append(resp.List, info)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// subCommentList 批量查询二级评论,并按父评论ID分组
|
|
func subCommentList(ctx context.Context, parentIds []int64, imgCdn string) (map[int64][]proto.CommentInfo, *srvdef.SrvErr) {
|
|
data := make(map[int64][]proto.CommentInfo, len(parentIds))
|
|
if len(parentIds) == 0 {
|
|
return data, nil
|
|
}
|
|
list, err := commentmod.FindManyComment(ctx, bson.M{
|
|
commentmod.FN.Comment.ParentsId: bson.M{"$in": parentIds},
|
|
commentmod.FN.Comment.ReviewStatus: 1,
|
|
}, options.Find().
|
|
SetProjection(commentListProjection()).
|
|
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}).
|
|
SetLimit(subCommentListLimit))
|
|
if err != nil {
|
|
log.Warn("subCommentList.FindManyComment", log.Any("parentIds", parentIds), log.E(err))
|
|
return nil, err
|
|
}
|
|
for _, item := range list {
|
|
data[item.ParentsId] = append(data[item.ParentsId], commentInfoData(item, imgCdn))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// commentTreeChildren 按层批量查询当前页一级评论的子孙评论,避免 N+1 查询
|
|
func commentTreeChildren(ctx context.Context, objectId int64, objectType pubdef.CommentObjectType, parentIds []int64) (commentmod.CommentModelList, *srvdef.SrvErr) {
|
|
data := make(commentmod.CommentModelList, 0)
|
|
currentParentIds := uniqueCommentIds(parentIds)
|
|
if len(currentParentIds) == 0 {
|
|
return data, nil
|
|
}
|
|
seenIds := make(map[int64]struct{}, len(currentParentIds))
|
|
for _, id := range currentParentIds {
|
|
seenIds[id] = struct{}{}
|
|
}
|
|
for depth := 0; len(currentParentIds) > 0 && depth < commentTreeMaxDepth; depth++ {
|
|
filter := bson.M{
|
|
commentmod.FN.Comment.ObjectId: objectId,
|
|
commentmod.FN.Comment.ObjectType: objectType,
|
|
commentmod.FN.Comment.ParentsId: bson.M{"$in": currentParentIds},
|
|
commentmod.FN.Comment.ReviewStatus: 1,
|
|
}
|
|
list, err := commentmod.FindManyComment(ctx, filter, options.Find().
|
|
SetProjection(commentListProjection()).
|
|
SetSort(bson.D{{Key: commentmod.FN.Comment.CreatedAt, Value: -1}}))
|
|
if err != nil {
|
|
log.Warn("commentTreeChildren.FindManyComment", log.Any("filter", filter), log.E(err))
|
|
return nil, err
|
|
}
|
|
nextParentIds := make([]int64, 0, len(list))
|
|
for _, item := range list {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if _, ok := seenIds[item.Id]; ok {
|
|
continue
|
|
}
|
|
seenIds[item.Id] = struct{}{}
|
|
data = append(data, item)
|
|
nextParentIds = append(nextParentIds, item.Id)
|
|
}
|
|
currentParentIds = nextParentIds
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// buildCommentTree 将一级评论和子孙评论按 parentsId 组装为树形结构
|
|
func buildCommentTree(topList commentmod.CommentModelList, children commentmod.CommentModelList, imgCdn string, liked map[int64]bool) []proto.CommentInfo {
|
|
nodes := make(map[int64]*commentTreeNode, len(topList)+len(children))
|
|
roots := make([]*commentTreeNode, 0, len(topList))
|
|
for _, item := range topList {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
info := commentInfoData(item, imgCdn)
|
|
info.IsLike = liked[item.Id]
|
|
node := &commentTreeNode{info: info}
|
|
nodes[item.Id] = node
|
|
roots = append(roots, node)
|
|
}
|
|
for _, item := range children {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
info := commentInfoData(item, imgCdn)
|
|
info.IsLike = liked[item.Id]
|
|
nodes[item.Id] = &commentTreeNode{info: info}
|
|
}
|
|
for _, item := range children {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
parentNode, ok := nodes[item.ParentsId]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if node, ok := nodes[item.Id]; ok {
|
|
parentNode.children = append(parentNode.children, node)
|
|
}
|
|
}
|
|
return commentTreeNodesToInfos(roots)
|
|
}
|
|
|
|
// commentTreeNodesToInfos 递归输出评论树响应
|
|
func commentTreeNodesToInfos(nodes []*commentTreeNode) []proto.CommentInfo {
|
|
list := make([]proto.CommentInfo, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
if node == nil {
|
|
continue
|
|
}
|
|
info := node.info
|
|
info.SubComment = commentTreeNodesToInfos(node.children)
|
|
list = append(list, info)
|
|
}
|
|
return list
|
|
}
|
|
|
|
// collectCommentIds 收集评论ID集合
|
|
func collectCommentIds(list commentmod.CommentModelList) []int64 {
|
|
ids := make([]int64, 0, len(list))
|
|
for _, item := range list {
|
|
if item == nil || item.Id <= 0 {
|
|
continue
|
|
}
|
|
ids = append(ids, item.Id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// uniqueCommentIds 去重评论ID集合
|
|
func uniqueCommentIds(ids []int64) []int64 {
|
|
seen := make(map[int64]struct{}, len(ids))
|
|
result := make([]int64, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
result = append(result, id)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// commentInfoData 将评论模型转换为 APP 评论响应结构
|
|
func commentInfoData(item *commentmod.CommentModel, imgCdn string) proto.CommentInfo {
|
|
return proto.CommentInfo{
|
|
Id: item.Id,
|
|
ObjectId: item.ObjectId,
|
|
UserName: item.UserName,
|
|
Avatar: netutil.MergeURL(imgCdn, item.Avatar),
|
|
Level: item.Level,
|
|
ParentsId: item.ParentsId,
|
|
ReplyUserName: item.ReplyUserName,
|
|
Text: item.Text,
|
|
CommentCount: item.CommentCount,
|
|
CountLike: item.CountLike,
|
|
SubComment: make([]proto.CommentInfo, 0),
|
|
CreatedAt: item.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// commentListProjection 限定评论列表字段,减少详情页评论查询的数据传输
|
|
func commentListProjection() bson.M {
|
|
return bson.M{
|
|
commentmod.FN.Comment.Id: 1,
|
|
commentmod.FN.Comment.ObjectId: 1,
|
|
commentmod.FN.Comment.UserName: 1,
|
|
commentmod.FN.Comment.Avatar: 1,
|
|
commentmod.FN.Comment.Level: 1,
|
|
commentmod.FN.Comment.ParentsId: 1,
|
|
commentmod.FN.Comment.ReplyUserName: 1,
|
|
commentmod.FN.Comment.Text: 1,
|
|
commentmod.FN.Comment.CommentCount: 1,
|
|
commentmod.FN.Comment.CountLike: 1,
|
|
commentmod.FN.Comment.CreatedAt: 1,
|
|
}
|
|
}
|
|
|
|
func SendComment(ctx context.Context, ip string, userId int64, req *proto.CommentSendReq, ua useragent.UA) *srvdef.SrvErr {
|
|
if req == nil || req.ObjectId <= 0 || req.ParentsId < 0 || !pubdef.ValidCommentObjectType(req.ObjectType) {
|
|
return srvdef.New(srvdef.ErrParamError, "评论参数有误")
|
|
}
|
|
req.Text = strings.TrimSpace(req.Text)
|
|
if req.Text == "" {
|
|
return srvdef.New(srvdef.ErrParamError, "评论内容不能为空")
|
|
}
|
|
if utf8.RuneCountInString(req.Text) > 200 {
|
|
return srvdef.New(srvdef.ErrParamError, "评论内容不能超过200字")
|
|
}
|
|
curValue, err := redis.Incr(redisconst.CommentLimitKey(ip), redisconst.CommentLimitKeyExpire)
|
|
if err != nil {
|
|
log.Error("SendComment redis incr failed", log.E(err))
|
|
}
|
|
//15秒超3评论提示频繁操作
|
|
if curValue > 1 {
|
|
log.Error("SendComment user comment too frequently!",
|
|
log.Any("ip", ip), log.Any("req", req), log.E(err))
|
|
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
|
|
}
|
|
|
|
//操作频繁度判断
|
|
dayCurValue, err := redis.Incr(redisconst.CommentOneDayLimitKey(ip), redisconst.CommentDayLimitKeyExpire)
|
|
if err != nil {
|
|
log.Error("SendComment redis incr failed", log.E(err))
|
|
}
|
|
// 每天20条
|
|
if dayCurValue > 50 {
|
|
log.Error("SendComment user comment too frequently!",
|
|
log.Any("ip", ip), log.Any("req", req), log.E(err))
|
|
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
|
|
}
|
|
if !pubdef.ValidCommentObjectType(req.ObjectType) {
|
|
return srvdef.New(srvdef.ErrParamError, "评论类型有误")
|
|
}
|
|
objectName := pubdef.CommentObjectTypeName(req.ObjectType)
|
|
var videoInfo2 videoinformationmod.VideoInformation
|
|
switch req.ObjectType {
|
|
case pubdef.CommentObjectTypeVideo:
|
|
videoInfo, err1 := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
|
|
videoinformationmod.FN.VideoInformation.Id: req.ObjectId,
|
|
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
|
|
})
|
|
if err1 != nil {
|
|
log.Warn("SendComment.FindOneVideoInformation error", log.Any("objectId", req.ObjectId), log.E(err1))
|
|
return err1
|
|
}
|
|
videoInfo2 = *videoInfo
|
|
case pubdef.CommentObjectTypePlaylet:
|
|
_, err1 := playletmod.FindOnePlaylet(ctx, bson.M{
|
|
playletmod.FN.Playlet.Id: req.ObjectId,
|
|
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
|
})
|
|
if err1 != nil {
|
|
log.Warn("SendComment.FindOnePlaylet error", log.Any("objectId", req.ObjectId), log.E(err1))
|
|
return err1
|
|
}
|
|
case pubdef.CommentObjectTypePlayletChapter:
|
|
chapter, err1 := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
|
|
playletchaptermod.FN.PlayletChapter.Id: req.ObjectId,
|
|
})
|
|
if err1 != nil {
|
|
return err1
|
|
}
|
|
_, err1 = playletmod.FindOnePlaylet(ctx, bson.M{
|
|
playletmod.FN.Playlet.Id: chapter.PlayletId,
|
|
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
|
})
|
|
if err1 != nil {
|
|
return err1
|
|
}
|
|
}
|
|
// 通过 token 中的 userId 查询用户信息,补充用户名和头像
|
|
userInfo, err1 := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
|
|
if err1 != nil {
|
|
log.Warn("SendComment.FindOneUser error", log.Any("userId", userId), log.E(err1))
|
|
return err1
|
|
}
|
|
userName := userInfo.NickName
|
|
if userName == "" {
|
|
userName = userInfo.AccountNo
|
|
}
|
|
|
|
level := int64(1)
|
|
replyUserName := ""
|
|
if req.ParentsId > 0 {
|
|
// 回复评论
|
|
parentsInfo, err1 := commentmod.FindOneComment(ctx, bson.M{
|
|
commentmod.FN.Comment.Id: req.ParentsId,
|
|
commentmod.FN.Comment.ObjectId: req.ObjectId,
|
|
commentmod.FN.Comment.ObjectType: req.ObjectType,
|
|
commentmod.FN.Comment.ReviewStatus: 1,
|
|
})
|
|
if err1 != nil {
|
|
log.Warn("SendComment.FindOneComment error", log.Any("objectId", req.ObjectId), log.Any("parentsId", req.ParentsId), log.E(err1))
|
|
return err1
|
|
}
|
|
level = parentsInfo.Level + 1
|
|
if level > int64(commentTreeMaxDepth+1) {
|
|
return srvdef.New(srvdef.ErrParamError, "回复层级过深")
|
|
}
|
|
replyUserName = parentsInfo.UserName
|
|
}
|
|
info := commentmod.CommentModel{
|
|
Id: mongos.Lid.Comment.Get(),
|
|
Ip: ip,
|
|
ObjectId: req.ObjectId,
|
|
ObjectName: objectName,
|
|
ObjectType: req.ObjectType,
|
|
UserId: userId,
|
|
UserName: userName,
|
|
Avatar: userInfo.Avatar,
|
|
Level: level,
|
|
ParentsId: req.ParentsId,
|
|
ReplyUserName: replyUserName,
|
|
Text: req.Text,
|
|
CommentCount: 0,
|
|
ReviewStatus: 0,
|
|
UpdatedAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err1 := commentmod.InsertOneComment(ctx, &info); err1 != nil {
|
|
return err1
|
|
}
|
|
var reportErr error
|
|
switch req.ObjectType {
|
|
case pubdef.CommentObjectTypeVideo:
|
|
reportErr = reportser.ReportVideoComment(userId, ua, ip, &videoInfo2, req.Text)
|
|
}
|
|
if reportErr != nil {
|
|
log.Warn("SendComment.ReportCommentEvent error", log.Any("objectType", req.ObjectType), log.Any("objectId", req.ObjectId), log.E(reportErr))
|
|
}
|
|
return nil
|
|
}
|