概述
在好友动态、推荐动态列表里点开一张卡片,进入的就是动态详情页。这里解决这个页面的数据来源问题:按动态 id 查询单条动态,搞清楚 _id 与 pid 两套 id 各自的使用场景,以及详情页的评论列表是怎么配合查出来的。
纲要
- 业务场景:详情页比列表卡片多什么
- 数据基础:movement 集合的双 id 设计(业务键 pid ≠ 主键 _id)
- 接口设计:
GET /movements/{id}与GET /comments的参数与响应 - 实现链路(核心)
- Controller:
@PathVariable接收路径参数 - Service:查动态 → 查用户 →
MovementsVo.init组装 - API 层:
mongoTemplate.findById,String 直接传、无需手动转 ObjectId - 评论列表:
comment集合按publishId+commentType分页查询 - hasLiked 回填:单条逐项查与列表批查的边界
- 请求时序图
- 详情页原型推演(doc 线框图)
- 异常与边界:动态不存在、审核驳回 state、评论分页越界、拉黑可见性
- 验证要点与常见误解澄清
业务场景:从列表卡片到详情页
好友动态列表和推荐动态列表里,每张卡片展示的是精简信息:昵称头像、文字、图片缩略、点赞评论数。用户点击卡片的「评论」入口后,客户端要切到详情页,此时需要的数据比列表卡片多两类:
| 数据 | 列表卡片 | 详情页 |
|---|---|---|
| 动态正文与图片 | 有(缩略) | 全文 + 图片轮播 |
| 互动计数 | 有 | 有(要求精确) |
| 评论列表 | 无 | 全部评论,分页加载 |
| 当前用户点赞状态 | 有(列表批量回填) | 有(可逐条判断) |
评论功能入口在卡片上,但点进去第一件事是先查动态详情,再查评论列表 ,详情页顶部渲染动态本体,底部评论区另发请求分页拉评论。所以「查询单条动态」这个接口本身只负责动态本体,评论由独立的 /comments 接口提供,两者用同一个 movementId 串联。
上一节查询推荐动态里 pid 扮演过核心角色(Redis 推荐结果存的是 pid 列表),本节正好把两套 id 的分工讲透。
数据基础:movement 集合的双 id 设计
movement 集合的实体定义(实读自 mf-model 模块):
java
package com.mf.model.mongo;
import com.mf.model.enums.CommentType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
//动态详情表
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement")
public class Movement implements java.io.Serializable {
private ObjectId id; //主键id
//redis实现,使用Mongodb实现
private Long pid; //Long类型,用于推荐系统的模型(自动增长)
private Long created; //发布时间
private Long userId;
private String textContent; //文字
private List<String> medias; //媒体数据,图片或小视频 url
private String longitude; //经度
private String latitude; //纬度
private String locationName; //位置名称
private Integer state = 0;//状态 0:未审(默认),1:通过,2:驳回
private Integer likeCount = 0; //点赞数
private Integer commentCount = 0; //评论数
private Integer loveCount = 0; //喜欢数
//根据评论类型,获取对应的互动数量
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;
}
}
}
一个文档里有两个 id,分工完全不同:
_id(ObjectId) |
pid(Long,自增) |
|
|---|---|---|
| 性质 | MongoDB 主键,发布时自动生成 | 业务 id,发布时由 MongoDB sequence(IdWorker.getNextId,findAndModify + inc + upsert)生成 |
| 谁在用 | 详情查询、评论关联(comment.publishId)、时间线表 movementId | 推荐系统模型:大数据计算结果以 pid 列表形式写回 Redis |
| 查询方式 | mongoTemplate.findById(...) |
Criteria.where("pid").in(...) |
| 出现在哪 | MovementsVo.id(toHexString() 后给前端) |
仅推荐链路内部流转,不透出给前端 |
关键闭环在 MovementsVo.init 这一行(实读):
java
vo.setId(item.getId().toHexString());
列表接口返回给前端的 id 就是 _id 的十六进制串,前端点卡片时原样带回,详情接口按 _id 查即可。前端手里根本没有 pid ,所以详情链路不涉及 pid 查询;findMovementsByPids 里那段 Criteria.where("pid").in(pids) 只服务推荐场景。把「按 pid 查详情」当成默认做法是这一节最常见的想当然,业务键不等于主键,两套 id 各管一段链路。
接口设计
查询单条动态对应讲义交友项目05-圈子互动§1.3「根据id查询动态」。
动态详情接口
| 项 | 内容 |
|---|---|
| 路径 | GET /movements/{id} |
| 请求方式 | GET |
| 参数 | id:动态 id,路径参数(_id 的十六进制串,来自列表接口返回的 MovementsVo.id) |
| Header | Authorization:JWT token |
| 响应 | MovementsVo(动态本体 + 发布人信息 + 互动计数) |
响应体示例:
json
{
"id": "5fae53947e52992e78a3afa5",
"userId": 1,
"avatar": "https://mf-oss.oss-cn-qingdao.aliyuncs.com/images/2020/07/01/xxx.jpg",
"nickname": "郁金香",
"gender": "woman",
"age": 24,
"tags": ["单身", "白羊座", "蛇口"],
"textContent": "今天天气不错,出来走走~",
"imageContent": [
"https://mf-oss.oss-cn-qingdao.aliyuncs.com/images/2020/07/01/a.jpg",
"https://mf-oss.oss-cn-qingdao.aliyuncs.com/images/2020/07/01/b.jpg"
],
"distance": "500米",
"createDate": "2026-09-30 10:12:33",
"likeCount": 3,
"commentCount": 8,
"loveCount": 1,
"hasLiked": 0,
"hasLoved": 0
}
评论列表接口(详情页配合)
| 项 | 内容 |
|---|---|
| 路径 | GET /comments |
| 请求方式 | GET |
| 参数 | movementId:动态 id;page:页码,默认 1;pagesize:页大小,默认 10 |
| 响应 | PageResult,items 为 CommentVo 列表 |
响应体示例:
json
{
"counts": 0,
"pagesize": 10,
"pages": 0,
"page": 1,
"items": [
{
"id": "5fe7f9263c851428107cd4e8",
"avatar": "https://mf-oss.oss-cn-qingdao.aliyuncs.com/images/2020/07/01/xxx.jpg",
"nickname": "清风徐来",
"content": "拍得真好,下次带上我",
"createDate": "2026-09-30 10:30:00",
"likeCount": 2,
"hasLiked": 0
}
]
}
实现链路
完整调用链跨三个模块:mf-app-server(Controller/Service)→ Dubbo RPC → mf-dubbo-mongo(API 实现,直连 MongoDB)。工程结构:
tree
mf
├── mf-app-server # 应用服务:Controller + Service
│ └── com/mf/server
│ ├── controller
│ │ ├── MovementController.java # GET /movements/{id}
│ │ └── CommentsController.java # GET /comments
│ └── service
│ ├── MovementService.java # findById:查动态+组装VO
│ └── CommentsService.java # findComments:查评论+组装VO
├── mf-dubbo
│ ├── mf-dubbo-interface # API 接口定义
│ │ └── com/mf/dubbo/api
│ │ ├── MovementApi.java # findById(String)
│ │ └── CommentApi.java # findComments(...)
│ └── mf-dubbo-mongo # API 实现,操作 MongoDB
│ └── com/mf/dubbo/api
│ ├── MovementApiImpl.java # mongoTemplate.findById
│ └── CommentApiImpl.java # publishId+commentType 分页查
└── mf-model
└── com/mf/model
├── mongo
│ ├── Movement.java # movement 集合
│ └── Comment.java # comment 集合
└── vo
├── MovementsVo.java
└── CommentVo.java
Controller:路径参数接收
java
package com.mf.server.controller;
import com.mf.model.mongo.Movement;
import com.mf.model.vo.MovementsVo;
import com.mf.model.vo.PageResult;
import com.mf.server.service.CommentsService;
import com.mf.server.service.MovementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/movements")
public class MovementController {
@Autowired
private MovementService movementService;
//...... 发布动态、查询好友/推荐动态等接口省略 ......
/**
* 查询单条动态
*/
@GetMapping("/{id}")
public ResponseEntity findById(@PathVariable("id") String movementId) {
MovementsVo vo = movementService.findById(movementId);
return ResponseEntity.ok(vo);
}
}
id 是路径参数,用 @PathVariable 接收,参数类型声明为 String。这个 String 就是 ObjectId 的十六进制形式,后面会看到它被自动转回 ObjectId,Controller 层不用管。
Service:查动态 + 查用户 + 组装 VO
java
package com.mf.server.service;
import cn.hutool.core.collection.CollUtil;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.MovementApi;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Movement;
import com.mf.model.vo.ErrorResult;
import com.mf.model.vo.MovementsVo;
import com.mf.model.vo.PageResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class MovementService {
@DubboReference
private MovementApi movementApi;
@DubboReference
private UserInfoApi userInfoApi;
//...... 发布动态、列表查询等已有方法省略 ......
//根据id查询
public MovementsVo findById(String movementId) {
//1、调用api根据id查询动态详情
Movement movement = movementApi.findById(movementId);
//2、转化vo对象
if(movement != null) {
UserInfo userInfo = userInfoApi.findById(movement.getUserId());
return MovementsVo.init(userInfo,movement);
}else {
return null;
}
}
}
逻辑就两步:先按 id 查 Movement,查到再按发布人 userId 查 UserInfo,两者交给 MovementsVo.init 拼成一个 VO。init 内部用 BeanUtils.copyProperties 拷贝同名字段,再单独处理 _id 转 String、图片列表、发布时间格式化这几处类型不一致的字段,组装口径与好友动态列表完全一致,这里不展开,细节见查询好友动态:代码实现篇。
API 层:findById 与 String → ObjectId 的自动转换
mf-dubbo-interface 中补充接口方法:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Movement;
import com.mf.model.vo.PageResult;
public interface MovementApi {
//发布动态
void publish(Movement movement);
//根据用户id,查询此用户发布的动态数据列表
PageResult findByUserId(Long userId, Integer page, Integer pagesize);
//根据id查询
Movement findById(String movementId);
}
mf-dubbo-mongo 中实现:
java
package com.mf.dubbo.api;
import cn.hutool.core.collection.CollUtil;
import com.mf.dubbo.utils.IdWorker;
import com.mf.model.mongo.Friend;
import com.mf.model.mongo.Movement;
import com.mf.model.mongo.MovementTimeLine;
import com.mf.model.vo.PageResult;
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.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import java.util.List;
@DubboService
public class MovementApiImpl implements MovementApi {
@Autowired
private MongoTemplate mongoTemplate;
//...... publish / findByUserId / findFriendMovements / findMovementsByPids 等省略 ......
@Override
public Movement findById(String movementId) {
return mongoTemplate.findById(movementId,Movement.class);
}
}
一行搞定。容易疑惑的点:Movement.id 的类型明明是 ObjectId,为什么这里传 String 也行?
Spring Data MongoDB 在映射查询时会做类型转换:实体主键声明为 ObjectId 时,findById 收到的 String 会被自动转换成 ObjectId 再去匹配 _id。所以不需要手动 new ObjectId(movementId) 。对比同文件里 findMovementsByPids,那才是按 pid 查,条件是 Criteria.where("pid").in(pids),参数是 Long 列表。两个方法放在一起看,两套 id 的查询姿势一目了然。
一个前提别忘了:传进来的 String 必须是合法的 24 位十六进制串。前端如果传了脏数据(比如被截断的 id),Spring 的转换会抛异常,这属于入参校验的边界,稍后边界一节讨论。
评论列表:comment 集合的查询口径
详情页底部的评论不在这个接口里,而是 CommentsController 提供的独立分页接口。评论存在 comment 集合,实体(实读):
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* 圈子互动表(点赞,评论,喜欢)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "comment")
public class Comment implements java.io.Serializable{
private ObjectId id;
private ObjectId publishId; //动态id
private Integer commentType; //评论类型,1-点赞,2-评论,3-喜欢
private String content; //评论内容
private Long userId; //评论人
private Long publishUserId; //被评论人ID
private Long created; //发表时间
private Integer likeCount = 0; //当前评论的点赞数
}
注意 comment 是点赞、评论、喜欢三合一的互动表,靠 commentType 区分(1-点赞,2-评论,3-喜欢),publishId 指向被互动的动态。所以查「某条动态的评论列表」必须同时带两个条件:publishId + commentType=2。Dubbo 端实现(实读自 CommentApiImpl):
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;
//...... save / hasComment / delete 等方法省略 ......
//分页查询
public List<Comment> findComments(String movementId, CommentType commentType, Integer page, Integer pagesize) {
//1、构造查询条件
Query query = Query.query(Criteria.where("publishId").is(new ObjectId(movementId)).and("commentType")
.is(commentType.getType()))
.skip((page -1) * pagesize)
.limit(pagesize)
.with(Sort.by(Sort.Order.desc("created")));
//2、查询并返回
return mongoTemplate.find(query,Comment.class);
}
}
这里反而是手动 new ObjectId(movementId) ,因为这次不是 findById,而是普通字段 publishId 上的 Criteria 查询,字段类型是 ObjectId,条件值必须自己转,Spring 不会替普通字段做这件事。同一个是 String 类型的动态 id,一处自动转、一处手动转,这是很典型的对比考点。
CommentsService.findComments 拿到评论列表后,批量提取 userId 查 UserInfo,逐条 CommentVo.init 组装(头像、昵称、时间格式化),最后包成 PageResult 返回,这个「查列表 → 批量查用户 → 转 VO」的三段式与动态列表一致,不再赘述。
hasLiked 回填:单条与列表的边界
MovementsVo 里有两个互动状态字段(实读):
java
private Integer hasLiked; //是否点赞(1是,0否)
private Integer hasLoved; //是否喜欢(1是,0否)
而 MovementsVo.init 里是硬编码的默认值:
java
//设置是否点赞(后续处理)
vo.setHasLoved(0);
vo.setHasLiked(0);
课程版本的详情接口没有回填真实点赞状态。要补上的话,单条查询有两条现成路:
- 查 Redis:
redisTemplate.opsForHash().hasKey(Constants.MOVEMENTS_INTERACT_KEY + movementId, Constants.MOVEMENT_LIKE_HASHKEY + userId),好友动态列表就是这么做批量判断的; - 查 Mongo:
commentApi.hasComment(movementId, userId, CommentType.LIKE),底层是mongoTemplate.exists。
单条详情只服务一个用户 + 一条动态,逐项查一次也就一两次 IO,N+1 问题在这里不明显;真正需要批查的是列表场景,一页 10 条动态,每条都单独查一次点赞状态就是 10 次往返,所以列表才把「提取 key 列表 + 一次 Redis 判断」当成必修课。这条边界记清楚:详情页怎么查都行,列表页必须批量。
请求时序图
MongoDB UserInfoApi(Dubbo) MovementApi(Dubbo) mf-app-server 客户端(详情页) MongoDB UserInfoApi(Dubbo) MovementApi(Dubbo) mf-app-server 客户端(详情页) #mermaid-svg-YvwVCcDtuSvwkINV{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-YvwVCcDtuSvwkINV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YvwVCcDtuSvwkINV .error-icon{fill:#552222;}#mermaid-svg-YvwVCcDtuSvwkINV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YvwVCcDtuSvwkINV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YvwVCcDtuSvwkINV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YvwVCcDtuSvwkINV .marker.cross{stroke:#333333;}#mermaid-svg-YvwVCcDtuSvwkINV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YvwVCcDtuSvwkINV p{margin:0;}#mermaid-svg-YvwVCcDtuSvwkINV .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvwVCcDtuSvwkINV text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YvwVCcDtuSvwkINV .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YvwVCcDtuSvwkINV .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YvwVCcDtuSvwkINV .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YvwVCcDtuSvwkINV .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YvwVCcDtuSvwkINV #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YvwVCcDtuSvwkINV .sequenceNumber{fill:white;}#mermaid-svg-YvwVCcDtuSvwkINV #sequencenumber{fill:#333;}#mermaid-svg-YvwVCcDtuSvwkINV #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YvwVCcDtuSvwkINV .messageText{fill:#333;stroke:none;}#mermaid-svg-YvwVCcDtuSvwkINV .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvwVCcDtuSvwkINV .labelText,#mermaid-svg-YvwVCcDtuSvwkINV .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YvwVCcDtuSvwkINV .loopText,#mermaid-svg-YvwVCcDtuSvwkINV .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YvwVCcDtuSvwkINV .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-YvwVCcDtuSvwkINV .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YvwVCcDtuSvwkINV .noteText,#mermaid-svg-YvwVCcDtuSvwkINV .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YvwVCcDtuSvwkINV .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvwVCcDtuSvwkINV .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvwVCcDtuSvwkINV .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvwVCcDtuSvwkINV .actorPopupMenu{position:absolute;}#mermaid-svg-YvwVCcDtuSvwkINV .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-YvwVCcDtuSvwkINV .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvwVCcDtuSvwkINV .actor-man circle,#mermaid-svg-YvwVCcDtuSvwkINV line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YvwVCcDtuSvwkINV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt movement != null movement == null publishId + commentType=2 分页查comment集合 批量查UserInfo组装CommentVo GET /movements/{id} (携带JWT) 拦截器校验token, UserHolder暂存userId findById(movementId) mongoTemplate.findById(String自动转ObjectId) Movement文档(或null) Movement findById(movement.getUserId()) 查询userinfo UserInfo UserInfo MovementsVo.init(userInfo, movement) 组装VO 200 + MovementsVo 200 + null(动态不存在) GET /comments?movementId={id}&page=1 CommentsService.findComments 200 + PageResult(评论列表)
详情页原型推演
移动端原型(交友项目移动端原型V1.2)的圈子模块只有信息流列表页,没有独立的动态详情页线框。下面按接口字段推演详情页布局,字段与 MovementsVo / CommentVo 一一对应:
doc
┌─────────────────────────┐
│ ← 动态详情 │ 导航栏:返回按钮 + 标题
├─────────────────────────┤
│ (头像) 郁金香 ♀ 24 │ avatar / nickname / gender / age
│ 单身·白羊座·蛇口 │ tags(逗号分隔转标签组)
│ │
│ 今天天气不错,出来走走~ │ textContent 动态全文
│ ┌─────┐ ┌─────┐ │
│ │ 图1 │ │ 图2 │ → │ imageContent 图片轮播
│ └─────┘ └─────┘ │
│ 📍500米 10:12 │ distance / createDate
│ ❤ 3 💬 8 ⭐ 1 │ likeCount / commentCount / loveCount
│ (点赞·评论·喜欢) │
├─────────────────────────┤
│ 全部评论 (8) │ commentCount
│ ─────────────────────── │
│ (头像) 清风徐来 │ CommentVo.nickname / avatar
│ 拍得真好,下次带上我 │ content
│ 10:30 赞 2 │ createDate / likeCount
│ ─────────────────────── │
│ (头像) 南山客 │
│ 这地方在哪? │
│ 09:58 赞 0 │
│ ─────────────────────── │
│ 加载更多... │ 分页:GET /comments?page=2
├─────────────────────────┤
│ [ 说点什么... ] 发送 │ 评论输入框 → POST /comments
└─────────────────────────┘
要点:
- 顶部动态区字段全部来自
GET /movements/{id}的MovementsVo,与列表卡片共用渲染组件,只是图片从缩略换成轮播; - 评论区独立分页加载,首次进页面发
GET /comments?movementId=xx&page=1,上拉翻页递增 page; - 点赞/喜欢按钮点击即调
GET /movements/{id}/like、/{id}/love,返回最新计数直接更新按钮数字; - 底部输入框对应发布评论接口
POST /comments,发布成功后本地插入一条或重新拉第一页。
异常与边界
| 场景 | 现象与原因 | 课程版处理 | 生产建议 |
|---|---|---|---|
| 动态不存在 | id 无效、被删除,findById 返回 null |
Service 返回 null,接口响应体为空 | 返回明确的 404 或业务异常码,前端跳「内容不存在」页 |
| id 格式非法 | 前端传脏数据,String 转 ObjectId 抛异常 | 未捕获,500 | 入口处校验 24 位十六进制串,非法直接参数错误 |
| 审核驳回动态(state=2) | state 字段 0 未审/1 通过/2 驳回,详情查询不过滤 state,任何知道 id 的人都能看到驳回内容 |
不过滤 | 产品取舍点:建议驳回动态仅作者本人可见(查询加 userId 条件),他人访问按不存在处理 |
| 评论分页越界 | page 超出总页数 | skip 越过全部数据,返回空 items,前端停止加载 |
可接受,无需特判 |
| 评论 id 与动态 id 不匹配 | 评论接口的 publishId 是 ObjectId,前端传了 pid 会查空 | 返回空 PageResult | 前端统一透传 MovementsVo.id(_id),不要混用两套 id |
| 作者被拉黑 | 黑名单用户的动态不应出现在详情页 | 本接口不处理 | 列表层已在黑名单过滤时处理;直链进入的场景可在详情查询前校验黑名单关系 |
关于 state 再多说一句:发布动态时 state 默认 0(未审),审核流程在后续章节。详情接口作为「按 id 直查」的读路径,天然绕开了列表层的过滤逻辑,列表查不到的动态,详情接口却能查到,这是所有「详情直查」接口的通病,上线前必须补过滤。
验证要点
用 Postman 按发布 → 列表 → 详情的顺序走一遍闭环:
POST /movements发布一条动态(携带表单参数 textContent 与图片);GET /movements?page=1&pagesize=10查好友动态列表,从 items 里取第一条的id(注意取的是_id十六进制串,不是 pid);GET /movements/{id}传上一步的 id,核对返回字段与列表里那条一致:textContent、imageContent、likeCount 等;GET /comments?movementId={id}&page=1&pagesize=10查评论列表,核对 commentType=2 的记录才出现在结果里(点赞/喜欢记录不混入);- 反向验证:随便编一个 24 位十六进制串调详情接口,确认返回 null 而不是别人的数据。
代码改动涉及 mf-app-server 和 mf-dubbo-mongo 两个模块,重启时两个服务都要重启,只重启 app-server 会因为 Dubbo 端还是旧接口而报 NoSuchMethodException。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 「详情接口要按 pid 查,因为 pid 是业务 id」 | 实读代码是 mongoTemplate.findById(movementId, Movement.class),按 _id 查。前端拿到的 MovementsVo.id 本来就是 _id 的 hex 串,pid 只在推荐链路(findMovementsByPids)里用 |
「传 String 前必须手动 new ObjectId()」 |
findById 走主键映射,Spring Data 自动转换;普通字段(如 publishId)的 Criteria 查询才需要手动转 |
| 「详情页一个接口返回动态+评论」 | 本项目拆成两个接口:GET /movements/{id} 查动态本体,GET /comments 分页查评论,前端组合渲染 |
| 「comment 表只存评论」 | 点赞、评论、喜欢三合一,靠 commentType 区分(1/2/3),查评论列表必须带 commentType=2 |
| 「详情页查点赞状态会有 N+1 问题」 | 单条详情逐项查只有一两次 IO,可接受;列表场景才必须批量查(一次 Redis hash 判断) |
| 「列表查不到的动态详情也查不到」 | 详情接口按 id 直查且不过滤 state,驳回(state=2)的动态仍能被查出,需另行处理 |
API 速览
| 接口 | 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
/movements/{id} |
GET | 路径参数 id(动态 _id) |
MovementsVo |
单条动态详情 |
/comments |
GET | movementId、page、pagesize | PageResult<CommentVo> |
详情页评论列表 |
/movements/{id}/like |
GET | 路径参数 id | 最新点赞数 | 点赞(详情页互动) |
/movements/{id}/dislike |
GET | 路径参数 id | 最新点赞数 | 取消点赞 |
/movements/{id}/love |
GET | 路径参数 id | 最新喜欢数 | 喜欢 |
/movements/{id}/unlove |
GET | 路径参数 id | 最新喜欢数 | 取消喜欢 |
官方文档
总结
查询单条动态是「查询动态」三件套里最轻的一个:Controller 接路径参数,Service 查动态查用户拼 VO,API 层一行 findById。真正的含金量在 id 的辨析上,_id 是主键、前端唯一持有的动态标识,详情、评论、互动全部围绕它;pid 是给推荐系统用的业务键,只在 Redis 与 Mongo 之间的推荐链路流转。两套 id 在 findMovementsByPids 和 findById 两个方法里各归各位,混用就是查空或报错。
配套的两个细节值得带走:Spring Data 对主键 String 的自动转换(普通字段不享受这个待遇);单条查询与列表查询在点赞状态回填上的批查边界。
至此「查询动态」的三个功能(好友动态、推荐动态、单条动态)全部完成。下一篇进入圈子互动:概述,开始点赞、喜欢、评论的互动功能实现,前面频繁露面的 comment 集合和 commentType 字段,马上会正面登场。