Java 项目实战: 交友项目 - 发布评论

概述

评论和点赞同属圈子互动,但评论是「带文本内容的互动」:它要在 comment 集合里落一条有 content 的记录,再让 movement 的评论计数 +1。这条链路既要理解它和「点赞」这条更短链路的异同,也要看清平铺评论、计数维护、孤儿数据这几个绕不开的点。下面从背景、流程、代码、测试到收尾,把发布评论这件事讲透。

业务场景:评论是有内容的互动

点赞表达的是「我认同」,评论表达的是「我想说什么」。产品视角看,两者节奏完全不同:点赞是零成本的一次点击,评论则要求用户组织语言,触发频率低一到两个数量级,但单条价值高得多,评论区是社交产品里对话发生的地方,也是用户停留时间最长的区域之一。

维度 点赞 评论
数据量 大(随手就点) 小一两个数量级
是否有内容 无,纯标记(content 为空) 有,文本正文
触发频率 高频、冲动型 低频、表达型
幂等约束 一人只能赞一次(需查重) 可反复评论(无需查重)
额外状态 Redis 记录点赞标识 无需额外状态
产品价值 轻量认可,刷存在感 引出对话,沉淀内容,社区活跃度的核心指标

对应到交友项目的数据模型,两者落在同一张 comment 集合 ,靠 commentType 区分(1-点赞,2-评论,3-喜欢)。这是典型的「三合一」设计:互动记录结构高度相似,只有「是否有内容」和计数目标字段不同,合并存储可以复用同一套保存、查询、计数代码。

发布评论流程分步

接口约定:POST /comments,参数以 JSON body 传入(后端用 Map 接收),两个参数:

参数 说明
movementId 被评论动态的 id
comment 评论正文内容

接口文档定义响应不返回业务内容,返回 200 即可。课程视频里提过「要返回评论总数」,工程实读里 CommentsService 确实拿到了 commentApi.save() 返回的最新评论数,但只打了一行日志,没有回给前端。也就是说服务端算了这个数(点赞接口会把数返回给前端刷新角标),评论接口暂时没用上,属于「结构上预留、业务上未接」。

整条链路分五步:

步骤 动作 关键点
① 接收参数并校验 movementId + 文本;非空、长度上限、敏感词过滤
② 构建 Comment 对象 commentType=2、publishId、userId、内容、创建时间
③ 落库 comment 集合 同时回填被评论人 publishUserId
④ movement 计数 +1 findAndModify $inc,拿到最新计数
⑤ 返回 200;最新评论数目前仅记日志

步骤① 接收参数

Controller 从 Map 里取 movementId 和 comment 两个字符串,透传给 Service。这是评论与点赞在入口处的第一个差异:点赞路径是 GET /{id}/like,只有一个 id;评论多了一项正文,参数体随之从路径参数变成请求体。

java 复制代码
// 伪代码:入口层职责
POST /comments
  movementId <- body["movementId"]   // 动态 id
  comment    <- body["comment"]      // 评论正文
  调用 Service.saveComments(movementId, comment)
  返回 200

文本内容在这里至少要做三件事,项目工程里未展开,但生产必做:

  • 非空校验:空串、纯空白直接拒绝;
  • 长度上限:给个明确上限(如 500 字),防止超长文本刷库;
  • 敏感词过滤:UGC 文本一律先过敏感词/内容安全,命中的拒绝或转人工。项目用的是阿里云内容审核服务,实现在后台系统章节,这里不展开,知道「审核动作挂在发布链路的前置位置」即可。

鉴权在这一层之前已完成:token 解析出的当前用户 id 放在 UserHolder(ThreadLocal)里,Service 直接取,不用从参数传。

步骤② 构建 Comment

从 UserHolder.getUserId() 拿评论人 id,组装 Comment:

java 复制代码
// 伪代码:构建评论记录
Comment c = new Comment();
c.setPublishId(new ObjectId(movementId));        // 被评论的动态
c.setCommentType(CommentType.COMMENT.getType()); // 固定为 2
c.setContent(comment);                           // ★ 与点赞的核心差异:有正文
c.setUserId(userId);                             // 评论人,来自登录态
c.setCreated(System.currentTimeMillis());        // 发表时间

注意 movementId 要转成 ObjectId 再存,movement 的 _id 是 ObjectId 类型,存成字符串会导致查询对不上。commentType 写死为 CommentType.COMMENT(值 2),这是三合一集合的判别字段。

步骤③④ 落库 + 计数联动(API 层)

这两步在 dubbo 提供者的 API 实现里完成,而且和点赞、喜欢共用同一个方法 ,靠 commentType 分流:

java 复制代码
// 伪代码:API 层,评论/点赞/喜欢 通用
Movement movement = 按主键查 movement;
if (movement != null) {
    c.setPublishUserId(movement.getUserId()); // 回填被评论人,供通知类功能使用
}
保存 c 到 comment 集合;
Update: 按 commentType 选择目标字段
    COMMENT(2) -> commentCount $inc +1   // 本篇走的分支
    LIKE(1)    -> likeCount    $inc +1
    LOVE(3)    -> loveCount    $inc +1
最新movement = findAndModify(查询movement, Update, 返回更新后的新文档);
return 最新movement.按commentType取对应计数字段;

两个细节值得停留:

  • 回填 publishUserId 有前置条件 :先查 movement,查到才设置被评论人。这个「查一次」顺带起到了弱校验作用,但注意工程代码里 movement == null 时并不拦截,照样往下保存,动态不存在的边界实际是漏掉的,后面异常表里再说。
  • findAndModify + returnNew(true) :一次往返完成「计数 +1 并拿回新值」,不用再单独查一次总数。这个方法同时服务三种互动类型,返回时按 commentType 映射回对应字段,调用方拿到的就是「这次互动之后的最新计数」。

步骤⑤ 返回

Controller 返回 200 空体。API 返回的最新评论数在 Service 里只落了日志,前端如需刷新「共 N 条评论」文案,目前靠重新拉评论列表。

代码实现

发布评论这条链路的特殊之处在于:一次写入要同时维护两份数据,comment 集合里新增一条评论,movement 文档里的 commentCount 还要加一。下面把 Controller → Service → Dubbo API → MongoTemplate 的完整代码贴出来逐段拆解,重点看 CommentApiImpl.save 里「查动态回填被评论人 → 落库 → findAndModify 计数」这三步,以及一个工程里真实存在的坑:动态查不到时评论照样落库。

文件清单

发布评论涉及 3 个模块、6 个文件,分工很清晰:app-server 管参数和组装,dubbo-interface 定契约,dubbo-mongo 干真正的数据库活。

模块 文件 职责
mf-app-server CommentsController POST /comments,从 Map 里取 movementId 和评论内容
mf-app-server CommentsService 构建 Comment 对象,通过 Dubbo 调 API
mf-dubbo-interface CommentApi 定义 save 方法契约
mf-dubbo-mongo CommentApiImpl 落库 + 计数联动(这里核心)
mf-model Comment 评论实体,映射 comment 集合
mf-model CommentType 评论类型枚举,1-点赞 2-评论 3-喜欢
tree 复制代码
mf
├── mf-app-server
│   └── src/main/java/com/mf/server
│       ├── controller
│       │   └── CommentsController.java   # POST /comments
│       └── service
│           └── CommentsService.java      # 构建 Comment,调 Dubbo
├── mf-dubbo
│   ├── mf-dubbo-interface
│   │   └── src/main/java/com/mf/dubbo/api
│   │       └── CommentApi.java           # Integer save(Comment comment)
│   └── mf-dubbo-mongo
│       └── src/main/java/com/mf/dubbo/api
│           └── CommentApiImpl.java       # 落库 + 计数联动
└── mf-model
    └── src/main/java/com/mf/model
        ├── enums
        │   └── CommentType.java          # LIKE(1) COMMENT(2) LOVE(3)
        └── mongo
            └── Comment.java              # @Document(collection = "comment")

