package commentser import ( "context" "strconv" "strings" "time" "unicode/utf8" "videoweb/app/api/proto" "videoweb/app/service/configser" "videoweb/pub/common/enum/redisconst" "videoweb/pub/common/log" "videoweb/pub/common/netutil" "videoweb/pub/common/srvdef" "videoweb/pub/models/commentlikemod" "videoweb/pub/models/commentmod" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletmod" "videoweb/pub/models/usermod" "videoweb/pub/mongos" "videoweb/pub/redis" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const ( ObjectTypePlaylet int64 = 10 // 短剧评论 ObjectTypePlayletChapter int64 = 11 // 短剧剧集评论 commentLevelFirst int64 = 1 // 一级评论 commentLevelSecond int64 = 2 // 二级评论 commentReviewPending int = 0 // 待审核 commentReviewPass int = 1 // 审核通过 commentMaxTextLen int = 200 // 评论最大字数 commentDefaultPageSize int64 = 20 // 默认分页条数 commentMaxPageSize int64 = 100 // 最大分页条数 commentSortHot int64 = 1 // 热门 commentSortNewest int64 = 2 // 最新 commentPlayletHeatInc int64 = 3 // 发布评论增加的短剧热度值 operateStatusYes int64 = 1 // 已操作 operateStatusNo int64 = 2 // 未操作 ) var commentListProjection = bson.D{ {Key: commentmod.FN.Comment.Id, Value: 1}, {Key: commentmod.FN.Comment.ObjectId, Value: 1}, {Key: commentmod.FN.Comment.ObjectType, Value: 1}, {Key: commentmod.FN.Comment.UserId, Value: 1}, {Key: commentmod.FN.Comment.UserName, Value: 1}, {Key: commentmod.FN.Comment.UserAvatar, Value: 1}, {Key: commentmod.FN.Comment.Text, Value: 1}, {Key: commentmod.FN.Comment.Level, Value: 1}, {Key: commentmod.FN.Comment.ParentsId, Value: 1}, {Key: commentmod.FN.Comment.ReplyCommentId, Value: 1}, {Key: commentmod.FN.Comment.ReplyUserName, Value: 1}, {Key: commentmod.FN.Comment.CommentCount, Value: 1}, {Key: commentmod.FN.Comment.CountLike, Value: 1}, {Key: commentmod.FN.Comment.CreatedAt, Value: 1}, } // SendComment 发布评论 func SendComment(ctx context.Context, userId int64, ip string, req *proto.CommentSendReq) *srvdef.SrvErr { // 评论入口先做频控和基础参数校验,再校验短剧/剧集是否可评论,避免无效请求进入写库链路。 if userId <= 0 { return srvdef.New(srvdef.InvalidToken, "请先登录", "") } if err := checkCommentLimit(ip, req); err != nil { return err } text, err := normalizeCommentText(req) if err != nil { return err } objectName, playletId, err := validateCommentTarget(ctx, req.ObjectType, req.ObjectId) if err != nil { return err } level := commentLevelFirst parentsId := int64(0) replyCommentId := int64(0) replyUserName := "" if req.ParentsId > 0 { replyComment, err := findReplyTargetComment(ctx, req) if err != nil { return err } level = commentLevelSecond parentsId = req.ParentsId replyCommentId = replyComment.Id replyUserName = replyComment.UserName } user, err := findCommentUser(ctx, userId) if err != nil { return err } now := time.Now() info := commentmod.CommentModel{ Id: mongos.Lid.Comment.Get(), Ip: ip, ObjectId: req.ObjectId, ObjectName: objectName, ObjectType: req.ObjectType, UserId: user.Id, UserName: commentUserName(user), UserAvatar: user.Avatar, Level: level, ParentsId: parentsId, ReplyCommentId: replyCommentId, ReplyUserName: replyUserName, Text: text, CommentCount: 0, CountLike: 0, ReviewStatus: commentReviewPending, UpdatedAt: now, CreatedAt: now, } if err := commentmod.InsertOneComment(ctx, &info); err != nil { log.Error("插入短剧评论失败", log.Any("评论信息", info), log.E(err)) return err } if err := incrementCommentPlayletHeatValue(ctx, playletId, commentPlayletHeatInc); err != nil { log.Error("评论发布后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("评论信息", info), log.E(err)) return err } // app端发布评论先进入待审核状态,不立即增加短剧/剧集评论数;后台审核通过时再统一同步 countComment。 return nil } func findCommentUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) if err != nil { log.Error("查询评论用户失败", log.Any("用户ID", userId), log.E(err)) return nil, err } return user, nil } func commentUserName(user *usermod.User) string { if user == nil { return "" } if name := strings.TrimSpace(user.NickName); name != "" { return name } if name := strings.TrimSpace(user.AccountNo); name != "" { return name } return "用户" + strconv.FormatInt(user.Id, 10) } // GetCommentPage 分页查询评论列表 func GetCommentPage(ctx context.Context, userId int64, req *proto.GetCommentListReq) (*proto.GetCommentListResp, *srvdef.SrvErr) { // 列表只查一级且已审核评论,并通过一次批量查询补齐当前用户点赞状态,避免列表 N+1 查询。 if req == nil || req.ObjectId <= 0 || !isPlayletCommentType(req.ObjectType) { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "") } pageNum, pageSize := normalizeCommentPage(req.PageNum, req.PageSize) return getCommentPageData(ctx, userId, req.ObjectId, req.ObjectType, req.SortOrder, pageNum, pageSize) } // GetCommentList 兼容旧详情接口的评论查询 func GetCommentList(ctx context.Context, objectId, objectType, page int64) (*proto.GetCommentListResp, *srvdef.SrvErr) { pageNum, _ := normalizeCommentPage(page, commentDefaultPageSize) return getCommentPageData(ctx, 0, objectId, objectType, commentSortNewest, pageNum, commentDefaultPageSize) } // SetCommentLike 设置当前用户评论点赞状态 func SetCommentLike(ctx context.Context, userId int64, req *proto.CommentLikeSetReq) (*proto.CommentLikeSetResp, *srvdef.SrvErr) { // 使用评论点赞关系表做去重;只有关系真实变化时才更新 _comment.countLike,重复点赞/取消不会重复增减计数。 if userId <= 0 { return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "") } if req == nil || req.CommentId <= 0 || req.IsLike == nil { return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "") } comment, err := findLikeTargetComment(ctx, req.CommentId) if err != nil { return nil, err } filter := bson.M{ commentlikemod.FN.CommentLike.UserId: userId, commentlikemod.FN.CommentLike.CommentId: req.CommentId, } changed := false if *req.IsLike { inserted, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{ UserId: userId, CommentId: req.CommentId, ObjectId: comment.ObjectId, ObjectType: comment.ObjectType, }) if err != nil { log.Error("新增评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err)) return nil, err } changed = inserted } else { deleted, err := commentlikemod.DeleteOneCommentLike(ctx, filter) if err != nil { log.Error("删除评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err)) return nil, err } changed = deleted > 0 } if changed { delta := int64(1) if !*req.IsLike { delta = -1 } if err := incrementCommentLikeCount(ctx, req.CommentId, delta); err != nil { rollbackCommentLike(ctx, userId, comment, *req.IsLike) return nil, err } } return &proto.CommentLikeSetResp{ CommentId: req.CommentId, IsLike: operateStatusByFlag(*req.IsLike), CountLike: applyLocalCounter(comment.CountLike, *req.IsLike, changed), }, nil } func checkCommentLimit(ip string, req *proto.CommentSendReq) *srvdef.SrvErr { curValue, err := redis.Incr(redisconst.CommentLimitKey(ip), redisconst.CommentLimitKeyExpire) if err != nil { log.Error("评论频率限制写入Redis失败", log.E(err), log.Any("IP", ip)) } if curValue > 1 { log.Error("评论操作过于频繁", log.Any("IP", ip), log.Any("请求参数", req)) return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁") } dayCurValue, err := redis.Incr(redisconst.CommentOneDayLimitKey(ip), redisconst.CommentDayLimitKeyExpire) if err != nil { log.Error("评论每日限制写入Redis失败", log.E(err), log.Any("IP", ip)) } if dayCurValue > 50 { log.Error("评论每日次数超限", log.Any("IP", ip), log.Any("请求参数", req)) return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁") } return nil } func normalizeCommentText(req *proto.CommentSendReq) (string, *srvdef.SrvErr) { if req == nil || req.ObjectId <= 0 || !isPlayletCommentType(req.ObjectType) { return "", srvdef.New(srvdef.ErrParamError, "参数错误", "") } text := strings.TrimSpace(req.Text) if text == "" { return "", srvdef.New(srvdef.CommentTextIsEmpty, "评论内容不能为空", "") } if utf8.RuneCountInString(text) > commentMaxTextLen { return "", srvdef.New(srvdef.ErrParamError, "评论内容不能超过200字", "") } return text, nil } func validateCommentTarget(ctx context.Context, objectType, objectId int64) (string, int64, *srvdef.SrvErr) { switch objectType { case ObjectTypePlaylet: playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: objectId, playletmod.FN.Playlet.Status: playletmod.StatusUp, }, options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Title, Value: 1}, })) if err != nil { log.Error("查询评论短剧目标失败", log.Any("短剧ID", objectId), log.E(err)) return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "") } return playlet.Title, playlet.Id, nil case ObjectTypePlayletChapter: chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: objectId, }, options.FindOne().SetProjection(bson.D{ {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, })) if err != nil { log.Error("查询评论剧集目标失败", log.Any("剧集ID", objectId), log.E(err)) return "", 0, srvdef.New(srvdef.ErrParamError, "剧集不存在", "") } if _, err = playletmod.FindOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: chapter.PlayletId, playletmod.FN.Playlet.Status: playletmod.StatusUp, }, options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, })); err != nil { log.Error("查询剧集所属短剧失败", log.Any("剧集ID", objectId), log.Any("短剧ID", chapter.PlayletId), log.E(err)) return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "") } if chapter.Title != "" { return chapter.Title, chapter.PlayletId, nil } return "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集", chapter.PlayletId, nil default: return "", 0, srvdef.New(srvdef.ErrParamError, "评论类型有误", "") } } func findReplyTargetComment(ctx context.Context, req *proto.CommentSendReq) (*commentmod.CommentModel, *srvdef.SrvErr) { parentComment, err := 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.Level: commentLevelFirst, commentmod.FN.Comment.ParentsId: 0, commentmod.FN.Comment.ReviewStatus: commentReviewPass, }, options.FindOne().SetProjection(bson.D{ {Key: commentmod.FN.Comment.Id, Value: 1}, {Key: commentmod.FN.Comment.UserName, Value: 1}, })) if err != nil { log.Error("查询回复父评论失败", log.Any("父评论ID", req.ParentsId), log.Any("请求参数", req), log.E(err)) return nil, srvdef.New(srvdef.ErrParamError, "父评论不存在或不可回复", "") } replyCommentId := req.ReplyCommentId if replyCommentId <= 0 { replyCommentId = req.ParentsId } if replyCommentId == parentComment.Id { return parentComment, nil } replyComment, err := commentmod.FindOneComment(ctx, bson.M{ commentmod.FN.Comment.Id: replyCommentId, commentmod.FN.Comment.ObjectId: req.ObjectId, commentmod.FN.Comment.ObjectType: req.ObjectType, commentmod.FN.Comment.Level: commentLevelSecond, commentmod.FN.Comment.ParentsId: req.ParentsId, commentmod.FN.Comment.ReviewStatus: commentReviewPass, }, options.FindOne().SetProjection(bson.D{ {Key: commentmod.FN.Comment.Id, Value: 1}, {Key: commentmod.FN.Comment.UserName, Value: 1}, })) if err != nil { log.Error("查询被回复评论失败", log.Any("被回复评论ID", replyCommentId), log.Any("请求参数", req), log.E(err)) return nil, srvdef.New(srvdef.ErrParamError, "被回复评论不存在或不可回复", "") } return replyComment, nil } func incrementTargetCommentCount(ctx context.Context, objectType, objectId int64, delta int64) *srvdef.SrvErr { switch objectType { case ObjectTypePlaylet: if err := playletmod.UpdateOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: objectId, }, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountComment: delta}}); err != nil { log.Error("更新短剧评论数失败", log.Any("短剧ID", objectId), log.Any("增量", delta), log.E(err)) return err } case ObjectTypePlayletChapter: if err := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.Id: objectId, }, bson.M{"$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: delta}}); err != nil { log.Error("更新剧集评论数失败", log.Any("剧集ID", objectId), log.Any("增量", delta), log.E(err)) return err } } return nil } func incrementCommentPlayletHeatValue(ctx context.Context, playletId int64, delta int64) *srvdef.SrvErr { if playletId <= 0 || delta == 0 { return nil } return playletmod.UpdateOnePlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: playletId, }, bson.M{ "$inc": bson.M{ playletmod.FN.Playlet.HeatValue: delta, }, }) } func getCommentPageData(ctx context.Context, userId int64, objectId, objectType, sortOrder, pageNum, pageSize int64) (*proto.GetCommentListResp, *srvdef.SrvErr) { resp := &proto.GetCommentListResp{ ObjectId: objectId, TotalCount: 0, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]proto.CommentInfo, 0), } if objectId <= 0 || !isPlayletCommentType(objectType) { return resp, nil } filter := bson.M{ commentmod.FN.Comment.ObjectId: objectId, commentmod.FN.Comment.ObjectType: objectType, commentmod.FN.Comment.ReviewStatus: commentReviewPass, commentmod.FN.Comment.Level: commentLevelFirst, commentmod.FN.Comment.ParentsId: 0, } total, err := commentmod.CountComments(ctx, filter) if err != nil { log.Warn("统计评论列表失败", log.Any("filter", filter), log.E(err)) return resp, err } resp.TotalCount = total if total == 0 { return resp, nil } skip := (pageNum - 1) * pageSize if skip >= total { return resp, nil } opts := options.Find(). SetProjection(commentListProjection). SetSkip(skip). SetLimit(pageSize). SetSort(commentListSort(sortOrder)) list, err := commentmod.FindManyComment(ctx, filter, opts) if err != nil { log.Warn("查询评论列表失败", log.Any("filter", filter), log.E(err)) return resp, err } parentIds := commentIdsFromList(list) children, err := findChildComments(ctx, parentIds) if err != nil { return resp, err } allComments := make(commentmod.CommentModelList, 0, len(list)+len(children)) allComments = append(allComments, list...) allComments = append(allComments, children...) likedCommentIds, err := findLikedCommentIdMap(ctx, userId, allComments) if err != nil { return resp, err } childrenByParent := make(map[int64][]proto.CommentInfo) for _, item := range children { if item == nil { continue } _, liked := likedCommentIds[item.Id] childrenByParent[item.ParentsId] = append(childrenByParent[item.ParentsId], buildCommentInfo(ctx, item, liked)) } resp.List = make([]proto.CommentInfo, 0, len(list)) for _, item := range list { if item == nil { continue } _, liked := likedCommentIds[item.Id] info := buildCommentInfo(ctx, item, liked) info.Children = childrenByParent[item.Id] resp.List = append(resp.List, info) } return resp, nil } func commentListSort(sortOrder int64) bson.D { switch sortOrder { case commentSortHot: return bson.D{ {Key: commentmod.FN.Comment.CountLike, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}, } case commentSortNewest: fallthrough default: return bson.D{ {Key: commentmod.FN.Comment.CreatedAt, Value: -1}, {Key: commentmod.FN.Comment.Id, Value: -1}, } } } func commentIdsFromList(list commentmod.CommentModelList) []int64 { commentIds := make([]int64, 0, len(list)) for _, item := range list { if item != nil { commentIds = append(commentIds, item.Id) } } return commentIds } func findChildComments(ctx context.Context, parentIds []int64) (commentmod.CommentModelList, *srvdef.SrvErr) { if len(parentIds) == 0 { return make(commentmod.CommentModelList, 0), nil } filter := bson.M{ commentmod.FN.Comment.ParentsId: bson.M{"$in": parentIds}, commentmod.FN.Comment.Level: commentLevelSecond, commentmod.FN.Comment.ReviewStatus: commentReviewPass, } opts := options.Find(). SetProjection(commentListProjection). SetSort(bson.D{ {Key: commentmod.FN.Comment.ParentsId, Value: 1}, {Key: commentmod.FN.Comment.CreatedAt, Value: 1}, {Key: commentmod.FN.Comment.Id, Value: 1}, }) children, err := commentmod.FindManyComment(ctx, filter, opts) if err != nil { log.Warn("查询二级评论列表失败", log.Any("filter", filter), log.E(err)) return nil, err } return children, nil } func findLikedCommentIdMap(ctx context.Context, userId int64, list commentmod.CommentModelList) (map[int64]struct{}, *srvdef.SrvErr) { likedMap := make(map[int64]struct{}) if userId <= 0 || len(list) == 0 { return likedMap, nil } commentIds := commentIdsFromList(list) if len(commentIds) == 0 { return likedMap, nil } likes, err := commentlikemod.FindManyCommentLike(ctx, bson.M{ commentlikemod.FN.CommentLike.UserId: userId, commentlikemod.FN.CommentLike.CommentId: bson.M{"$in": commentIds}, }, options.Find().SetProjection(bson.D{ {Key: commentlikemod.FN.CommentLike.CommentId, Value: 1}, })) if err != nil { log.Error("批量查询评论点赞状态失败", log.Any("用户ID", userId), log.E(err)) return nil, err } for _, item := range likes { if item != nil { likedMap[item.CommentId] = struct{}{} } } return likedMap, nil } func buildCommentInfo(ctx context.Context, item *commentmod.CommentModel, liked bool) proto.CommentInfo { return proto.CommentInfo{ Id: item.Id, ObjectId: item.ObjectId, ObjectType: item.ObjectType, UserId: item.UserId, UserName: item.UserName, UserAvatar: withCommentAvatarDomain(ctx, item.UserAvatar), Text: item.Text, Level: item.Level, ParentsId: item.ParentsId, ReplyCommentId: item.ReplyCommentId, ReplyUserName: item.ReplyUserName, CommentCount: item.CommentCount, CountLike: item.CountLike, IsLike: operateStatusByFlag(liked), CreatedAt: item.CreatedAt, Children: make([]proto.CommentInfo, 0), } } func withCommentAvatarDomain(ctx context.Context, path string) string { path = strings.TrimSpace(path) if path == "" { return "" } imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) if imgCdn == "" { return path } return netutil.MergeURL(imgCdn, path) } func findLikeTargetComment(ctx context.Context, commentId int64) (*commentmod.CommentModel, *srvdef.SrvErr) { comment, err := commentmod.FindOneComment(ctx, bson.M{ commentmod.FN.Comment.Id: commentId, commentmod.FN.Comment.ReviewStatus: commentReviewPass, commentmod.FN.Comment.ObjectType: bson.M{ "$in": []int64{ObjectTypePlaylet, ObjectTypePlayletChapter}, }, }, options.FindOne().SetProjection(bson.D{ {Key: commentmod.FN.Comment.Id, Value: 1}, {Key: commentmod.FN.Comment.ObjectId, Value: 1}, {Key: commentmod.FN.Comment.ObjectType, Value: 1}, {Key: commentmod.FN.Comment.CountLike, Value: 1}, })) if err != nil { log.Error("查询评论点赞目标失败", log.Any("评论ID", commentId), log.E(err)) return nil, srvdef.New(srvdef.ErrParamError, "评论不存在", "") } return comment, nil } func incrementCommentLikeCount(ctx context.Context, commentId int64, delta int64) *srvdef.SrvErr { filter := bson.M{ commentmod.FN.Comment.Id: commentId, } if delta < 0 { filter[commentmod.FN.Comment.CountLike] = bson.M{"$gt": 0} } err := commentmod.UpdateOneComment(ctx, filter, bson.M{ "$inc": bson.M{ commentmod.FN.Comment.CountLike: delta, }, }) if err != nil { if delta < 0 && err.Code == srvdef.ErrNoData { log.Warn("评论点赞数已为0,跳过扣减", log.Any("评论ID", commentId)) return nil } log.Error("更新评论点赞数失败", log.Any("评论ID", commentId), log.Any("增量", delta), log.E(err)) return err } return nil } func rollbackCommentLike(ctx context.Context, userId int64, comment *commentmod.CommentModel, enabled bool) { filter := bson.M{ commentlikemod.FN.CommentLike.UserId: userId, commentlikemod.FN.CommentLike.CommentId: comment.Id, } if enabled { if _, err := commentlikemod.DeleteOneCommentLike(ctx, filter); err != nil { log.Error("回滚评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err)) } return } if _, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{ UserId: userId, CommentId: comment.Id, ObjectId: comment.ObjectId, ObjectType: comment.ObjectType, }); err != nil { log.Error("恢复评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err)) } } func normalizeCommentPage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = commentDefaultPageSize } if pageSize > commentMaxPageSize { pageSize = commentMaxPageSize } return pageNum, pageSize } func isPlayletCommentType(objectType int64) bool { return objectType == ObjectTypePlaylet || objectType == ObjectTypePlayletChapter } func applyLocalCounter(current int64, enabled bool, changed bool) int64 { if !changed { return current } if enabled { return current + 1 } if current <= 0 { return 0 } return current - 1 } func operateStatusByFlag(enabled bool) int64 { if enabled { return operateStatusYes } return operateStatusNo }