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

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

tree 复制代码
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 层

java 复制代码
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);
    }
}

三个细节:

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

Service 层

java 复制代码
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 接口定义

java 复制代码
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 的数据,查出来永远是空还不好排查。枚举把非法值挡在编译期。

java 复制代码
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 实现(核心)

java 复制代码
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 表结构:

java 复制代码
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 与组装逻辑:

java 复制代码
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 覆盖不了的:

  1. 先拷 userInfo 再拷 comment :两个对象都有 id 字段(评论人 ID vs 评论 ID),后拷的 Comment 覆盖前者,保证 VO 的 id 最终是评论 ID。
  2. ObjectId → String :item.getId().toHexString(),ObjectId 不能直接序列化给前端。
  3. 时间戳格式化 :created 是毫秒 Long,转成 yyyy-MM-dd HH:mm:ss 字符串。
  4. 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 服务需先重启加载新代码):

  1. 登录测试账号,进入圈子,找一条评论数为 0 的动态。
  2. 点评论按钮进入详情页,发布一条评论;手动刷新客户端,确认评论数变为 1。
  3. 再发布两条,凑齐 3 条评论。
  4. 调接口核对分页、排序与组装:
bash 复制代码
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 批查评论人资料

官方文档

总结

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

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

相关推荐
sycmancia3 小时前
Qml——Text元素
java·前端·html
T_Apollo3 小时前
3.1 JVM 工作原理
java
Java内核笔记3 小时前
同一份知识库,4 种切分 × 3 种召回,纯关键词比纯向量高 13 个点
java·人工智能·spring boot
海岳云舟3 小时前
springboot单元测试技术
java·后端·spring
众链网络3 小时前
年卡跨淡旺季的有效期模型:不可用日期、次数与顺延规则
java·前端·数据库
文丑颜不良啊3 小时前
LeetCode926~950题目解析
java·leetcode
今天的砖头有点烫手啊3 小时前
Java 对象内存布局:压缩指针、对齐填充与 JOL 实测
java·jvm
devilnumber3 小时前
Lambda 表达式(Java)优缺点清晰分析
java·开发语言·python
时空节拍AI数字人3 小时前
博物馆数字人讲解系统设计:知识库 schema、展项联动协议与时序控制
java·服务器·前端·人工智能·ai·语音识别