顺带说一句命名:讲义里写的是 CommentController 和 CommentService,工程实读下来实际类名是 CommentsController 和 CommentsService(类名带 s,对应 /comments 请求路径)。代码以下文实读版本为准。

Controller:POST /comments,Map 接参

接口约定是 POST /comments,请求体 JSON 里两个字段:movementId(动态 ID 字符串)和 comment(评论文本)。工程里没有为这两个参数定义 DTO,直接用 Map 接的:

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) {
        // 从请求体中取出动态 id 与评论文本,均为 String,手动强转
        String movementId = (String) map.get("movementId");
        String comment = (String) map.get("comment");
        commentsService.saveComments(movementId, comment);
        // 发布评论不返回业务数据,状态 200 即可
        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);
    }
}

用 Map 接参能跑,但两个值都是 (String) 强转拿出来的,key 写错不会有任何编译期提示。更工程化的写法是定义一个请求 DTO,配合 @NotBlank 做参数校验,尤其评论内容这里没有任何长度限制,一个 10 万字符的评论也会原样写进 MongoDB。这个坑后面坑表里还会出现。

Service:构建 Comment,四个关键字段

CommentsService.saveComments 的职责只有一个:把散落的参数组装成一个 Comment 对象,然后交给 Dubbo API。被评论人 publishUserId 这里故意不设,原因下一节讲。

java 复制代码
package com.mf.server.service;

import com.mf.dubbo.api.CommentApi;
import com.mf.model.enums.CommentType;
import com.mf.model.mongo.Comment;
import com.mf.server.interceptor.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.apache.dubbo.config.annotation.DubboReference;
import org.bson.types.ObjectId;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class CommentsService {

    @DubboReference
    private CommentApi commentApi;

    // 发布评论
    public void saveComments(String movementId, String comment) {
        // 1、获取操作用户 id(拦截器解析 token 后放入 ThreadLocal)
        Long userId = UserHolder.getUserId();
        // 2、构造 Comment
        Comment comment1 = new Comment();
        // 前端传来的 movementId 是字符串,MongoDB 的 _id 是 ObjectId,需要转换
        comment1.setPublishId(new ObjectId(movementId));
        // 评论类型固定为 COMMENT(2),用枚举取值而不是裸写数字
        comment1.setCommentType(CommentType.COMMENT.getType());
        // 评论内容
        comment1.setContent(comment);
        // 评论人 = 当前登录用户
        comment1.setUserId(userId);
        // 发表时间(毫秒时间戳)
        comment1.setCreated(System.currentTimeMillis());
        // 3、调用 API 保存评论,返回操作后的最新评论数
        Integer commentCount = commentApi.save(comment1);
        log.info("commentCount = " + commentCount);
    }
}

三个值得停下来看的点:

  • publishId 必须是 ObjectId。前端传的是 "6236f9..." 这种 24 位十六进制字符串,直接存字符串后面 findById 就对不上了,new ObjectId(movementId) 这一步转换不能省。如果前端传了非法字符串,这里会直接抛 IllegalArgumentException。
  • commentType 用枚举取值。机翻稿里讲师特意强调过:直接传数字 1、2、3 只有写代码的人知道含义,其他人可能传 4、5、6 这种非法值,所以用枚举限定。
  • save 的返回值(最新评论数)在发布评论场景其实用不上,只是打了个日志。保留返回值是给后面的点赞/喜欢复用的,那两个接口要把最新计数返回给前端刷新 UI。

Dubbo 接口:CommentApi

接口定义在 mf-dubbo-interface,一个方法四个参数都打包在 Comment 对象里:

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 comment1);

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

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

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

CommentApiImpl.save:一次调用,两份数据(这里核心)

CommentApiImpl.save 是点赞、评论、喜欢三个功能共用的保存方法,整体分五步。先看完整代码,再逐段拆:

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.springframework.beans.factory.annotation.Autowired;
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 Integer save(Comment comment) {
        // 1、查询动态
        Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
        // 2、向 comment 对象设置被评论人属性
        if (movement != null) {
            comment.setPublishUserId(movement.getUserId());
        }
        // 3、保存到数据库
        mongoTemplate.save(comment);
        // 4、更新动态表中的对应字段
        Query query = Query.query(Criteria.where("id").is(comment.getPublishId()));
        Update update = new Update();
        // 公共方法,按类型分门别类:点赞加 likeCount,评论加 commentCount,喜欢加 loveCount
        if (comment.getCommentType() == CommentType.LIKE.getType()) {
            update.inc("likeCount", 1);
        } else if (comment.getCommentType() == CommentType.COMMENT.getType()) {
            update.inc("commentCount", 1);
        } else {
            update.inc("loveCount", 1);
        }
        // 设置更新参数
        FindAndModifyOptions options = new FindAndModifyOptions();
        options.returnNew(true); // 获取更新后的最新数据
        Movement modify = mongoTemplate.findAndModify(query, update, options, Movement.class);
        // 5、获取最新的评论数量,并返回
        return modify.statisCount(comment.getCommentType());
    }

    // ...... findComments / hasComment / delete 略,见前文接口定义
}

第一步:查动态,回填 publishUserId

java 复制代码
Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
if (movement != null) {
    comment.setPublishUserId(movement.getUserId());
}

为什么 Service 层不直接设置 publishUserId,要跑到 API 层来查一次动态?机翻稿里讲师解释过这个取舍:被评论人就是动态发布人,Service 层当然也可以先查动态再赋值,但保存评论之后无论如何还要更新 movement 的计数字段(第三、四步),既然都要碰 movement,就在 API 层一次查出来,回填和后续计数共用这一次查询,省掉 app-server 与 mongo 服务之间多一次往返。

publishUserId 不是可有可无的字段:评论列表的通知、展示(「谁评论了你的动态」)都依赖它知道动态主人是谁。落库前不回填,后面做消息通知时这条评论就成了「不知道该通知谁」的脏数据。

第二步:落库 comment

java 复制代码
mongoTemplate.save(comment);

save 语义是「有 id 就覆盖、没 id 就插入」。此时 comment 的 id 还是 null,所以走插入,MongoDB 自动生成 _id。publishUserId 可能为 null(见孤儿评论一节),其余字段 Service 层已经填齐。

第三步:findAndModify 联动计数

java 复制代码
Query query = Query.query(Criteria.where("id").is(comment.getPublishId()));
Update update = new Update();
if (comment.getCommentType() == CommentType.LIKE.getType()) {
    update.inc("likeCount", 1);
} else if (comment.getCommentType() == CommentType.COMMENT.getType()) {
    update.inc("commentCount", 1);
} else {
    update.inc("loveCount", 1);
}
FindAndModifyOptions options = new FindAndModifyOptions();
options.returnNew(true);
Movement modify = mongoTemplate.findAndModify(query, update, options, Movement.class);

