概述
发布动态之后,用户点进「我的」页面看到的那个时间线,数据从哪来、接口长什么样、返回的 VO 要拼哪些字段,这里把「查询我的动态」这个功能的需求与设计一次讲清。链路本身不难,难的是几个容易想当然的细节。
纲要
- 业务场景:个人主页的时间线
- 「我的动态」与「好友动态」的数据来源差异(原稿箱 vs 收件箱)
- 接口设计
GET /movements/all请求参数与响应结构- userId 可选:查自己走 token、查别人传 userId
- 返回结构设计
- PageResult 分页口径
- MovementsVo 字段拆解:动态本体 + 用户资料 + 互动状态
- 互动计数从哪来、hasLiked 的 N+1 问题
- 组装流程与时序图
- 移动端原型(线框图)
- 异常与边界
- 验证要点、常见误解澄清
业务场景:个人主页的时间线
用户在 App 底部切到「我的」,点「我的动态」,进入一个按时间倒序排列的列表页:每条动态显示文字、图片九宫格、发布时间,还有点赞数、评论数这些互动信息。这就是「查询我的动态」,按当前用户 ID 查询他发过的所有动态。
对服务端来说,需求一句话就能说完:根据用户 ID 分页查询动态详情表 movement。机翻稿里那句「而我们查询的表就是动态详情表,所以这个功能还是很简单的」说的就是这个意思。但「简单」只针对查自己的场景,一碰到「看别人的主页」,需求立刻分叉:
| 场景 | 触发方式 | userId 来源 | 数据范围 |
|---|---|---|---|
| 看自己的动态 | 「我的」页 → 我的动态 | token 解析(UserHolder.getUserId()) |
自己的全部动态 |
| 看别人的主页 | 圈子页点头像 → 他人主页 | 前端显式传 userId | 对方公开的动态 |
同一个接口 GET /movements/all 服务两个场景,所以 userId 设计成可选参数,这是个关键设计决策,下文接口设计一节展开。
与好友动态时间线的数据来源差异
这是本功能最容易被误解的一点。项目的圈子模块里存在两条完全不同的读取路径:
| 对比项 | 我的动态(这里) | 好友动态(后续章节) |
|---|---|---|
| 查询集合 | movement(动态详情表) |
movement_timeline(时间线表) |
| 过滤条件 | userId = 本人 |
userId = 本人(作为收件人) |
| 数据性质 | 发布者的原稿箱:动态存了一份在这里 | 好友发布时写扩散推给我的收件箱 |
| 典型问题 | 我发过的所有动态 | 我关注的好友们发的动态 |
发布动态的时候(见同章发布圈子动态系列),服务端做了两件事:动态本体存进 movement,再给每个好友的 movement_timeline 写一条引用记录。这套写扩散模型的分工是,movement_timeline 是给好友 看的收件箱,movement 是发布者的原稿箱。查「我的动态」时直接查 movement 按 userId 过滤,不要去查 timeLine:一是 timeLine 里以本人作为收件人的记录只包含好友转发关系,不是完整的个人作品集;二是查原稿集合语义最直接,避免绕一圈。
两条读取路径画出来是这样:
#mermaid-svg-ZXDJ4z5KsuOmPaSW{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-ZXDJ4z5KsuOmPaSW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .error-icon{fill:#552222;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .marker.cross{stroke:#333333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW p{margin:0;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster-label text{fill:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster-label span{color:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster-label span p{background-color:transparent;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .label text,#mermaid-svg-ZXDJ4z5KsuOmPaSW span{fill:#333;color:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .node rect,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node circle,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node ellipse,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node polygon,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .rough-node .label text,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node .label text,#mermaid-svg-ZXDJ4z5KsuOmPaSW .image-shape .label,#mermaid-svg-ZXDJ4z5KsuOmPaSW .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .rough-node .label,#mermaid-svg-ZXDJ4z5KsuOmPaSW .node .label,#mermaid-svg-ZXDJ4z5KsuOmPaSW .image-shape .label,#mermaid-svg-ZXDJ4z5KsuOmPaSW .icon-shape .label{text-align:center;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .node.clickable{cursor:pointer;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .arrowheadPath{fill:#333333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZXDJ4z5KsuOmPaSW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZXDJ4z5KsuOmPaSW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster text{fill:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .cluster span{color:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW 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-ZXDJ4z5KsuOmPaSW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZXDJ4z5KsuOmPaSW rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .icon-shape,#mermaid-svg-ZXDJ4z5KsuOmPaSW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .icon-shape p,#mermaid-svg-ZXDJ4z5KsuOmPaSW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .icon-shape .label rect,#mermaid-svg-ZXDJ4z5KsuOmPaSW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZXDJ4z5KsuOmPaSW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZXDJ4z5KsuOmPaSW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZXDJ4z5KsuOmPaSW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发布时写入
按 userId 过滤原稿
按收件人查时间线
发布动态
movement
原稿集合
movement_timeline
收件箱集合
查我的动态
查好友动态
为什么发布时要写扩散而不是查询时 join?查询好友动态时若直接扫 movement 再按好友关系过滤,在数据量大之后是一次昂贵的全表扫描;写扩散把代价转移到发布时(一次发布、N 个好友各写一条),查询时只读自己的收件箱。而「我的动态」天然只有一个归属人,不存在扩散需求,直查原稿集合反而最高效。
接口设计
按 YAPI 文档与工程实读,接口定义如下:
| 项目 | 内容 |
|---|---|
| 请求路径 | GET /movements/all |
| 请求参数 | userId(Long,可选)、page(页码,默认 1)、pagesize(页大小,默认 10) |
| 返回数据 | PageResult<MovementsVo> |
请求与响应示例:
json
// 请求
GET /movements/all?userId=106&page=1&pagesize=10
// 响应
{
"counts": 0,
"pagesize": 10,
"pages": 0,
"page": 1,
"items": [
{
"id": "5fe9a04b0e6d2c4b1c8d6f01",
"userId": 106,
"avatar": "https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/images/mf/avatar_1.png",
"nickname": "黑而言",
"gender": "woman",
"age": 22,
"tags": ["单身", "大学本科", "年龄相仿"],
"textContent": "你的酒窝没有酒,我却醉的像条狗",
"imageContent": [
"https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/images/mf/avatar_1.png"
],
"distance": "500米",
"createDate": "2020-12-26 13:30:00",
"likeCount": 0,
"commentCount": 0,
"loveCount": 0,
"hasLiked": 0,
"hasLoved": 0
}
]
}
Controller 侧的签名(实读自 mf-app-server 的 MovementController):
java
package com.mf.server.controller;
import com.mf.model.vo.PageResult;
import com.mf.server.service.MovementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/movements")
public class MovementController {
@Autowired
private MovementService movementService;
/**
* 查询我的动态
*/
@GetMapping("/all")
public ResponseEntity findByUserId(Long userId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pagesize) {
PageResult pr = movementService.findByUserId(userId, page, pagesize);
return ResponseEntity.ok(pr);
}
}
三个参数都没有 @RequestParam 强制标注,Spring MVC 按「参数名与请求参数同名即绑定」处理;page/pagesize 缺省时取 defaultValue,userId 缺省时是 null。
userId 可选的设计意图与工程现状
设计意图很清楚:查自己不传 userId(或前端统一传 token 里的值),看别人主页必传 userId 。但实读工程代码,Service 是直接把 userId 透传给 Dubbo API 的,没有做「没传就取当前登录人」的兜底:
java
// MovementService.findByUserId 中的现状:userId 原样透传
PageResult pr = movementApi.findByUserId(userId, page, pagesize);
userId 为 null 时,MongoDB 查询条件变成 where("userId").is(null),返回空列表,接口不报错,但语义是错的。更严谨的写法是在 Service 入口补一行兜底:
java
public PageResult findByUserId(Long userId, Integer page, Integer pagesize) {
// userId 未传时视为查询自己的动态
if (userId == null) {
userId = UserHolder.getUserId();
}
// ...后续逻辑不变
}
UserHolder 在登录拦截器里已经把 token 解析结果放进了 ThreadLocal,这里取值零成本。顺带把权限也讲清:看自己的走 token 天然合法;看别人的主页时,接口只能返回对方的公开动态,这部分约束在「异常与边界」一节展开。
返回结构设计
PageResult 分页口径
PageResult 是项目全项目统一的分页壳,五个字段口径如下:
| 字段 | 类型 | 含义 |
|---|---|---|
counts |
Long | 总记录数 |
pagesize |
Integer | 页大小 |
pages |
Long | 总页数(由 counts 和 pagesize 推算) |
page |
Integer | 当前页码 |
items |
List | 当前页数据列表 |
这里有个实读发现的工程细节:API 层返回时 counts 写死了 0,
java
return new PageResult(page, pagesize, 0l, movements);
构造器里 pages 是从 counts 推算的,counts 为 0 意味着 pages 恒为 0。前端如果依赖总页数做「没有更多了」的判断,只能改用「本页 items 数量 < pagesize」来兜底。要做真实的 counts 需要多发一次 count 查询,课程在此处选择了省略,这是取舍而不是疏漏,但用的时候心里要有数。
MovementsVo 字段拆解
机翻稿里说得很直白:「针对返回的这些值,目前并没有一个合适的实体类来进行装载,所以需要构建一个返回 VO 对象。」 Movement 是 MongoDB 文档实体,UserInfo 是 MySQL 用户表实体,前端要的是两者的拼合再加互动状态,VO 就是这个「视图拼装盘」。
MovementsVo 的字段按来源分成三组(实读自 mf-model 模块):
| 分组 | 字段 | 来源 |
|---|---|---|
| 动态本体 | id、textContent、imageContent、createDate |
Movement(medias 转为 imageContent 数组,created 时间戳格式化) |
| 用户资料 | userId、avatar、nickname、gender、age、tags、distance |
UserInfo(tags 按逗号拆成数组;distance 为模拟值) |
| 互动状态 | likeCount、commentCount、loveCount、hasLiked、hasLoved |
本阶段写死 0,后续点赞/评论功能回填 |
类骨架与转换方法:
java
package com.mf.model.vo;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Movement;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.util.StringUtils;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MovementsVo implements Serializable {
private String id; // 动态id(ObjectId 转字符串)
private Long userId; // 用户id
private String avatar; // 头像
private String nickname; // 昵称
private String gender; // 性别 man woman
private Integer age; // 年龄
private String[] tags; // 标签
private String textContent; // 文字动态
private String[] imageContent; // 图片动态
private String distance; // 距离
private String createDate; // 发布时间
private Integer likeCount; // 点赞数
private Integer commentCount; // 评论数
private Integer loveCount; // 喜欢数
private Integer hasLiked; // 是否点赞(1是,0否)
private Integer hasLoved; // 是否喜欢(1是,0否)
public static MovementsVo init(UserInfo userInfo, Movement item) {
MovementsVo vo = new MovementsVo();
// 动态本体:同名属性浅拷贝
BeanUtils.copyProperties(item, vo);
vo.setId(item.getId().toHexString());
// 用户资料:同名属性浅拷贝,tags 拆分
BeanUtils.copyProperties(userInfo, vo);
if (!StringUtils.isEmpty(userInfo.getTags())) {
vo.setTags(userInfo.getTags().split(","));
}
// 图片列表:List<String> 转数组
vo.setImageContent(item.getMedias().toArray(new String[]{}));
// 距离为模拟数据,实际应由地理位置计算
vo.setDistance("500米");
// 发布时间格式化
Date date = new Date(item.getCreated());
vo.setCreateDate(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date));
// 互动状态本阶段占位,后续功能实现时回填
vo.setHasLoved(0);
vo.setHasLiked(0);
vo.setLikeCount(0);
vo.setLoveCount(0);
vo.setCommentCount(0);
return vo;
}
}
几个转换细节值得留意:
id必须显式toHexString()。Movement 的主键是ObjectId,直接 BeanUtils 拷贝时类型对不上(VO 里是 String),序列化给前端会是嵌套对象。- 两次
copyProperties有顺序讲究:先拷 Movement 再拷 UserInfo,同名字段(如userId)以后拷的为准。两边的 userId 值一致所以无感,但换个场景这就是隐患。 medias可能为null(纯文字动态没传图),toArray前没有判空,纯文字动态在这版代码里会 NPE,实测发布接口强制要求文字但图片可选,这是个潜在坑。
互动计数从哪来、N+1 问题
VO 里最特殊的是互动状态这一组。实读 movement 集合的实体类,它的字段只有 pid、created、userId、textContent、medias、经纬度、locationName、state,没有任何计数字段 。所以本阶段 likeCount 等五个值写死 0,是明确的工程现状,源码注释里也写了「后续处理」。
后续实现点赞、评论功能时,计数有两个可选来源:
| 方案 | 做法 | 代价 |
|---|---|---|
| 冗余计数 | 点赞时直接更新 movement 文档上的计数字段 |
查询零成本,写入有并发计数竞争 |
| 现场统计 | 查 VO 时去 comment 集合按 movementId 做 count |
写入简单,每条动态多一次 count 查询 |
项目后续章节实际采用的是「movement 上冗余计数字段 + Redis 记录点赞人」的组合。这里先给结论存档:这里写死 0 不影响功能骨架,计数是纯增量改造。
hasLiked 的逻辑更绕一点:它不是动态的属性,而是「当前登录用户与这条动态的关系」。查询时除了动态列表,还要拿当前用户的点赞记录集合做交集。未来真实现时,最直观的写法是循环里逐条查,N 条动态 N 次查询,典型的 N+1。批查方案是固定的三板斧:
java
// 1. 收集本页所有动态 id
List<ObjectId> ids = items.stream().map(Movement::getId).collect(Collectors.toList());
// 2. 一次 in 查询拿回当前用户对这些动态的点赞记录
Criteria criteria = Criteria.where("userId").is(UserHolder.getUserId())
.and("movementId").in(ids);
List<Comment> comments = mongoTemplate.find(Query.query(criteria), Comment.class);
// 3. 按 movementId 分组成 Set,组装 VO 时 O(1) 判断
Set<ObjectId> likedIds = comments.stream()
.map(Comment::getMovementId).collect(Collectors.toSet());
3 次查询固定开销,与页大小无关。这里只需知道 VO 为这个字段留了位置、实现时别写进循环即可。
顺带一提用户资料的查询:本接口一页动态全部属于同一个 userId,UserInfo 在循环外查一次、循环内复用,天然没有 N+1。这是「我的动态」比「好友动态」好组装的地方,好友动态列表里每条动态的发布者都不同,用户资料反而需要批查。
组装流程与时序图
Service 层的组装步骤,机翻稿归纳为两步:「第一个构建 VO;接着第二个 API 层根据当前的用户 ID 查询动态数据,但是查询的动态数据没有用户详情,那怎么办?所以要根据 API 封装 VO 对象。」翻译成设计语言:
- Dubbo API 层只负责数据:按
userId从movement分页查询,返回PageResult<Movement>; - Service 层负责视图:补查
UserInfo,逐条调用MovementsVo.init拼装,替换items后返回。
为什么不在 API 层直接返回 VO?因为 VO 依赖 UserInfoApi,把用户查询塞进 MovementApi 会造成 Dubbo 服务间的横向依赖;数据归数据、视图归视图,Service 是天然的装配层。
完整时序:
MySQL(userinfo) UserInfoApi(Dubbo) MongoDB(movement) MovementApi(Dubbo) MovementService MovementController 客户端 MySQL(userinfo) UserInfoApi(Dubbo) MongoDB(movement) MovementApi(Dubbo) MovementService MovementController 客户端 #mermaid-svg-OMPBDUYsOrHkdfo3{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-OMPBDUYsOrHkdfo3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OMPBDUYsOrHkdfo3 .error-icon{fill:#552222;}#mermaid-svg-OMPBDUYsOrHkdfo3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OMPBDUYsOrHkdfo3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OMPBDUYsOrHkdfo3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OMPBDUYsOrHkdfo3 .marker.cross{stroke:#333333;}#mermaid-svg-OMPBDUYsOrHkdfo3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OMPBDUYsOrHkdfo3 p{margin:0;}#mermaid-svg-OMPBDUYsOrHkdfo3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OMPBDUYsOrHkdfo3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OMPBDUYsOrHkdfo3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-OMPBDUYsOrHkdfo3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-OMPBDUYsOrHkdfo3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-OMPBDUYsOrHkdfo3 .sequenceNumber{fill:white;}#mermaid-svg-OMPBDUYsOrHkdfo3 #sequencenumber{fill:#333;}#mermaid-svg-OMPBDUYsOrHkdfo3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-OMPBDUYsOrHkdfo3 .messageText{fill:#333;stroke:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OMPBDUYsOrHkdfo3 .labelText,#mermaid-svg-OMPBDUYsOrHkdfo3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .loopText,#mermaid-svg-OMPBDUYsOrHkdfo3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .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-OMPBDUYsOrHkdfo3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OMPBDUYsOrHkdfo3 .noteText,#mermaid-svg-OMPBDUYsOrHkdfo3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-OMPBDUYsOrHkdfo3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OMPBDUYsOrHkdfo3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OMPBDUYsOrHkdfo3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OMPBDUYsOrHkdfo3 .actorPopupMenu{position:absolute;}#mermaid-svg-OMPBDUYsOrHkdfo3 .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-OMPBDUYsOrHkdfo3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OMPBDUYsOrHkdfo3 .actor-man circle,#mermaid-svg-OMPBDUYsOrHkdfo3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-OMPBDUYsOrHkdfo3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 遍历 items GET /movements/all?userId&page&pagesize 参数绑定(page=1, pagesize=10 默认值) findByUserId(userId, page, pagesize) findByUserId(userId, page, pagesize) Query(userId=?).skip().limit().with(sort created desc) List<Movement> PageResult(counts=0, items=movements) findById(userId) selectById UserInfo UserInfo MovementsVo.init(userInfo, movement) pr.setItems(vos) PageResult<MovementsVo> 200 OK + JSON
注意排序字段:Sort.by(Sort.Order.desc("created")),按发布时间戳倒序,这正是时间线「最新在最上」的语义;skip((page-1)*pagesize) 与 limit(pagesize) 组成标准偏移分页。
移动端原型
原型资料(交友项目移动端原型 V1.2)的「我的.html」里只有「我的动态」这个入口,没有独立的列表页设计稿,下面的线框按接口返回字段推演:
doc
+--------------------------------------+
| ← 我的动态 (10条) |
+--------------------------------------+
| [头像] 黑而言 ♡22 · ♢5 |
| 单身 | 本科 | 年龄相仿 |
| |
| 你的酒窝没有酒,我却醉的像条狗 |
| +------+ +------+ |
| | 图片 | | 图片 | +------ |
| +------+ +------+ |
| 2020-12-26 13:30 · 距离500米 |
| ♡ 点赞 12 💬 评论 5 |
|--------------------------------------|
| [头像] 黑而言 |
| ... |
| |
| (上拉加载更多 ↓) |
+--------------------------------------+
要点:
- 顶部导航返回上一页(「我的」页),标题即功能名;
- 每条动态卡片:头像 + 昵称取自
nickname/avatar,标签行对应tags数组; - 正文区渲染
textContent,九宫格渲染imageContent数组(0 张时不显示图片区); - 底部互动行渲染
likeCount/commentCount,点赞图标的高亮状态由hasLiked决定,本阶段恒为 0,图标恒灰; - 滚动触底按
page + 1请求下一页;因counts/pages恒为 0,「没有更多」只能用items.length < pagesize判断; - 时间显示
createDate,原型上可再按「X 分钟前」做相对时间优化。
异常与边界
| 场景 | 现状行为 | 期望与说明 |
|---|---|---|
| userId 未传(null) | 查询条件 userId=null,返回空 items,不报错 |
应兜底为 UserHolder.getUserId(),见上文改进代码 |
| 分页越界(page 很大) | skip 超出总量,返回空 items | 合法行为,前端按「没有更多」处理 |
| 用户从未发过动态 | 空 items | 正常空态页,App 显示「还没有发布过动态」 |
| page 或 pagesize 传 0/负数 | skip 为负数,MongoDB 抛异常 |
应校验参数,非法时回退默认值或返回 400 |
| 查看他人主页 | 返回该 userId 的全部动态 | 未做可见性过滤:movement.state 字段(0 未审/1 通过/2 驳回)理应参与过滤,未审或驳回的动态不该出现在别人眼里;私密可见范围的约束同理。当前版本只实现了「查自己」的主链路 |
| counts/pages | 恒为 0 | 前端不能依赖总页数,用 items 数量判断翻页结束 |
验证要点
测试路径按这个顺序走,能覆盖主流程与边界:
- 前置数据:用测试类或 App 先给用户 106 发布 2~3 条动态(含纯文字、带图两种形态);
- 基本查询 :
GET /movements/all?userId=106&page=1&pagesize=10,确认返回 items 数量正确、按发布时间倒序; - 字段完整性 :核对单条 item 的
id(字符串而非 ObjectId 嵌套)、nickname/avatar(用户资料已拼入)、imageContent(medias 已转数组)、createDate(已格式化); - 分页:pagesize=1 时翻页,确认第二条动态出现在 page=2;
- 空态:换一个从未发动态的 userId,确认返回空 items 而非报错;
- 占位字段 :确认
likeCount等五项互动字段存在且为 0(后续功能交付时再更新此断言)。
常见误解澄清
| 误解 | 事实 |
|---|---|
查我的动态要去查 movement_timeline |
不对。timeLine 是好友动态的收件箱,我的动态直查 movement 按 userId 过滤 |
| userId 不传时接口会自动查当前登录人 | 本版代码没有这个兜底,userId 为 null 会查空;需自行补 UserHolder.getUserId() |
likeCount 是实时统计出来的 |
本阶段写死 0,计数字段要等点赞/评论功能实现后才有真实值 |
| PageResult 的 counts 是真实总数 | API 层写死 0,pages 恒 0,前端翻页结束要用 items 数量判断 |
distance 是计算出的真实距离 |
写死「500米」,真实距离需要地理位置计算,项目未实现 |
| 每条动态要查一次用户信息 | 同一页动态同属一个用户,UserInfo 循环外查一次即可;好友动态列表才需要批查 |
API 速览
| 接口 | 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
/movements/all |
GET | userId?、page=1、pagesize=10 |
PageResult<MovementsVo> |
查询我的动态;看他人主页传对方 userId |
关键依赖:MovementApi.findByUserId(Dubbo,MongoDB 偏移分页)、UserInfoApi.findById(Dubbo,用户资料)、UserHolder.getUserId()(ThreadLocal 当前登录人)。
官方文档
总结
「查询我的动态」需求本身只是一次按 userId 的分页查询,设计的重心全在返回结构上:Movement 和 UserInfo 两个存储模型拼成一个面向前端的 MovementsVo,互动状态字段提前占位、为后续点赞评论功能留好接口。几个值得带走的结论:我的动态查原稿集合 movement,与好友动态的收件箱 movement_timeline 是两条路;userId 可选背后是「查自己走 token、查他人传 ID」的双场景设计,但当前代码缺 token 兜底;counts 写死 0 意味着翻页结束必须由前端自行判断。
下一篇查询圈子个人动态:代码实现已经把 Service 与 Dubbo API 层的完整代码展开过了,之后是查询圈子个人动态:测试与总结,会用真实数据把这条链路完整跑一遍。