概述
评论和点赞同属圈子互动,但评论是「带文本内容的互动」:它要在 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() );
}
}
断点处重点看三个变量:
- 第 2 步查出的
movement对象:其userId(动态主人,比如 1 号用户)即将被回填到comment.publishUserId,注意这不是评论人 106。 - 第 4 步更新前的
movement.statisCount():commentCount是旧值(0)。 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()
一次请求同时暴露了三个缺陷:
- 孤儿评论 :movement 已删,评论仍写进
comment集合,且publishUserId永远是 null,列表页查到这条评论时既不知道该通知谁,用户信息也拼接不出来。 - 两步写非原子 :第一步
save成功、第二步findAndModify落空,中间没有事务,脏数据已经产生。 - 误报 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里用 RedissetIfAbsent记录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 保证了明细与计数的原子性 | 两者是独立操作,只在单文档计数层面原子;两步之间失败仍会偏差,靠「计数可重算」兜底 |
官方文档
- Spring Data MongoDB - MongoTemplate
- MongoDB - findAndModify 命令
- MongoDB - $inc 操作符
- Apache Dubbo - 服务提供者注解 @DubboService
- MongoDB Shell (mongosh) - CRUD 操作
- MongoDB - $inc 运算符
总结
- 发布评论的五步链路:收参数(含内容校验)→ 构建 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 三个缺陷。 - 这套「明细 + 计数」的两步写骨架,同章的点赞、喜欢,乃至日后任何互动功能,都是它的变体;而它自带的两个软肋(计数非原子、校验缺失)也正是面试时最常被追问的地方。