三个动作合在一个调用里:

  • inc 是 MongoDB 的原子自增操作符,自增在服务端完成,不存在「读出来加一再写回去」的竞态,两个人同时评论,commentCount 也会正确加二。
  • 按 commentType 分流到不同的计数键,因为这个方法是点赞/评论/喜欢三合一的公共入口。发布评论走 commentCount。
  • returnNew(true) 是 findAndModify 和普通 update 的关键区别:默认情况下 findAndModify 返回的是更新前 的旧文档,设了 returnNew(true) 才返回更新后 的新文档。这里要的就是加一之后的最新计数直接返给前端,不设这个参数就得再查一次数据库。这与 CRUD 篇讲的 findAndModify 是同一个 API,当时用它生成自增 ID,这里用它拿更新后的统计值,「更新并取回」是它的两个典型用法。

注意一个细节:Criteria.where("id") 写的是 id 而不是 _id,Spring Data MongoDB 会自动映射到 _id,并且会把查询值转换成 ObjectId。

第四步:statisCount 统一取数

java 复制代码
return modify.statisCount(comment.getCommentType());

statisCount 是 Movement 实体上的工具方法,内部逻辑和上面 inc 的 if-else 一一对应:

java 复制代码
// Movement.java(节选)
public Integer statisCount(Integer commentType) {
    if (commentType == CommentType.LIKE.getType()) {
        return this.likeCount;
    } else if (commentType == CommentType.COMMENT.getType()) {
        return this.commentCount;
    } else {
        return loveCount;
    }
}

讲师在机翻稿里说得很直白:如果在这里写 modify.getLikeCount() / modify.getCommentCount() 的 if-else,太啰嗦,所以封装进实体,调用方一行搞定。

隐藏的第五步风险:modify 可能为 null

save 的最后一行没有判空。如果动态存在,findAndModify 一定能改到文档,modify 不为 null,没问题。但如果动态不存在,第一步的 if (movement != null) 把回填跳过了,落库照做,而 findAndModify 按 id 找不到任何文档会返回 null,紧接着 modify.statisCount(...) 直接抛 NullPointerException。也就是说动态不存在时这个接口不是「悄悄跳过」,而是评论已经写进去了之后才炸。这引出这里最重要的坑。

Comment 实体与 CommentType 枚举

三合一设计靠 commentType 区分,Comment 实体(mf-model 模块)长这样:

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;        //评论内容(仅 commentType=2 时有效)
    private Long userId;           //操作人
    private Long publishUserId;    //被评论人ID(即动态发布人)
    private Long created;          //创建时间
    private Integer likeCount = 0; //当前评论的点赞数

}

对应的类型枚举:

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;
    }
}

与点赞链路的对照

两条链路的骨架完全相同:落一条 comment 记录 + 给 movement 对应计数 $inc +1 ,甚至走的是同一个 save 方法。差异集中在四处:

环节 点赞 评论
commentType 1(LIKE) 2(COMMENT)
content 不设置 必填正文
查重 先 hasComment 查是否已赞,已赞抛异常 不查重,允许连续评论
Redis 状态 写点赞标识 hash,供「点赞标识」接口判断 无任何额外状态
计数目标 likeCount +1 commentCount +1

看出设计意图了吗:「是否需要查重」决定了链路复杂度 。点赞天然幂等(一人一赞),所以要查重、要维护状态位;评论天生允许重复,链路反而是三种互动里最短的一条。这也是为什么评论适合作为理解这条通用链路的最小样本,步骤②把 commentType 换成 1、去掉 content,就是点赞的保存逻辑。

动态点赞复用的是同一个 save 方法,两条链路在 app-server 侧的分叉只在构建 Comment 和额外状态维护上:

对比项 发布评论 动态点赞
HTTP 方式 POST /comments,JSON 请求体 GET /movements/{id}/like,路径参数
commentType COMMENT(2) LIKE(1)
content 字段 有,评论文本 无,为 null
前置校验 无(可重复评论) hasComment 查重,已点赞抛 BusinessException
计数键 commentCount +1 likeCount +1
Redis 状态 不维护 写入 MOVEMENTS_INTERACT_KEY+movementId 的 hash,标记已点赞
返回值利用 仅打日志 直接返回给前端刷新点赞数

点赞的 hasComment 查重逻辑在 CommentApiImpl.hasComment 里,按 userId + publishId + commentType 三条件 exists 查询。发布评论没有这一步,同一人对同一条动态可以评论多次,这是业务语义决定的。落库和计数部分两条链路完全共用 save,所以孤儿评论问题对点赞同样存在。

评论的边界问题

评论的对象:只能评动态吗

项目的实现是平铺评论 :所有评论都直接挂在动态下(publishId 指向 movement),Comment 实体里没有 parentId 之类的字段,「回复某条评论」和「评论动态」在数据上是同一种记录。列表查询也只按 publishId + commentType=2 过滤,不区分层级。

tree 复制代码
交友项目的平铺结构
movement (动态)
├── comment A  (publishId=movement, type=2)
├── comment B  (publishId=movement, type=2)
└── comment C  (publishId=movement, type=2)   // 即使语义上是"回复B",数据上也和B平级

主流产品普遍是楼中楼 (两级评论):评论可以指向动态,回复可以指向评论。要在 Comment 上加一个可空的 parentId(指向被回复的评论 id),列表先查一级、再按 parentId 拉取二级。这是对现有模型向上兼容的扩展,本课程不实现,作为认知储备提出,面试聊「评论系统怎么设计」时是标准展开点。

评论的可见性:动态没了,评论怎么办

评论的 publishId 是指向 movement 的裸引用,MongoDB 没有外键约束。动态被作者删除或审核驳回后,comment 里的记录仍然存在,形成孤儿数据。后果是查询侧必须配套:按动态查评论时先确认动态可见,否则宁可返回空列表也不展示失效评论;孤儿记录靠定期清理任务兜底。项目课程没有处理这条,属于已知简化。

评论的审核:UGC 文本必须过审

评论是最典型的 UGC 文本,广告、辱骂、违规内容都从这里进来。项目在生产语义上接了阿里云内容审核 (文本反垃圾),调用位置在发布链路的参数校验之后、落库之前;具体接入代码放在后台系统章节讲解,这里记住位置即可:审核是发布链路的门卫,不是事后过滤。

孤儿评论问题:动态删了,评论还在

把 save 的第一步和第二步连起来看,就是这里最重要的坑:

java 复制代码
Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
if (movement != null) {                                  // 查不到只是跳过回填
    comment.setPublishUserId(movement.getUserId());
}
mongoTemplate.save(comment);                             // 但落库是无条件的!

if 只包住了 setPublishUserId,save 在判断之外。实测结论:对一条不存在的 movementId 发布评论,评论依然写进 comment 集合 ,只是 publishUserId 为 null。后果:

  • 动态被删除后,已有评论全部变成孤儿数据,详情页查不到动态,但 comment 集合里数据还在,统计口径从此对不上(评论数集合里有、动态上没有)。
  • 对不存在的 id 评论也不会报错给用户,脏数据悄无声息地积累。
  • 更糟的是这种请求会在落库之后才在 modify.statisCount 处抛 NPE,用户看到 500,但评论已经写进去了,重试一次就多一条孤儿。

修法有两条路,治本的是第一条:

java 复制代码
// 修法一:movement 不存在时直接拒绝(推荐,改 save 前两步)
Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
if (movement == null) {
    // 动态不存在(或已被删除),拒绝评论,避免孤儿数据
    throw new BusinessException(ErrorResult.movementNotExist());
}
comment.setPublishUserId(movement.getUserId());

