概述
圈子的第三种互动「喜欢」是点赞的同构操作:同样的四步流程、同一张 Comment 表,只把 commentType 从 1 换成 3、hashKey 从 LIKE 换成 LOVE。这里讲清这种同构怎么复用,以及复制粘贴时最容易踩的那个坑,常量漏改(工程里真实存在,读侧 hasLiked 就被它坑了)。
纲要
- 业务语义:喜欢与点赞的产品差异
- 实现结构:接口、Service 方法、Redis 标记与点赞的逐项对照
- 核心代码:
loveComment/disloveComment(与点赞同构,只看差异点) - 复用链路:
CommentApiImpl.save/delete/hasComment按 commentType 分流的公共逻辑 - 时序图:喜欢与取消喜欢的完整数据流
- 工程实证:hasLiked 读侧用错 hashKey 的 bug 与修法
- 同构操作的工程启示:三合一设计下「加一种互动类型」的成本
- 坑表、验证步骤、常见误解澄清
业务语义:喜欢和点赞差在哪
动态列表每条动态下方有三个互动按钮:点赞、评论、喜欢。讲师在视频里说得很直白,喜欢和点赞「除了状态不一致,剩下都一样」,代码层面确实如此。但从产品角度,两者语义不同:
| 维度 | 点赞 | 喜欢 |
|---|---|---|
| 用户心理 | 轻量认可,「这条不错」 | 更强的偏好表达,「我对这条(发布者)感兴趣」 |
| 交互成本 | 随手一点,可反复切换 | 表态更重,列表里单独一类记录 |
| 数据落点 | commentType=1,累计 likeCount |
commentType=3,累计 loveCount |
有一个容易混淆的点要先澄清:交友项目里有两套「喜欢」。交友模块左滑右滑的「喜欢」是用户对用户 的表态,存在 USER_LIKE_SET_ 这套 Redis key 里,互相喜欢触发配对;这里圈子互动的「喜欢」是用户对动态 的表态,落在 Comment 表(commentType=3)。讲义和本章工程里没有把动态「喜欢」接入配对逻辑,所以这里的差异只到产品语义层面,不要把两套机制混为一谈。
与点赞的同构对照
先给对照表,这是这里的核心。每一处差异都只是一个枚举值或一个常量:
| 对照项 | 点赞 | 喜欢 |
|---|---|---|
| 接口路径 | GET /movements/{id}/like |
GET /movements/{id}/love |
| 取消接口 | GET /movements/{id}/dislike |
GET /movements/{id}/unlove |
| Controller 方法 | like() / dislike() |
love() / unlove() |
| Service 方法 | likeComment() / dislikeComment() |
loveComment() / disloveComment() |
| 互动类型 | CommentType.LIKE(1) |
CommentType.LOVE(3) |
| 计数字段 | likeCount |
loveCount |
| Redis hashKey | MOVEMENT_LIKE_ + userId |
MOVEMENT_LOVE_ + userId |
| 重复操作异常 | likeError()(000006 用户已点赞) |
loveError()(000008 用户已喜欢) |
| 未操作取消异常 | disLikeError()(000007 用户未点赞) |
disloveError()(000009 用户未喜欢) |
这里改动涉及的文件分布(工程快照 05-圈子互动/01-代码/mf):
tree
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller/MovementController.java # 新增 /love、/unlove 接口
│ └── service
│ ├── CommentsService.java # 新增 loveComment、disloveComment
│ └── MovementService.java # 读侧 hasLiked(bug 所在)
├── mf-dubbo/mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ └── CommentApiImpl.java # save/delete/hasComment 公共链路,零改动
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── enums/CommentType.java # LOVE(3) 枚举值
│ ├── mongo/Comment.java # 三合一互动表
│ ├── mongo/Movement.java # loveCount 计数 + statisCount 分流
│ └── vo/ErrorResult.java # loveError / disloveError
└── mf-commons
└── src/main/java/com/mf/commons/utils
└── Constants.java # MOVEMENT_LOVE_HASHKEY 常量
主 key 是同一个:MOVEMENTS_INTERACT_ + movementId。点赞和喜欢的状态存在同一个 Hash 里,靠 hashKey 前缀区分。枚举定义在 CommentType 中:
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;
}
}
接口层:Controller 两个方法
mf-app-server 模块的 MovementController,在点赞接口旁边追加两个方法:
java
/**
* 喜欢
*/
@GetMapping("/{id}/love")
public ResponseEntity love(@PathVariable("id") String movementId) {
Integer likeCount = commentsService.loveComment(movementId);
return ResponseEntity.ok(likeCount);
}
/**
* 取消喜欢
*/
@GetMapping("/{id}/unlove")
public ResponseEntity unlove(@PathVariable("id") String movementId) {
Integer likeCount = commentsService.disloveComment(movementId);
return ResponseEntity.ok(likeCount);
}
返回值变量名叫 likeCount,实际存的是 loveCount,这是从点赞代码复制过来没改变量名的痕迹。不影响功能(返回给前端的数值是对的),但读代码的人容易误会,复制同构代码时这类命名残留值得顺手清理。
Service 层:loveComment 与 disloveComment
CommentsService 中的实现。结构与 likeComment 完全相同,只标注差异点,不逐行重复注释:
java
//喜欢
public Integer loveComment(String movementId) {
//1、判重:查该用户对该动态是否已有 type=3 的记录
Boolean hasComment = commentApi.hasComment(movementId, UserHolder.getUserId(), CommentType.LOVE);
//2、已喜欢则抛异常
if(hasComment) {
throw new BusinessException(ErrorResult.loveError());
}
//3、保存 type=3 的 Comment 到 MongoDB
Comment comment = new Comment();
comment.setPublishId(new ObjectId(movementId));
comment.setCommentType(CommentType.LOVE.getType());
comment.setUserId(UserHolder.getUserId());
comment.setCreated(System.currentTimeMillis());
Integer count = commentApi.save(comment);
//4、写 Redis 标记:hashKey 用 LOVE 前缀,主 key 与点赞共用
String key = Constants.MOVEMENTS_INTERACT_KEY + movementId;
String hashKey = Constants.MOVEMENT_LOVE_HASHKEY + UserHolder.getUserId();
redisTemplate.opsForHash().put(key, hashKey, "1");
return count;
}
//取消喜欢
public Integer disloveComment(String movementId) {
//1、判重:未喜欢时取消没有意义,直接拒绝
Boolean hasComment = commentApi.hasComment(movementId, UserHolder.getUserId(), CommentType.LOVE);
if(!hasComment) {
throw new BusinessException(ErrorResult.disloveError());
}
//2、删除 type=3 的 Comment,并拿回最新计数
Comment comment = new Comment();
comment.setPublishId(new ObjectId(movementId));
comment.setCommentType(CommentType.LOVE.getType());
comment.setUserId(UserHolder.getUserId());
Integer count = commentApi.delete(comment);
//3、删掉 Redis 里的 LOVE 标记
String key = Constants.MOVEMENTS_INTERACT_KEY + movementId;
String hashKey = Constants.MOVEMENT_LOVE_HASHKEY + UserHolder.getUserId();
redisTemplate.opsForHash().delete(key, hashKey);
return count;
}
四步骨架:判重 → 落库 → 计数 → Redis 标记。和点赞唯一的区别集中在三处:CommentType.LOVE、MOVEMENT_LOVE_HASHKEY、loveError()/disloveError()。
公共链路:CommentApi 按 type 分流
Service 之下不用写任何新代码,CommentApi 的三个方法是三种互动共用的。这就是「三合一」设计的回报:
java
//发布评论,并获取评论数量(文本评论/点赞/喜欢通用)
public Integer save(Comment comment) {
//1、查询动态
Movement movement = mongoTemplate.findById(comment.getPublishId(), Movement.class);
//2、设置被评论人
if(movement != null) {
comment.setPublishUserId(movement.getUserId());
}
//3、保存 Comment 文档
mongoTemplate.save(comment);
//4、按类型对动态计数做 $inc:1→likeCount,2→commentCount,3→loveCount
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());
}
//判断某用户对某动态的某类互动是否已存在
public Boolean hasComment(String movementId, Long userId, CommentType commentType) {
Criteria criteria = Criteria.where("userId").is(userId)
.and("publishId").is(new ObjectId(movementId))
.and("commentType").is(commentType.getType());
Query query = Query.query(criteria);
return mongoTemplate.exists(query, Comment.class);
}
statisCount 是 Movement 实体上的辅助方法,按类型取对应计数字段,type=3 时返回 loveCount。delete 方法与 save 对称:按 userId + publishId + commentType 三个条件 remove,再对 loveCount 做 -1 的 $inc。
判重走的是 Mongo 的 exists 查询,条件里有 commentType,所以「点过赞」不会阻止「喜欢」,两个状态天然隔离,至少写侧是隔离的,读侧马上会看到一个反例。
时序图
喜欢与取消喜欢的完整数据流:
Redis MongoDB CommentApi(Dubbo) CommentsService MovementController 客户端 Redis MongoDB CommentApi(Dubbo) CommentsService MovementController 客户端 #mermaid-svg-tLA4uZWWTxfwF1x5{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-tLA4uZWWTxfwF1x5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tLA4uZWWTxfwF1x5 .error-icon{fill:#552222;}#mermaid-svg-tLA4uZWWTxfwF1x5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tLA4uZWWTxfwF1x5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tLA4uZWWTxfwF1x5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tLA4uZWWTxfwF1x5 .marker.cross{stroke:#333333;}#mermaid-svg-tLA4uZWWTxfwF1x5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tLA4uZWWTxfwF1x5 p{margin:0;}#mermaid-svg-tLA4uZWWTxfwF1x5 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tLA4uZWWTxfwF1x5 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tLA4uZWWTxfwF1x5 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tLA4uZWWTxfwF1x5 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tLA4uZWWTxfwF1x5 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tLA4uZWWTxfwF1x5 .sequenceNumber{fill:white;}#mermaid-svg-tLA4uZWWTxfwF1x5 #sequencenumber{fill:#333;}#mermaid-svg-tLA4uZWWTxfwF1x5 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tLA4uZWWTxfwF1x5 .messageText{fill:#333;stroke:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tLA4uZWWTxfwF1x5 .labelText,#mermaid-svg-tLA4uZWWTxfwF1x5 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .loopText,#mermaid-svg-tLA4uZWWTxfwF1x5 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .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-tLA4uZWWTxfwF1x5 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tLA4uZWWTxfwF1x5 .noteText,#mermaid-svg-tLA4uZWWTxfwF1x5 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tLA4uZWWTxfwF1x5 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tLA4uZWWTxfwF1x5 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tLA4uZWWTxfwF1x5 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tLA4uZWWTxfwF1x5 .actorPopupMenu{position:absolute;}#mermaid-svg-tLA4uZWWTxfwF1x5 .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-tLA4uZWWTxfwF1x5 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tLA4uZWWTxfwF1x5 .actor-man circle,#mermaid-svg-tLA4uZWWTxfwF1x5 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tLA4uZWWTxfwF1x5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /movements/{id}/love loveComment(movementId) hasComment(movementId, userId, LOVE) exists(userId+publishId+type=3) false false save(commenttype=3) insert Comment inc loveCount +1 (findAndModify) 最新 loveCount HSET MOVEMENTS_INTERACT_{id} MOVEMENT_LOVE_{userId} "1" loveCount 200 loveCount GET /movements/{id}/unlove disloveComment(movementId) hasComment(...LOVE) exists(...) true delete(comment\[type=3\]) remove Comment inc loveCount -1 最新 loveCount HDEL MOVEMENTS_INTERACT_{id} MOVEMENT_LOVE_{userId} loveCount 200 loveCount
工程实证:hasLiked 读侧的 hashKey 用错了
讲义 2.4.3 要求「修改之前的查询圈子列表代码,从 redis 查询是否具有喜欢操作记录。同点赞」,即好友动态和推荐动态查询时,除了补一个 hasLiked 之类的喜欢标识,原有的点赞标识判断也要在。实际工程快照(MovementService)里是这样的:
java
for (Movement movement : list) {
UserInfo userInfo = map.get(movement.getUserId());
if(userInfo != null) {
MovementsVo vo = MovementsVo.init(userInfo, movement);
//修复点赞状态的bug,判断hashKey是否存在
String key = Constants.MOVEMENTS_INTERACT_KEY + movement.getId().toHexString();
String hashKey = Constants.MOVEMENT_LOVE_HASHKEY + UserHolder.getUserId();
if(redisTemplate.opsForHash().hasKey(key, hashKey)) {
vo.setHasLiked(1);
}
vos.add(vo);
}
}
注释写着「修复点赞状态的 bug」,hashKey 却取的是 MOVEMENT_LOVE_HASHKEY(MOVEMENT_LOVE_ 前缀),不是 MOVEMENT_LIKE_HASHKEY(MOVEMENT_LIKE_ 前缀)。这正是从点赞代码复制过来改喜欢标识时,把判断点赞的 hashKey 顺手替换掉了,点赞的读侧判断被喜欢状态接管。
具体表现 :用户对某条动态只点了喜欢、没有点赞,刷新列表后这条动态的 hasLiked=1,前端把点赞按钮渲染成「已点赞」高亮。反过来,只点赞不喜欢的用户,点赞状态反而不显示。点赞和喜欢两个状态在读侧被焊死成同一个。
修法很直接,hasLiked 判断改回 LIKE 的 hashKey:
java
// 点赞状态判断用 LIKE 的 hashKey
String likeHashKey = Constants.MOVEMENT_LIKE_HASHKEY + UserHolder.getUserId();
if(redisTemplate.opsForHash().hasKey(key, likeHashKey)) {
vo.setHasLiked(1);
}
// 喜欢标识如需单独返回,再用 LOVE 的 hashKey 查一次,存入独立字段
String loveHashKey = Constants.MOVEMENT_LOVE_HASHKEY + UserHolder.getUserId();
if(redisTemplate.opsForHash().hasKey(key, loveHashKey)) {
vo.setHasLiked(1); // 实际应替换为 vo.setHasLoved(1),Vo 需扩展字段
}
彻底的做法是给 MovementsVo 加 hasLoved 字段,两个状态各查各的 hashKey,互不干扰。只改一行的最小修法(第一段)能立刻止损,但喜欢标识就没地方放了,要不要加字段取决于产品是否真的展示「已喜欢」高亮。
这个 bug 是「复制粘贴同构代码」的经典事故:逻辑结构一模一样时,人眼对代码的扫读会自动跳过相同部分,最容易漏改的恰恰是那些不起眼的常量。两个常量只差四个字母(LIKE/LOVE),IDE 的语法检查完全拦不住。
工程启示:同构操作的成本账
回看三种互动类型落地的代码量:
| 方案 | 新增一种互动类型的成本 |
|---|---|
| 三合一 Comment 表(本项目) | 1 个枚举值 + 2 个常量 + 2 个异常工厂方法 + 1 对 Controller 方法 + 1 对 Service 方法(复制改造) |
| 分三张表(点赞表/喜欢表/评论表) | 每种互动一套独立的领域模型、Repository、Service、Controller,三套 CRUD、三套判重、三套计数逻辑 |
三合一方案的判重、计数、删除逻辑全部收敛在 CommentApiImpl 一处,按 commentType 分流。新增「喜欢」没有动任何公共代码,只加了一个枚举分支。代价也有:所有分流点都是 if-else,类型多了以后更优雅的写法是给每种类型映射一个计数字段名(Map<CommentType, String>),消灭 if-else 链。
另一个隐性收益是查询侧的统一:findComments 按 publishId + commentType 分页查询,评论列表、点赞列表、喜欢列表用同一个方法,只是参数不同。
坑表
| 坑 | 后果 | 规避 |
|---|---|---|
| 复制同构代码漏改常量/枚举(这里主角,工程实证见上) | 状态互相污染,且编译期无任何报警 | 复制后先全局搜旧常量名;或把差异抽成参数,让两处共用一份代码 |
| 重复喜欢无判重 | loveCount 虚高,一个用户刷出多条 LOVE 记录 |
入口先 hasComment 判重,重复操作抛 loveError |
计数键写错(loveCount 写成 likeCount) |
喜欢数加到点赞数上,两列数据都废 | CommentApiImpl 里统一分流,Service 层不直接碰计数字段 |
| Redis 与 Mongo 脱节 | Mongo 删了记录但 Redis 标记残留(或反之),读侧显示状态与数据库不符 | 两步操作放在同一方法内顺序执行;对一致性要求高的场景可先写 Redis 再落库,失败时补偿删除 |
变量名残留(如 Controller 里 likeCount 装着喜欢数) |
不影响运行但误导维护者 | 复制后顺手改名,IDE 重命名一把搞定 |
验证
按讲师视频里的流程可完整复现。环境:MongoDB 与 Redis 已启动,服务以本地测试配置运行。
喜欢一条动态(客户端点喜欢按钮,或直接调接口):
bash
curl "http://localhost:18080/movements/<movementId>/love" -H "Authorization: <token>"
查 Mongo:Comment 表多一条 commentType: 3 的记录;Movement 表对应动态的 loveCount 变 1:
javascript
// mongosh
db.comment.find({ publishId: ObjectId("<movementId>"), commentType: 3 })
db.movement.find({ _id: ObjectId("<movementId>") }, { likeCount: 1, loveCount: 1 })
查 Redis:Hash 里多一个 MOVEMENT_LOVE_<userId> 的 hashKey:
bash
redis-cli HKEYS "MOVEMENTS_INTERACT_<movementId>"
# 输出应包含 MOVEMENT_LIKE_xxx 与 MOVEMENT_LOVE_xxx 两类 hashKey(操作过的话)
redis-cli HGET "MOVEMENTS_INTERACT_<movementId>" "MOVEMENT_LOVE_<userId>"
取消喜欢回退:
bash
curl "http://localhost:18080/movements/<movementId>/unlove" -H "Authorization: <token>"
再查一遍:Comment 记录消失、loveCount 回 0、Redis 的 LOVE hashKey 被删。
最后验证与点赞的状态隔离:对同一条动态只 执行 love(不调 like),然后检查 Redis,MOVEMENT_LIKE_<userId> 不存在,MOVEMENT_LOVE_<userId> 存在。按工程现状的 bug 代码,此时列表接口会错误地返回 hasLiked=1;按上文修法改回 MOVEMENT_LIKE_HASHKEY 后,hasLiked 保持 0,这一步同时验证了功能正确性和 bug 修复效果。
API 速览
| 接口 | 方法 | 说明 | 成功返回 | 重复/非法操作 |
|---|---|---|---|---|
/movements/{id}/love |
GET | 喜欢动态 | 最新 loveCount |
已喜欢:000008 用户已喜欢 |
/movements/{id}/unlove |
GET | 取消喜欢 | 最新 loveCount |
未喜欢:000009 用户未喜欢 |
/movements/{id}/like |
GET | 点赞动态(对照) | 最新 likeCount |
已点赞:000006 |
/movements/{id}/dislike |
GET | 取消点赞(对照) | 最新 likeCount |
未点赞:000007 |
官方文档
- Spring Data MongoDB - Template API(
findAndModify、$inc、exists的行为细节) - Redis Hash 命令(
HSET/HDEL/HKEYS/HEXISTS)
常见误解澄清
| 误解 | 事实 |
|---|---|
| 喜欢就是点赞的别名,可以只做一种 | 代码同构,但产品语义不同,数据上是两类记录(type=1 与 type=3),各自独立判重、独立计数 |
| 动态「喜欢」会触发交友配对 | 不会。配对用的是交友模块的 USER_LIKE_SET_ 体系(用户对用户),圈子互动的「喜欢」是用户对动态的 type=3 Comment,两套机制无关 |
| 喜欢状态存在单独的 Redis key 里 | 主 key 与点赞共用 MOVEMENTS_INTERACT_{movementId},只在 hashKey 前缀上区分 MOVEMENT_LIKE_ / MOVEMENT_LOVE_ |
| 写侧判重能保证读侧状态正确 | 不行。本项目写侧判重和状态隔离都是对的,读侧 hashKey 用错照样把两个状态搅在一起,读写两侧要分别验证 |
save() 返回的是评论总数 |
返回的是按本次 commentType 分流后的那一个计数字段(type=3 时是 loveCount),由 statisCount 从 findAndModify 的最新文档里取 |
总结
- 喜欢与取消喜欢是点赞的同构操作:四步骨架(判重 → 落库 → 计数 → Redis 标记)逐字对应,差异只有
CommentType.LOVE、MOVEMENT_LOVE_HASHKEY、loveError/disloveError三处。 - 三合一 Comment 表让新增互动类型的成本降到「一个枚举值 + 一组常量 + 一对同构方法」,公共链路零改动;这是点赞/评论/喜欢合在一张表的核心回报。
- 工程快照里读侧
hasLiked误用MOVEMENT_LOVE_HASHKEY,导致「只喜欢未点赞」的动态被标成已点赞,修法是读侧判断改回MOVEMENT_LIKE_HASHKEY,若需展示喜欢标识则给 Vo 增加hasLoved字段。 - 教训值得记住:复制同构代码时,逻辑不会错,错的往往是常量、枚举、字符串这类「数据差异点」,而它们恰好躲过编译检查和肉眼扫读。复制完先搜一遍旧标识符,比事后排查状态污染便宜得多。
圈子互动系列至此覆盖:概述与业务特点、发布评论、评论列表、动态点赞、取消点赞、喜欢与取消喜欢