概述
动态详情页向下滑动的评论区,靠 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);
}
}
三个细节:
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」的特例,普通字段没这个待遇。
| 查询字段 | _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 覆盖不了的:
请求时序图
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 服务需先重启加载新代码):
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,前端按「不足一页即到尾页」处理。
发布评论与评论列表共同构成动态评论的读写闭环。下一篇进入同章发布评论:测试总结,收束发布评论功能从写入、计数冗余到端到端验证的完整过程。