app-server 侧已有 BusinessException 体系(点赞重复时就是抛这个),在 Dubbo 提供方抛出后经 RPC 传回消费方,由全局异常处理器统一转成错误响应,前端拿到明确的「动态不存在或已删除」。

java 复制代码
// 修法二:兜底异步清理,处理已经产生的孤儿数据
// 定时任务扫描 publishUserId 为 null、且对应 movement 已不存在的评论,批量 remove
Query orphanQuery = Query.query(Criteria.where("publishUserId").is(null));
List<Comment> orphans = mongoTemplate.find(orphanQuery, Comment.class);
// 逐条核对 movement 是否真的不存在后再删除,防止误删(回填失败也可能导致 publishUserId 为 null)

修法二是补偿手段,适合线上已有存量孤儿数据的场景;两条不冲突,先堵住入口,再清历史。

流程时序图

MongoDB CommentApiImpl(dubbo) CommentsService CommentsController 客户端 MongoDB CommentApiImpl(dubbo) CommentsService CommentsController 客户端 #mermaid-svg-1iFX10h1VgRRuj4Q{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-1iFX10h1VgRRuj4Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1iFX10h1VgRRuj4Q .error-icon{fill:#552222;}#mermaid-svg-1iFX10h1VgRRuj4Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1iFX10h1VgRRuj4Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1iFX10h1VgRRuj4Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1iFX10h1VgRRuj4Q .marker.cross{stroke:#333333;}#mermaid-svg-1iFX10h1VgRRuj4Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1iFX10h1VgRRuj4Q p{margin:0;}#mermaid-svg-1iFX10h1VgRRuj4Q .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1iFX10h1VgRRuj4Q text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1iFX10h1VgRRuj4Q .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1iFX10h1VgRRuj4Q .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1iFX10h1VgRRuj4Q #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1iFX10h1VgRRuj4Q .sequenceNumber{fill:white;}#mermaid-svg-1iFX10h1VgRRuj4Q #sequencenumber{fill:#333;}#mermaid-svg-1iFX10h1VgRRuj4Q #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1iFX10h1VgRRuj4Q .messageText{fill:#333;stroke:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1iFX10h1VgRRuj4Q .labelText,#mermaid-svg-1iFX10h1VgRRuj4Q .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .loopText,#mermaid-svg-1iFX10h1VgRRuj4Q .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .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-1iFX10h1VgRRuj4Q .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1iFX10h1VgRRuj4Q .noteText,#mermaid-svg-1iFX10h1VgRRuj4Q .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1iFX10h1VgRRuj4Q .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1iFX10h1VgRRuj4Q .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1iFX10h1VgRRuj4Q .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1iFX10h1VgRRuj4Q .actorPopupMenu{position:absolute;}#mermaid-svg-1iFX10h1VgRRuj4Q .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-1iFX10h1VgRRuj4Q .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1iFX10h1VgRRuj4Q .actor-man circle,#mermaid-svg-1iFX10h1VgRRuj4Q line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1iFX10h1VgRRuj4Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /comments (movementId, comment) saveComments(movementId, comment) UserHolder.getUserId() 取登录用户 构建 Comment(commentType=2) save(comment) findById(movementId) movement(回填 publishUserId) save(comment) 落 comment 集合 findAndModify movement.commentCount $inc 1 (returnNew) 更新后的 movement 最新评论数 commentCount log.info(commentCount) void 200 OK

带孤儿分支的版本(标注动态不存在时的行为):
MongoDB CommentApiImpl (Dubbo 提供方) CommentsService CommentsController 客户端 MongoDB CommentApiImpl (Dubbo 提供方) CommentsService CommentsController 客户端 #mermaid-svg-CU9pf7D89MpLWiEV{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-CU9pf7D89MpLWiEV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CU9pf7D89MpLWiEV .error-icon{fill:#552222;}#mermaid-svg-CU9pf7D89MpLWiEV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CU9pf7D89MpLWiEV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CU9pf7D89MpLWiEV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CU9pf7D89MpLWiEV .marker.cross{stroke:#333333;}#mermaid-svg-CU9pf7D89MpLWiEV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CU9pf7D89MpLWiEV p{margin:0;}#mermaid-svg-CU9pf7D89MpLWiEV .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CU9pf7D89MpLWiEV text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CU9pf7D89MpLWiEV .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CU9pf7D89MpLWiEV .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CU9pf7D89MpLWiEV .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CU9pf7D89MpLWiEV .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CU9pf7D89MpLWiEV #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CU9pf7D89MpLWiEV .sequenceNumber{fill:white;}#mermaid-svg-CU9pf7D89MpLWiEV #sequencenumber{fill:#333;}#mermaid-svg-CU9pf7D89MpLWiEV #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CU9pf7D89MpLWiEV .messageText{fill:#333;stroke:none;}#mermaid-svg-CU9pf7D89MpLWiEV .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CU9pf7D89MpLWiEV .labelText,#mermaid-svg-CU9pf7D89MpLWiEV .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CU9pf7D89MpLWiEV .loopText,#mermaid-svg-CU9pf7D89MpLWiEV .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CU9pf7D89MpLWiEV .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-CU9pf7D89MpLWiEV .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CU9pf7D89MpLWiEV .noteText,#mermaid-svg-CU9pf7D89MpLWiEV .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CU9pf7D89MpLWiEV .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CU9pf7D89MpLWiEV .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CU9pf7D89MpLWiEV .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CU9pf7D89MpLWiEV .actorPopupMenu{position:absolute;}#mermaid-svg-CU9pf7D89MpLWiEV .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-CU9pf7D89MpLWiEV .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CU9pf7D89MpLWiEV .actor-man circle,#mermaid-svg-CU9pf7D89MpLWiEV line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CU9pf7D89MpLWiEV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt movement 存在 movement 不存在(孤儿评论坑) POST /comments {movementId, comment} 从 Map 取参(String 强转) saveComments(movementId, comment) UserHolder.getUserId() 取当前用户 构建 Comment(publishId=ObjectId, commentType=2, content, userId, created) save(comment) --- Dubbo RPC findById(publishId) 查 Movement Movement 或 null 回填 publishUserId = movement.userId 跳过回填,不报错 save(comment) 落库 findAndModify(id=publishId, $inc commentCount, returnNew=true) 更新后的 Movement(含最新 commentCount) statisCount(COMMENT) 取最新评论数 Integer commentCount log.info(commentCount) void 200 OK

评论区原型

移动端动态详情进入评论区,一屏结构如下(文案取自原型原文):

doc 复制代码
+--------------------------------------+
|  <- 返回        动态详情              |
+--------------------------------------+
|  [头像]  昵称        刚刚              |
|  动态正文文本......                        |
|  [图片九宫格]                          |
|  ❤ 128    💬 15    ⭐ 6              |
+--------------------------------------+
|  共15条评论                           |
|  ------------------------------------|
|  [头像] 用户A                 刚刚     |
|  评论内容文本......                        |
|  ------------------------------------|
|  [头像] 用户B                 1小时前  |
|  评论内容文本......                        |
|  ------------------------------------|
|          ... 更多评论 ...             |
+--------------------------------------+
|  [ 添加评论 ]              [ 发送 ]   |
+--------------------------------------+

