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 }