欢迎光临
我们一直在努力

Java 项目实战: 交友项目 - 查询评论列表

概述

动态详情页向下滑动的评论区,靠 GET /comments 这个接口支撑:按动态 ID 分页拉取评论,并把每条评论的评论人昵称、头像从 MySQL 侧批查回来拼进 VO。这里拆完整链路,重点讲两个易错点,publishId 的 ObjectId 手动转换、comment 集合三合一带来的 commentType=2 硬约束。

纲要

  • 业务场景与接口定义
  • 详情页评论区的分页加载
  • 请求/响应结构(PageResult + CommentVo)
  • 完整代码链路
  • Controller 层:CommentsController.findComments
  • Service 层:查评论 → 批查用户 → 组装 VO
  • Dubbo 接口:CommentApi.findComments
  • Mongo 实现:CommentApiImpl.findComments(核心)
  • 两个关键知识点
  • ObjectId 转换:_id 自动转 vs 普通字段手动转
  • commentType=2:三合一集合的过滤条件
  • 跨存储组装:批查用户资料
  • 请求时序图
  • 踩坑清单与排查手法
  • 可复现的验证步骤

业务场景

推荐圈子或好友动态的信息流里,每条动态底部都有一个评论按钮。点进去后页面分两块:上面是动态详情,下面是评论列表,往上滑动就触发分页加载。这里只做下面这块,评论列表的分页查询;发布评论的写入逻辑在发布评论系列已覆盖。

接口定义:

  • 地址:GET /comments
  • 参数:movementId(动态 ID)、page(页码,默认 1)、pagesize(页大小,默认 10)
  • 响应:PageResult,items 里是 CommentVo 列表,每个 VO 带 id、avatar、nickname(评论人详情三项)+ content(评论内容)、createDate、likeCount、hasLiked

涉及代码分布在四个模块:

mf
├── mf-app-server # 应用服务(本篇入口)
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ └── CommentsController.java # GET /comments
│ └── service
│ └── CommentsService.java # 查评论 + 组装 VO
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api
│ │ ├── CommentApi.java # Dubbo 接口定义
│ │ └── UserInfoApi.java # 批查用户详情
│ └── mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ └── CommentApiImpl.java # Mongo 分页查询实现
└── mf-model
└── src/main/java/com/mf/model
├── mongo/Comment.java # 互动表实体(三合一)
├── enums/CommentType.java # 1-点赞 2-评论 3-喜欢
└── vo/CommentVo.java # 列表响应 VO

完整代码链路

Controller 层

package com.mf.server.controller;

import com.mf.model.vo.PageResult;
import com.mf.server.service.CommentsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/comments")
public class CommentsController {

@Autowired
private CommentsService commentsService;

/**
* 发布评论
*/

@PostMapping
public ResponseEntity saveComments(@RequestBody Map map) {
String movementId = (String) map.get("movementId");
String comment = (String) map.get("comment");
commentsService.saveComments(movementId, comment);
return ResponseEntity.ok(null);
}

/**
* 分页查询评论列表
*/

@GetMapping
public ResponseEntity findComments(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pagesize,
String movementId) {
PageResult pr = commentsService.findComments(movementId, page, pagesize);
return ResponseEntity.ok(pr);
}
}