要点:

  • 顶部「共15条评论」:总数来自 movement 的 commentCount 字段,也就是发布链路里 $inc 维护的那个值,计数不准这里就穿帮;
  • 评论条目:头像、昵称、相对时间、正文;这些字段发布时分别落在 UserInfo(头像昵称)、Comment(正文、created)里,查询时再聚合;
  • 底部「添加评论」输入框:对应 POST /comments 的 comment 参数,点击发送触发整条发布链路;
  • 「💬 15」角标与「共15条评论」同源,都是 commentCount。

API 速览

接口 方法 参数 返回
/comments POST movementId:动态 id;comment:评论正文 200,无业务数据(评论数仅记日志)

测试验证

发布评论的三层代码(Controller → Service → API)写完只是「看起来能跑」,这一节专注一件事:怎么证明它真的能跑、跑对了。围绕 POST /comments 给出一套可复现的验证方案,先单测打断点看清每一步数据变化,再上 Postman 走端到端,用 mongosh 对 comment、movement 双集合核对,最后主动制造一次「动态被删后评论」的故障,把这个工程里真实存在的孤儿数据缺陷暴露出来并练习修复。

测试前先想清楚:要验证什么

发布评论是一条典型的「两步写」链路:先写 comment 明细集合,再 findAndModify 给 movement 集合的 commentCount 加一。任何一步出错,页面表现可能都一样(接口报错或数字不对),所以测试清单必须覆盖到每一个副作用:

检查项 怎么验 预期 不通过说明什么
接口连通 Postman 发 POST /comments 返回 200 参数名不对(comment 而非 content)、token 失效、服务没起
comment 落库 mongosh 查 comment 集合 新增一条记录 Dubbo 调不通、save 异常
字段正确性 核对 commentType、publishId、userId、content commentType=2、publishId 等于目标动态 id、userId 是登录人 构造 Comment 时设错字段
publishUserId 回填 核对该字段值 等于动态主人的 userId,不是评论人 movement 查不到(孤儿信号)或回填逻辑被跳过
计数联动 mongosh 查 movement.commentCount 在原值上 +1 findAndModify 条件没命中、$inc 字段名写错
重复发评论 连发两条 计数 1 → 2 累加 计数是「设置」而非「累加」
孤儿评论 删掉 movement 后再评论 应被拒绝(当前实现:仍落库,属缺陷) 引用完整性没有校验
content 校验 发空串、超长串 应被拦截(当前实现:照单全收) 缺少参数校验

前六项验证「正常路径」,后两项故意把系统逼到边界上,这两类实验的价值往往比正向验证大得多。

第一轮:API 层单元测试

为什么先测 API 层而不是直接开客户端?因为客户端要评论,得先「根据 ID 查动态详情」、再「分页查评论列表」,最后才轮到发布,任意一个接口没实现,页面直接卡死进不去。分层测试能把问题域收窄:这轮只验证 Dubbo 服务 CommentApiImpl#save 的落库与计数逻辑。

工程里已备好测试类 mf-app-server/src/test/java/com/wb/test/CommentApiTest.java:

java 复制代码
package com.wb.test;

import com.mf.dubbo.api.CommentApi;
import com.mf.model.enums.CommentType;
import com.mf.model.mongo.Comment;
import com.mf.server.AppServerApplication;
import org.apache.dubbo.config.annotation.DubboReference;
import org.bson.types.ObjectId;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class CommentApiTest {

    @DubboReference
    private CommentApi commentApi;

    @Test
    public void testSave() {
        Comment comment = new Comment();
        comment.setCommentType(CommentType.COMMENT.getType());
        comment.setUserId(106l);
        comment.setCreated(System.currentTimeMillis());
        comment.setContent("测试评论");
        comment.setPublishId(new ObjectId("5e82dc3e6401952928c211a3"));
        commentApi.save(comment);
    }
}

publishId 从 movement 集合里挑一条真实数据的 _id 复制过来,这一步不能凭空编,ObjectId 虽然格式合法,但库里查不到对应动态就会触发后面要讲的孤儿场景。

在 CommentApiImpl#save 打断点,debug 启动测试,按链路逐段核对:

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 Integer save(Comment comment) {
        //1、查询动态
        Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
        //2、向comment对象设置被评论人属性
        if(movement != null) {
            comment.setPublishUserId(movement.getUserId());
        }
        //3、保存到数据库
        mongoTemplate.save(comment);
        //4、更新动态表中的对应字段
        Query query = Query.query(Criteria.where("id").is(comment.getPublishId()));
        Update update = new Update();
        if(comment.getCommentType() == CommentType.LIKE.getType()) {
            update.inc("likeCount",1);
        }else if (comment.getCommentType() == CommentType.COMMENT.getType()){
            update.inc("commentCount",1);
        }else {
            update.inc("loveCount",1);
        }
        //设置更新参数
        FindAndModifyOptions options = new FindAndModifyOptions();
        options.returnNew(true) ;//获取更新后的最新数据
        Movement modify = mongoTemplate.findAndModify(query, update, options, Movement.class);
        //5、获取最新的评论数量,并返回
        return modify.statisCount(comment.getCommentType() );
    }
}

断点处重点看三个变量:

  1. 第 2 步查出的 movement 对象:其 userId(动态主人,比如 1 号用户)即将被回填到 comment.publishUserId,注意这不是评论人 106。
  2. 第 4 步更新前的 movement.statisCount():commentCount 是旧值(0)。
  3. findAndModify 返回的 modify 对象:因为设了 returnNew(true),拿到的是更新后 的副本,commentCount 已变成 1。

断点走完,回到数据库验证。这里有个容易忽略的细节:为什么用 findAndModify 而不是惯用的 update?update 只改数据、没有返回值,想拿最新计数还得再按 id 查一次;findAndModify 一步完成「更新 + 拿回最新对象」,省掉一次往返。

第二轮:Postman 端到端验证

单测过了只说明 Dubbo 层没问题,HTTP 入口到 Service 的拼装还得再验一遍。端到端走四步:

准备测试数据

登录拿到 token(工程预置 106 号测试用户),然后复用发布动态接口发一条新动态,从响应或 movement 集合里取出这条动态的 _id。用「刚发的动态」做被评论对象,计数从 0 开始,核对最直观。

发评论
http 复制代码
POST /comments HTTP/1.1
Host: localhost:18080
Authorization: Bearer <token>
Content-Type: application/json

{
    "movementId": "5e842e.....",
    "comment": "Java 从入门到放弃"
}

两个参数名是硬约束,来自 Controller 里从 Map 手动取值的方式:

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

写成 content 会取到 null,后面 new ObjectId(null) 直接抛 NPE,这是本接口最常见的 400/500 来源之一。

mongosh 双集合核对

Service 层执行完会打一行日志 commentCount = 1,日志只是旁证,最终以数据库为准:

javascript 复制代码
// 核对 comment 集合:明细是否落库、字段是否全对
db.comment.find({
    publishId: ObjectId("5e842e....."),
    commentType: 2
}).pretty()
// 预期一条记录:userId=106(评论人)、publishUserId=1(动态主人)、
// content="Java 从入门到放弃"、created 为当前时间戳

// 核对 movement 集合:计数是否联动
db.movement.find(
    { _id: ObjectId("5e842e.....") },
    { commentCount: 1 }
).pretty()
// 预期 commentCount: 1

明细和计数是两个集合里的两份数据,只查一个等于只验了一半。

验证计数累加

再发一条不同内容的评论,重新查 movement:

javascript 复制代码
db.movement.find(
    { _id: ObjectId("5e842e.....") },
    { commentCount: 1 }
).pretty()
// 预期 commentCount: 2

这一步专门验证 $inc 是「在旧值上累加」。如果实现误写成了 update.set("commentCount", 1) 之类,第一次测试发现不了,到这里就露馅了。

补充:发布后双集合快速核对

步骤分析篇给过一份更轻量的发布后核对清单,这里一并收进来。发布一条评论后,用 mongosh 按顺序核对两处数据:

javascript 复制代码
// 1. comment 集合应有新记录,commentType 为 2
db.comment.find({ publishId: ObjectId("<movementId>"), commentType: 2 })
  .sort({ created: -1 }).limit(1)
// 核对:content 有正文、userId 是当前登录用户、publishUserId 是动态作者

// 2. movement 的 commentCount 应比发布前 +1
db.movement.find({ _id: ObjectId("<movementId>") }, { commentCount: 1 })

再调一次评论列表接口 GET /comments?movementId=...,新评论应排在第一页首位(按 created 倒序)。这份清单和上面 Postman 端到端验证同构,差别只在前者是步骤分析阶段给出的纯数据库视角核对,后者走的是完整 HTTP 入口。

边界输入实验:校验缺失的实证

正常路径验完,故意喂脏数据:

输入 实际行为 期望行为
comment: "" 空评论落库,计数 +1 拒绝,返回参数错误
comment 缺失 new ObjectId(null) → NPE → 500 400 参数错误
comment: 一万字的字符串 原样落库 长度上限拦截
movementId 是库里不存在的合法 ObjectId 见下一节 拒绝

根因在两处:CommentsController 直接从 Map 取值不做判空,CommentsService.saveComments 拿到 content 不做长度校验就往下传。MongoDB 没有表结构约束,字符串多长都能存,校验缺了就是真的缺了,不会有人替你兜底。

孤儿评论实验:主动制造一次引用断裂

这是这里最值钱的实验。CommentApiImpl#save 第 2 步用 if(movement != null) 包住回填逻辑,动态查不到时只是跳过回填,流程照走:
#mermaid-svg-Xxz9Lzl8UnvSzdTe{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-Xxz9Lzl8UnvSzdTe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .error-icon{fill:#552222;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .marker.cross{stroke:#333333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe p{margin:0;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster-label text{fill:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster-label span{color:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster-label span p{background-color:transparent;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .label text,#mermaid-svg-Xxz9Lzl8UnvSzdTe span{fill:#333;color:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .node rect,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node circle,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node ellipse,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node polygon,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .rough-node .label text,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node .label text,#mermaid-svg-Xxz9Lzl8UnvSzdTe .image-shape .label,#mermaid-svg-Xxz9Lzl8UnvSzdTe .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .rough-node .label,#mermaid-svg-Xxz9Lzl8UnvSzdTe .node .label,#mermaid-svg-Xxz9Lzl8UnvSzdTe .image-shape .label,#mermaid-svg-Xxz9Lzl8UnvSzdTe .icon-shape .label{text-align:center;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .node.clickable{cursor:pointer;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .arrowheadPath{fill:#333333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xxz9Lzl8UnvSzdTe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xxz9Lzl8UnvSzdTe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster text{fill:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .cluster span{color:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe 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-Xxz9Lzl8UnvSzdTe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xxz9Lzl8UnvSzdTe rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .icon-shape,#mermaid-svg-Xxz9Lzl8UnvSzdTe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .icon-shape p,#mermaid-svg-Xxz9Lzl8UnvSzdTe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .icon-shape .label rect,#mermaid-svg-Xxz9Lzl8UnvSzdTe .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xxz9Lzl8UnvSzdTe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xxz9Lzl8UnvSzdTe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xxz9Lzl8UnvSzdTe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查到
查不到
POST /comments
findById 查动态
回填 publishUserId
save 落库
findAndModify commentCount+1
返回最新计数 200
跳过回填 publishUserId=null
save 落库:孤儿评论
findAndModify 返回 null
modify.statisCount 抛 NPE → 500

复现步骤:

javascript 复制代码
// 1. 记下要删的动态 id,确认当前 commentCount
db.movement.find({ _id: ObjectId("5e842e.....") }, { commentCount: 1 }).pretty()

// 2. 删除这条动态
db.movement.remove({ _id: ObjectId("5e842e.....") })

然后用 Postman 向这条「已不存在」的动态发评论,观察结果:

javascript 复制代码
// 3. 评论竟然落库了,而且 publishUserId 字段缺失
db.comment.find({ publishId: ObjectId("5e842e.....") }).pretty()

一次请求同时暴露了三个缺陷:

  1. 孤儿评论 :movement 已删,评论仍写进 comment 集合,且 publishUserId 永远是 null,列表页查到这条评论时既不知道该通知谁,用户信息也拼接不出来。
  2. 两步写非原子 :第一步 save 成功、第二步 findAndModify 落空,中间没有事务,脏数据已经产生。
  3. 误报 500 :modify.statisCount(...) 对 null 解引用抛 NPE,客户端收到 500,但数据其实已经写进去了一半,重试还会再写一条孤儿。
修复练习

缺陷转任务,两处可下手:

改法一(推荐,入口拦截) :CommentApiImpl#save 开头查不到动态就拒绝,把「跳过」改成「熔断」:

java 复制代码
//1、查询动态
Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
//2、动态不存在,拒绝评论(防止孤儿数据)
if(movement == null) {
    throw new IllegalStateException("被评论的动态不存在: " + comment.getPublishId());
}
//3、向comment对象设置被评论人属性
comment.setPublishUserId(movement.getUserId());
//4、保存到数据库
mongoTemplate.save(comment);
// 后续计数更新不变

IllegalStateException 也可以换成工程自定义的业务异常,让全局异常处理器翻译成友好的错误码;dubbo-mongo 模块里没有 BusinessException 类,简单处理直接抛运行时异常即可。注意 movement != null 的 if 块去掉后,回填逻辑必须在判空之后无条件执行。

改法二(补充,存量清理) :入口拦住了新数据,历史上已产生的孤儿还得清。写一个定时任务扫描 publishUserId 为 null 的评论,反查 movement,查不到的批量删除:

javascript 复制代码
// 清理前先预览孤儿数据
db.comment.find({ publishUserId: { $exists: false } }).count()

修复后怎么验证 :重跑一遍实验,删动态 → 发评论 → 预期收到业务报错,db.comment.find({publishId: ...}) 无新增记录,commentCount 也不变。原来那条 NPE 500 也随之消失,因为 modify 为 null 的路径根本走不到了。

顺手把 content 校验也补上(同在 saveComments 里,判空 + 长度上限),边界输入表里的前两行就从「实证缺陷」变成「已验证通过」。

排错速查表

现象 原因 排查
请求 400 / 参数为 null body 参数名写成 content,Controller 取的是 comment 对照 Controller 的 map.get("comment") 检查 JSON key
发评论直接 500,日志 NPE 指向 statisCount movement 查不到,findAndModify 返回 null,孤儿评论的伴生信号 用请求里的 movementId 去 db.movement.find 确认动态是否存在
接口 200 但 comment 没落库 Dubbo 调用失败被吞、save 异常 看 mf-dubbo-mongo 控制台日志,单独跑 CommentApiTest 定位层
接口 200、评论落库,但 commentCount 没加 findAndModify 的查询条件没命中(id 类型不对,误传 String) 确认 publishId 是 ObjectId 而非字符串;直接 mongosh 手动 $inc 修复脏数据
评论落库但 publishUserId 缺失 movement 查不到导致跳过回填,孤儿评论的信号 同上,先查动态是否存在;存在则检查回填代码是否被误删
commentCount 不累加、一直是 1 实现误用 set 而非 $inc 看 Update 构造代码,确认 update.inc("commentCount",1)