三个细节:

  • 类上 @RequestMapping("/comments") 已含路径,方法上 @GetMapping 不再写值,GET /comments 即命中。
  • page、pagesize 用 @RequestParam(defaultValue = …) 兜底,客户端不传也能工作;movementId 不加注解,按参数名从 query string 绑定。
  • page 从 1 开始。后面的 skip 公式是 (page – 1) * pagesize,如果客户端传 0,第一页会整页跳过,这是联调时「翻页少一条」的常见来源。
  • Service 层

    package com.mf.server.service;

    import cn.hutool.core.collection.CollUtil;
    import com.mf.dubbo.api.CommentApi;
    import com.mf.dubbo.api.UserInfoApi;
    import com.mf.model.domain.UserInfo;
    import com.mf.model.enums.CommentType;
    import com.mf.model.mongo.Comment;
    import com.mf.model.vo.CommentVo;
    import com.mf.model.vo.PageResult;
    import org.apache.dubbo.config.annotation.DubboReference;
    import org.springframework.stereotype.Service;

    import java.util.ArrayList;
    import java.util.List;
    import java.util.Map;

    @Service
    public class CommentsService {

    @DubboReference
    private CommentApi commentApi;

    @DubboReference
    private UserInfoApi userInfoApi;

    /**
    * 分页查询评论列表
    */

    public PageResult findComments(String movementId, Integer page, Integer pagesize) {
    //1、调用API查询评论列表
    List<Comment> list = commentApi.findComments(movementId, CommentType.COMMENT, page, pagesize);
    //2、判空:前端处理不了 null,空结果返回空 PageResult
    if (CollUtil.isEmpty(list)) {
    return new PageResult();
    }
    //3、提取所有评论人ID,批查用户详情
    List<Long> userIds = CollUtil.getFieldValues(list, "userId", Long.class);
    Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, null);
    //4、构造 VO 对象
    List<CommentVo> vos = new ArrayList<>();
    for (Comment comment : list) {
    UserInfo userInfo = map.get(comment.getUserId());
    if (userInfo != null) {
    CommentVo vo = CommentVo.init(userInfo, comment);
    vos.add(vo);
    }
    }
    //5、构造返回值
    return new PageResult(page, pagesize, 0L, vos);
    }
    }

    五步逻辑里藏着三个口径:

    • 空结果返回空对象而非 null。CollUtil.isEmpty 命中时 return new PageResult(),前端拿到 {"counts":0,"items":[]} 走空态渲染;返回 null 会让前端解析挂掉。
    • counts 固定传 0。这条分页链路没有做 count() 总数查询,PageResult 的 pages 因此恒为 0,前端靠「返回条数 < pagesize 判定到尾页」。评论列表用这种轻量口径就够了,省一次 count 查询。
    • userInfo != null 是注销兜底。批查回来的 map 里不一定有每个 userId,用户注销后 user_info 表里没数据。不判空直接 CommentVo.init(userInfo, …) 会 NPE;判空后跳过,代价是单页条数可能少于 pagesize(见坑表第 5 条)。

    Dubbo 接口定义

    package com.mf.dubbo.api;

    import com.mf.model.enums.CommentType;
    import com.mf.model.mongo.Comment;

    import java.util.List;

    public interface CommentApi {

    //发布评论,并获取评论数量
    Integer save(Comment comment);

    //分页查询
    List<Comment> findComments(String movementId, CommentType commentType, Integer page, Integer pagesize);

    //判断comment数据是否存在
    Boolean hasComment(String movementId, Long userId, CommentType commentType);

    //删除comment数据
    Integer delete(Comment comment);
    }

    第二个参数是 CommentType 枚举而不是 Integer。用枚举是为了把类型收口:如果开放 int,调用方随手写个 5,集合里根本没有 type=5 的数据,查出来永远是空还不好排查。枚举把非法值挡在编译期。

    package com.mf.model.enums;

    /**
    * 评论类型:1-点赞,2-评论,3-喜欢
    */

    public enum CommentType {

    LIKE(1), COMMENT(2), LOVE(3);

    int type;

    CommentType(int type) {
    this.type = type;
    }

    public int getType() {
    return type;
    }
    }

    Mongo 实现(核心)

    package com.mf.dubbo.api;

    import com.mf.model.enums.CommentType;
    import com.mf.model.mongo.Comment;
    import com.mf.model.mongo.Movement;
    import org.apache.dubbo.config.annotation.DubboService;
    import org.bson.types.ObjectId;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.domain.Sort;
    import org.springframework.data.mongodb.core.FindAndModifyOptions;
    import org.springframework.data.mongodb.core.MongoTemplate;
    import org.springframework.data.mongodb.core.query.Criteria;
    import org.springframework.data.mongodb.core.query.Query;
    import org.springframework.data.mongodb.core.query.Update;

    import java.util.List;

    @DubboService
    public class CommentApiImpl implements CommentApi {

    @Autowired
    private MongoTemplate mongoTemplate;

    //分页查询
    public List<Comment> findComments(String movementId, CommentType commentType, Integer page, Integer pagesize) {
    //1、构造查询条件
    Query query = Query.query(Criteria.where("publishId").is(new ObjectId(movementId)).and("commentType")
    .is(commentType.getType()))
    .skip((page – 1) * pagesize)
    .limit(pagesize)
    .with(Sort.by(Sort.Order.desc("created")));
    //2、查询并返回
    return mongoTemplate.find(query, Comment.class);
    }
    }

    逐行看这段不到 10 行的实现:

    • new ObjectId(movementId) 是手动转换,这是这里最重要的知识点,下一节单独展开。
    • commentType=2 是硬约束。comment 集合三合一存储(1-点赞、2-评论、3-喜欢),同一个 publishId 下既有评论文档也有点赞文档。条件里不带 commentType,点赞记录会混进评论列表,用户能在评论区刷出一堆空白条目(点赞文档没有 content 字段)。
    • 排序 + 分页的写法:Sort.by(Sort.Order.desc("created")) 按 created(毫秒时间戳)倒序,新评论在前,符合评论区习惯;skip((page-1)*pagesize) 跳过前 N 页,limit(pagesize) 取本页。链式顺序不影响生成的 Mongo 查询,但按「条件 → skip → limit → sort」读起来最顺。
    • Comment 实体上 @Document(collection = "comment") 指定集合名,mongoTemplate.find(query, Comment.class) 返回实体列表,序列化经 Dubbo 回传。

    ObjectId 转换:自动转 vs 手动转

    comment 表结构:

    package com.mf.model.mongo;

    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.bson.types.ObjectId;
    import org.springframework.data.mongodb.core.mapping.Document;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Document(collection = "comment")
    public class Comment implements java.io.Serializable {

    private ObjectId id;
    private ObjectId publishId; //动态id
    private Integer commentType; //评论类型,1-点赞,2-评论,3-喜欢
    private String content; //评论内容
    private Long userId; //评论人
    private Long publishUserId; //被评论人ID
    private Long created; //发表时间
    private Integer likeCount = 0; //当前评论的点赞数
    }

    为什么查动态详情时 findById(id, Movement.class) 传 String 就能查到,而这里 publishId 必须手动 new ObjectId()?对照表:

    场景字段传入类型是否需要手动转换原因
    findById(id, Movement.class) _id(实体属性名 id) String 不需要 Spring Data MongoDB 对主键属性(名为 id 或标 @Id)做自动类型转换,String → ObjectId 由框架完成
    Criteria.where("publishId").is(…) publishId(普通字段) String 必须 new ObjectId(movementId) 普通字段不做自动转换,String 直接 is 进去,和库里存储的 ObjectId 类型不匹配

    普通字段忘转的后果特别隐蔽:查询不报错,只是查不到数据,接口正常返回空列表。你在 Mongo shell 里肉眼能看到明明有 publishId 匹配的文档,代码却永远空结果,这是 Mongo 查询里最难查的一类静默 bug。排查手法:把生成的 Query 打出来对一下类型,或直接确认 Criteria.is() 传入的对象类型是否和库中字段类型(ObjectId)一致。

    项目作者原话的翻译是:如果把握不住框架内部的转换规则,就让查询字段类型和传入参数类型时刻保持一致,这样绝对不会出错。主键自动转是「框架知道你在查 _id」的特例,普通字段没这个待遇。

    维度movement 详情查询评论列表查询
    查询字段 _id(主键) publishId(普通字段)
    传参 String id String movementId
    写法 mongoTemplate.findById(id, Movement.class) Criteria.where("publishId").is(new ObjectId(movementId))
    转换方 框架自动转 手动 new ObjectId()
    忘转后果 不存在该场景 静默空结果

    VO 组装:跨存储批查评论人资料

    comment 集合里只存 userId(一个 Long),昵称头像在 MySQL 的 user_info 表。列表要展示评论人信息,就必须跨存储组装,这套「提取 ID → 批查 → Map 回填」的模式在项目里已经复用了很多次(动态列表、谁看过我都是同一套):

    #mermaid-svg-EpmMxiZb5sd6i2lA{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EpmMxiZb5sd6i2lA .error-icon{fill:#552222;}#mermaid-svg-EpmMxiZb5sd6i2lA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EpmMxiZb5sd6i2lA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EpmMxiZb5sd6i2lA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EpmMxiZb5sd6i2lA .marker.cross{stroke:#333333;}#mermaid-svg-EpmMxiZb5sd6i2lA svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EpmMxiZb5sd6i2lA p{margin:0;}#mermaid-svg-EpmMxiZb5sd6i2lA .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster-label text{fill:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster-label span{color:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster-label span p{background-color:transparent;}#mermaid-svg-EpmMxiZb5sd6i2lA .label text,#mermaid-svg-EpmMxiZb5sd6i2lA span{fill:#333;color:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA .node rect,#mermaid-svg-EpmMxiZb5sd6i2lA .node circle,#mermaid-svg-EpmMxiZb5sd6i2lA .node ellipse,#mermaid-svg-EpmMxiZb5sd6i2lA .node polygon,#mermaid-svg-EpmMxiZb5sd6i2lA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EpmMxiZb5sd6i2lA .rough-node .label text,#mermaid-svg-EpmMxiZb5sd6i2lA .node .label text,#mermaid-svg-EpmMxiZb5sd6i2lA .image-shape .label,#mermaid-svg-EpmMxiZb5sd6i2lA .icon-shape .label{text-anchor:middle;}#mermaid-svg-EpmMxiZb5sd6i2lA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EpmMxiZb5sd6i2lA .rough-node .label,#mermaid-svg-EpmMxiZb5sd6i2lA .node .label,#mermaid-svg-EpmMxiZb5sd6i2lA .image-shape .label,#mermaid-svg-EpmMxiZb5sd6i2lA .icon-shape .label{text-align:center;}#mermaid-svg-EpmMxiZb5sd6i2lA .node.clickable{cursor:pointer;}#mermaid-svg-EpmMxiZb5sd6i2lA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EpmMxiZb5sd6i2lA .arrowheadPath{fill:#333333;}#mermaid-svg-EpmMxiZb5sd6i2lA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EpmMxiZb5sd6i2lA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EpmMxiZb5sd6i2lA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EpmMxiZb5sd6i2lA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EpmMxiZb5sd6i2lA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EpmMxiZb5sd6i2lA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster text{fill:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA .cluster span{color:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EpmMxiZb5sd6i2lA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EpmMxiZb5sd6i2lA rect.text{fill:none;stroke-width:0;}#mermaid-svg-EpmMxiZb5sd6i2lA .icon-shape,#mermaid-svg-EpmMxiZb5sd6i2lA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EpmMxiZb5sd6i2lA .icon-shape p,#mermaid-svg-EpmMxiZb5sd6i2lA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EpmMxiZb5sd6i2lA .icon-shape .label rect,#mermaid-svg-EpmMxiZb5sd6i2lA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EpmMxiZb5sd6i2lA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EpmMxiZb5sd6i2lA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EpmMxiZb5sd6i2lA :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    CollUtil.getFieldValues(list, "userId", Long.class)

    Dubbo: userInfoApi.findByIds(userIds, null)

    循环回填 map.get(comment.getUserId())

    List<Comment>

    List<Long> userIds

    Map<Long, UserInfo>

    List<CommentVo>

    关键是一次 RPC 批量拿齐,而不是循环里逐个 findById,一页 10 条评论就是 10 次 RPC,这是必须避免的 N+1 调用。

    CommentVo 与组装逻辑:

    package com.mf.model.vo;

    import com.mf.model.domain.UserInfo;
    import com.mf.model.mongo.Comment;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.beans.BeanUtils;

    import java.io.Serializable;
    import java.text.SimpleDateFormat;
    import java.util.Date;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class CommentVo implements Serializable {

    private String id; //评论id
    private String avatar; //头像
    private String nickname; //昵称

    private String content; //评论
    private String createDate; //评论时间

    private Integer likeCount; //点赞数
    private Integer hasLiked; //是否点赞(1是,0否)

    public static CommentVo init(UserInfo userInfo, Comment item) {
    CommentVo vo = new CommentVo();
    BeanUtils.copyProperties(userInfo, vo);
    BeanUtils.copyProperties(item, vo);
    vo.setHasLiked(0);
    Date date = new Date(item.getCreated());
    vo.setCreateDate(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date));
    vo.setId(item.getId().toHexString());
    return vo;
    }
    }

    init 方法做了四次「个性化处理」,都是 copyProperties 覆盖不了的:

  • 先拷 userInfo 再拷 comment:两个对象都有 id 字段(评论人 ID vs 评论 ID),后拷的 Comment 覆盖前者,保证 VO 的 id 最终是评论 ID。
  • ObjectId → String:item.getId().toHexString(),ObjectId 不能直接序列化给前端。
  • 时间戳格式化:created 是毫秒 Long,转成 yyyy-MM-dd HH:mm:ss 字符串。
  • hasLiked 固定置 0:当前登录人是否赞过该评论的功能后续做评论点赞时再实现,先兜底为未点赞。
  • 请求时序图

    UserInfoApi(Dubbo/MySQL)

    CommentApiImpl(Dubbo/Mongo)

    mf-app-server

    客户端(详情页)

    UserInfoApi(Dubbo/MySQL)

    CommentApiImpl(Dubbo/Mongo)

    mf-app-server

    客户端(详情页)

    #mermaid-svg-VLnMJWyqLlAt9WEP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VLnMJWyqLlAt9WEP .error-icon{fill:#552222;}#mermaid-svg-VLnMJWyqLlAt9WEP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VLnMJWyqLlAt9WEP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VLnMJWyqLlAt9WEP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VLnMJWyqLlAt9WEP .marker.cross{stroke:#333333;}#mermaid-svg-VLnMJWyqLlAt9WEP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VLnMJWyqLlAt9WEP p{margin:0;}#mermaid-svg-VLnMJWyqLlAt9WEP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VLnMJWyqLlAt9WEP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VLnMJWyqLlAt9WEP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VLnMJWyqLlAt9WEP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VLnMJWyqLlAt9WEP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VLnMJWyqLlAt9WEP .sequenceNumber{fill:white;}#mermaid-svg-VLnMJWyqLlAt9WEP #sequencenumber{fill:#333;}#mermaid-svg-VLnMJWyqLlAt9WEP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VLnMJWyqLlAt9WEP .messageText{fill:#333;stroke:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VLnMJWyqLlAt9WEP .labelText,#mermaid-svg-VLnMJWyqLlAt9WEP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .loopText,#mermaid-svg-VLnMJWyqLlAt9WEP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VLnMJWyqLlAt9WEP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VLnMJWyqLlAt9WEP .noteText,#mermaid-svg-VLnMJWyqLlAt9WEP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VLnMJWyqLlAt9WEP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VLnMJWyqLlAt9WEP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VLnMJWyqLlAt9WEP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VLnMJWyqLlAt9WEP .actorPopupMenu{position:absolute;}#mermaid-svg-VLnMJWyqLlAt9WEP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VLnMJWyqLlAt9WEP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VLnMJWyqLlAt9WEP .actor-man circle,#mermaid-svg-VLnMJWyqLlAt9WEP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VLnMJWyqLlAt9WEP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    alt

    [列表为空]

    [列表非空]

    GET /comments?movementId=xx&page=1&pagesize=10

    Token 鉴权(拦截器解析用户)

    CommentsService.findComments

    findComments(movementId, COMMENT, 1, 10)

    new ObjectId(movementId) 手动转换

    publishId=ObjectId & commentType=2

    created 倒序 + skip/limit

    List<Comment>

    PageResult() 空对象

    CollUtil.getFieldValues 提取 userIds

    findByIds(userIds, null) 批查

    Map<Long, UserInfo>

    循环组装 CommentVo.init(userInfo, comment)

    userInfo 为 null 的跳过(注销兜底)

    PageResult(page, pagesize, 0, List<CommentVo>)

    踩坑清单

    坑现象原因与解法
    忘记 commentType 条件 评论列表混入空白条目 三合一集合没过滤,type=1 的点赞文档也查出来了(点赞文档无 content)。条件必须 .and("commentType").is(2)
    publishId 忘转 ObjectId 接口正常返回空列表,不报错 String 直接 is 与库中 ObjectId 类型不匹配,静默空结果。检查 new ObjectId(movementId) 是否存在
    page 传 0 或负数 第一页少一页数据 / skip 为负报错 页码从 1 开始,公式 (page-1)*pagesize;Controller 层 defaultValue 只兜「不传」,兜不了「传错」
    评论人注销后 NPE 列表接口 500 批查 map 里没有该 userId,init 前必须判 userInfo != null,跳过或用默认头像昵称兜底
    列表条数与详情页评论计数对不上 计数显示 10,列表只有 8 条 两个来源:计数来自 Movement 表的 commentCount 冗余字段,列表来自 comment 集合实时查询;注销用户被组装阶段跳过、历史脏数据都会造成差异
    返回 null 而非空 PageResult 前端解析报错 空结果返回 new PageResult(),items 是空集合

    验证步骤

    可按以下顺序复现(app-server 与 dubbo-mongo 服务需先重启加载新代码):

  • 登录测试账号,进入圈子,找一条评论数为 0 的动态。
  • 点评论按钮进入详情页,发布一条评论;手动刷新客户端,确认评论数变为 1。
  • 再发布两条,凑齐 3 条评论。
  • 调接口核对分页、排序与组装:
  • curl "http://localhost:8080/comments?movementId=<动态ID>&page=1&pagesize=2"

    核对点:

    • pagesize=2 时 items 长度为 2;page=2 能翻出第 3 条。
    • items 按 createDate 倒序,最新发布的评论在最前。
    • 每条 items 的 nickname、avatar 与发布者资料一致,hasLiked=0。
    • 传一个不存在的 movementId(注意仍是合法的 24 位十六进制串,如 000000000000000000000000),返回空 PageResult 而非报错。
    • 注销兜底观察:直接在 user_info 表删掉其中一位评论人的资料,再调列表接口,该条评论从列表消失但接口不报错(组装阶段 userInfo == null 被跳过)。

    常见误解澄清

    误解事实
    Spring Data MongoDB 会自动把所有 ID 类字段转 ObjectId 只转主键(属性名 id 或 @Id)。publishId 这类普通字段必须手动 new ObjectId()
    忘转 ObjectId 会抛类型转换异常 不会。查询正常执行、正常返回空列表,是最典型的静默 bug
    评论列表需要查总数才能分页 本实现 counts 传 0,前端以「返回条数 < pagesize」判定尾页,省一次 count 查询
    comment 集合按功能拆成点赞表/评论表/喜欢表 单集合三合一,靠 commentType(1-点赞 2-评论 3-喜欢)区分,查询必须带类型条件
    VO 的 id 是评论人的用户 ID init 里最后一次 copyProperties(item, vo) 用 Comment 覆盖,VO 的 id 是评论 ID(已转 String)
    列表里的 hasLiked 已实现点赞状态判断 当前固定置 0,评论点赞功能在后续小节实现

    API 速览

    项值
    接口 GET /comments
    参数 movementId(动态 ID,必传)、page(默认 1)、pagesize(默认 10)
    响应 PageResult{counts, pagesize, pages, page, items: List<CommentVo>}
    CommentVo 字段 id、avatar、nickname、content、createDate、likeCount、hasLiked
    Mongo 查询 publishId = ObjectId(movementId) AND commentType = 2,created 倒序,skip/limit 分页
    跨存储调用 UserInfoApi.findByIds 批查评论人资料

    官方文档

    • Spring Data MongoDB – Querying Documents
    • Spring Data MongoDB – Mapping (_id 字段处理与 ObjectId 转换)
    • MongoDB Manual – ObjectId
    • Hutool – CollUtil 工具类

    总结

    • 评论列表是一条四层链路:Controller 收参 → Service 编排 → Dubbo CommentApi → Mongo 条件分页查询,再回头批查 MySQL 用户资料拼 VO。
    • publishId 必须 new ObjectId() 手动转换:主键 id 有框架自动转换的特权,普通字段没有。这类错误静默返回空结果,排查看类型一致性。
    • commentType=2 不能省:三合一集合里点赞、喜欢和评论同仓存储,不带类型条件就是把点赞记录当评论吐给前端。
    • 组装环节沿用「提取 ID → findByIds 批查 → Map 回填」的跨存储模式,一次 RPC 拿齐,注销用户判空跳过。
    • 分页走轻量口径:不查总数,counts 传 0,前端按「不足一页即到尾页」处理。

    发布评论与评论列表共同构成动态评论的读写闭环。下一篇进入同章发布评论:测试总结,收束发布评论功能从写入、计数冗余到端到端验证的完整过程。

    赞(0)
    未经允许不得转载:171主机测评 » Java 项目实战: 交友项目 - 查询评论列表
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址