可复现验证:curl + mongosh 全流程

前提:mf-app-server、mf-dubbo-mongo 已启动,MongoDB 可连接。

bash 复制代码
# 1、发布评论(token 换成有效登录态,movementId 换成真实动态 id)
curl -X POST http://localhost:18080/comments \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"movementId":"636a1b2c3d4e5f6a7b8c9d0e","comment":"这篇动态写得不错"}'
bash 复制代码
# 2、mongosh 查 comment 集合,确认 commentType=2、publishUserId 已回填
mongosh --quiet --eval '
db.comment.find(
  { publishId: ObjectId("636a1b2c3d4e5f6a7b8c9d0e"), commentType: 2 },
  { content: 1, userId: 1, publishUserId: 1, created: 1 }
).sort({ created: -1 }).limit(5)'

# 3、查 movement 的 commentCount,应比发布前多 1
mongosh --quiet --eval '
db.movement.find(
  { _id: ObjectId("636a1b2c3d4e5f6a7b8c9d0e") },
  { commentCount: 1 }
)'
bash 复制代码
# 4、复现孤儿评论:先删除该动态,再对同一 movementId 发评论
mongosh --quiet --eval 'db.movement.deleteOne({ _id: ObjectId("636a1b2c3d4e5f6a7b8c9d0e") })'

curl -X POST http://localhost:18080/comments \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"movementId":"636a1b2c3d4e5f6a7b8c9d0e","comment":"对已删除动态的评论"}'

# 5、确认 comment 集合里多了一条 publishUserId 为 null 的记录(孤儿评论实锤)
mongosh --quiet --eval '
db.comment.find(
  { publishId: ObjectId("636a1b2c3d4e5f6a7b8c9d0e") },
  { content: 1, publishUserId: 1 }
)'
# 预期输出:两条记录,第二条 content 为"对已删除动态的评论",publishUserId 为 null

发布评论的可复用模式与坑位汇总

「明细 + 计数」两步写的通用结构

评论链路最核心的结构是两步写:互动明细落 comment 集合 + 动态表计数 $inc 。这不是评论独有的设计,同章的点赞、喜欢用的是同一个 CommentApiImpl.save() 方法,CommentType 枚举(1-点赞 / 2-评论 / 3-喜欢)区分类型,Movement.statisCount() 按类型取对应的计数字段,仅此而已。

java 复制代码
public Integer save(Comment comment) {
    // 1、查询动态
    Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
    // 2、向 comment 对象回填被评论人属性
    if (movement != null) {
        comment.setPublishUserId(movement.getUserId());
    }
    // 3、明细落库
    mongoTemplate.save(comment);
    // 4、按类型对动态计数 $inc,返回更新后的最新数量
    Query query = Query.query(Criteria.where("id").is(comment.getPublishId()));
    Update update = new Update();
    if (comment.getCommentType() == CommentType.LIKE.getType()) {
        update.inc("likeCount", 1);
    } else if (comment.getCommentType() == CommentType.COMMENT.getType()) {
        update.inc("commentCount", 1);
    } else {
        update.inc("loveCount", 1);
    }
    FindAndModifyOptions options = new FindAndModifyOptions();
    options.returnNew(true); // 返回更新后的最新文档
    Movement modify = mongoTemplate.findAndModify(query, update, options, Movement.class);
    return modify.statisCount(comment.getCommentType());
}

findAndModify 配合 returnNew(true) 是这里的点睛之笔:更新完直接拿到最新计数返回给前端,省掉一次回查。机翻稿里把方法名转成了「findandmultify」,实际是 findAndModify。

计数联动非原子的取舍

save(comment) 和 findAndModify 是两次独立的数据库操作,中间失败就会出现明细有、计数无的偏差。课程的处理方式是接受这个偏差 :计数只是冗余的加速字段,明细才是事实来源,必要时按 comment 集合重算一遍就能修复。这是典型的最终一致思路,互动场景下计数偶尔差 1 可容忍,为此引入事务或分布式锁得不偿失。但前提是你要清楚「以明细为准、计数可重算」这条兜底原则,否则偏差会越积越多。

校验缺失的代价

链路里有两处校验是缺的,而且缺陷会连锁:

  • movement 不存在未拒绝 。if (movement != null) 只挡住了 publishUserId 的回填,movement 查不到时评论照样 save 落库,这就是孤儿评论。
  • 孤儿评论引爆 NPE 。movement 不存在时 findAndModify 返回 null,下一行 modify.statisCount(...) 直接空指针。一个不合法的 movementId 不会安静失败,而是 500 报错。
  • content 无长度校验。前端传什么存什么,一条 10 万字的评论也能进库,列表查询时整段拉出来,浪费带宽和内存。

互动操作通用流程表

三种互动的步骤完全同构,差别只在类型和计数键。拿这张表做「喜欢」功能,基本不用回看代码:

步骤 点赞 评论 喜欢
构造 Comment,commentType 取值 CommentType.LIKE(1) CommentType.COMMENT(2) CommentType.LOVE(3)
content 字段 不设置 必填 不设置
明细落库(comment 集合) save save save
计数 $inc 目标 likeCount commentCount loveCount
返回最新计数 statisCount(1) statisCount(2) statisCount(3)
额外逻辑 先 hasComment 判重,已点赞则拒绝 无 先判重,同点赞
Redis 辅助 记录点赞/喜欢历史,列表页判断状态 不需要 记录历史

坑位汇总

坑 现象 根因
孤儿评论 movement 已删除,评论仍写入 comment 集合 save() 前只回填、不拒绝,movement 为 null 也继续执行
statisCount NPE 传不存在的 movementId 直接 500 findAndModify 返回 null 后未判空就调 statisCount
content 无校验 空串、超长文本照单全收 Controller/Service 均未做非空与长度检查
commentType 硬编码 计数分支里 if/else 手写三段 虽有 CommentType 枚举,但 $inc 的字段名映射没有收敛到枚举里
Map 接参 (String) map.get(...) 强转 参数无 DTO,类型错了运行期才爆,编译期无感知
content 无长度校验 超长文本原样入库,拖累评论列表查询 加 DTO + @Length(max = 500) 之类校验
两步写非原子 save 成功后若计数更新失败,评论数与评论列表不一致;无事务回滚 生产上可用 MongoDB 4.0+ 多文档事务,或接受短暂不一致 + 定时对账
findAndModify 返回值未判空 动态不存在时 NPE 修法一落地后自然消除;防御式写法可加 if (modify == null) return 0

练习建议

四个改动都不大,但每个都能加深对链路的理解:

  • 修孤儿评论 :改 CommentApiImpl.save(),movement == null 时抛业务异常(如 ParameterException("动态不存在")),同时删掉原来的 if 包裹。验证:用 Postman 传一个不存在的 movementId,应返回业务异常提示,且 comment 集合无新文档。
  • content 加长度校验 :在 CommentsService.saveComments() 入口检查 StringUtils.isBlank(content) 或 length() > 500 时抛异常。验证:分别提交空内容和 501 字评论,均被拒绝;500 字以内正常落库。
  • commentType 改枚举收敛 :给 CommentType 增加 getCountKey() 方法返回对应计数键("likeCount" / "commentCount" / "loveCount"),把 save() 和 delete() 里的 if/else 各压成一行 update.inc(comment.getCountKey(), ±1)。验证:点赞、评论、喜欢三种操作后计数均正确变化,行为与改造前一致。
  • 加评论频控 :在 CommentsService 里用 Redis setIfAbsent 记录 COMMENT_FREQ:{userId}:{movementId},1 分钟过期,命中则拒绝。验证:1 分钟内连发两条同动态评论,第二条被拒;过期后再发成功。

异常与边界

场景 现状 建议处理
movementId 不存在 / 格式非法 ObjectId 解析失败直接抛错;解析通过但动态已删时,代码仍会落库,只是不回填 publishUserId 落库前校验动态存在且可见,不存在返回业务错误
内容为空 / 纯空白 未校验,空串直接入库 参数校验拦截,返回「评论内容不能为空」
内容超长 未限制 设置长度上限(如 500 字),超出拒绝
敏感词命中 课程工程未接入 发布前过阿里云内容审核,命中拒绝或转人工(见后台系统章节)
落库成功但计数失败 save 与 findAndModify 是两次操作,非原子:计数可能漏加,或进程中断在两步之间 与点赞篇同源问题;低频场景可接受,严格场景用事务或对账修复
重复快速提交 不查重,连点会发多条相同评论 前端防抖 + 后端短时间窗去重

常见误解澄清

误解 事实
评论和点赞存在不同的表 同一张 comment 集合,靠 commentType(1/2/3)区分,保存/计数共用一个方法
点赞、评论、喜欢各需要一张表 只有一张 comment 集合,靠 commentType 区分类型,代码里也只有一套保存方法
发布评论会返回评论总数给前端 接口文档定义返回 200 空体;工程里算出的最新评论数只打了日志
发布评论需要返回最新评论数给前端 接口约定只返回状态 200;save 的返回值是为点赞/喜欢准备的,评论场景只打了日志
评论可以回复评论(楼中楼) 本课程是平铺评论,全部挂在动态下,无 parentId;楼中楼是扩展方案
movement 查不到时不会保存评论 工程代码只跳过 publishUserId 回填,评论照样落库,动态存在性校验实际是缺失的
movement 查不到时代码会自动跳过保存 只跳过了 publishUserId 回填,评论照样落库,随后还会因 modify 为 null 抛 NPE
movement 查不到时 save 会拒绝评论 实读代码是 if (movement != null) 只包住回填,save 无条件执行,评论照样落库
计数 +1 和保存评论是一个原子操作 是两条独立 MongoDB 命令,非原子;异常中断会造成计数与实际条数不一致
敏感词过滤在这条链路里实现 课程工程的发布链路未接入;生产上审核位于落库之前,实现在后台系统章节
publishUserId 是评论人的 id 不是。它是被评论的动态主人 的 userId,从 movement 上反查回填;评论人存在 userId 字段里
publishUserId 是 Service 层设置的 Service 层故意不设,由 API 层查动态后回填,为的是和计数更新共用一次查询
findAndModify 返回的就是更新前的数据 默认返回旧文档,但这里显式设了 returnNew(true),拿到的是加一后的新文档
commentCount 是实时 count 出来的 不是,它是 movement 文档上的冗余计数字段,靠 $inc 维护,删评论时要记得 -1
Criteria.where("id") 匹配不到 _id Spring Data MongoDB 自动把 id 映射到 _id 并转换 ObjectId,能正常匹配
评论和点赞是两套存储逻辑 同一张 comment 集合、同一个 save 方法,仅 commentType 和计数键不同
接口返回 200 就说明评论写成功了 200 只代表 Service 正常返回。落库、计数两个副作用要用 mongosh 核实,孤儿评论实验里甚至出现过「返回 500 但评论已落库」的错位
MongoDB 没有外键,引用完整性不用管 恰恰相反。没有数据库层约束,全靠应用层自查,漏检就直接产生脏数据
单测和 Postman 二选一就行 验证的东西不同:单测验证 Dubbo 层落库与计数逻辑,能打断点看中间变量;Postman 验证 HTTP 入口、参数解析、token 链路。省掉任何一个都有盲区
拿不到最新计数可以更新完再 findOne 查一次 可行但多一次往返。findAndModify 配 returnNew(true) 原子地完成「更新 + 取回最新文档」,一次交互搞定
点赞/喜欢功能要另写一套测试 不用。save 是三种类型通用的:点赞是 commentType=1 + likeCount,喜欢是 commentType=3 + loveCount,把这里清单里的类型和计数字段换掉即可复用
comment 集合名叫 quanzi_comment 那是讲义里的示意说法,以 @Document(collection = "comment") 注解为准,实际集合名是 comment
$inc + findAndModify 保证了明细与计数的原子性 两者是独立操作,只在单文档计数层面原子;两步之间失败仍会偏差,靠「计数可重算」兜底

官方文档

总结

  • 发布评论的五步链路:收参数(含内容校验)→ 构建 Comment(commentType=2、带正文)→ 落 comment 集合 → movement 的 commentCount 用 findAndModify $inc → 返回。它与点赞共享同一套保存骨架,差异只有三点:类型值不同、多一个 content、少一套查重和 Redis 状态,评论反而是链路最短的互动。
  • publishUserId 回填放在 API 层的设计动机,与计数更新共用一次 movement 查询;实读确认的孤儿评论问题,if (movement != null) 没有把 save 包进去,动态不存在时评论照落、NPE 后炸。上线前把这个校验前置成 BusinessException,比事后清理数据便宜得多。
  • 三个边界要有数:平铺评论无 parentId(楼中楼是加字段可扩展的)、动态删除后评论成孤儿数据、UGC 文本的审核在落库之前由内容审核服务承担。
  • 发布评论是「明细 + 计数」两步写,验证必须双集合核对:comment 查明细字段,movement 查 commentCount 联动,只查一个等于只验了一半;引用完整性要主动测,删掉 movement 再发评论一次实验同时暴露孤儿落库、两步写非原子、null 解引用 500 三个缺陷。
  • 这套「明细 + 计数」的两步写骨架,同章的点赞、喜欢,乃至日后任何互动功能,都是它的变体;而它自带的两个软肋(计数非原子、校验缺失)也正是面试时最常被追问的地方。
相关推荐
屈冠成1 小时前
ShopXO商城系统测试报告
开发语言·spring·maven·lua
多弗朗皮卡丘1 小时前
C++STL之空间配置器
开发语言·c++·内存池·空间配置器
tommyjiatong1 小时前
什么是PHP漏洞
开发语言·php
CodeYp20191 小时前
第19讲:封装红黑树实现 mymap 和 myset
开发语言·c++·红黑树
qq_364682151 小时前
录音APP怎么切换语言?多语言版本如何使用
java·服务器·前端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 用户体系集成:根据环信id查询用户详情
java·lua·交友
摇滚侠2 小时前
《On Java 中文版 基础卷》阅读笔记 复用 13
java·笔记·python
2601_968900772 小时前
玻璃深加工的工艺窗口:4 道工序的温度与时间参数边界
android·java·javascript
薛之谦_2 小时前
用 Java 实现阿里云式 IAM 子用户管理:架构设计与表结构详解
java·python·阿里云