Java 项目实战: 交友项目 - 查询圈子个人动态:查询我的动态需求分析

概述

发布动态之后,用户点进「我的」页面看到的那个时间线,数据从哪来、接口长什么样、返回的 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 对象。」翻译成设计语言:

  1. Dubbo API 层只负责数据:按 userId 从 movement 分页查询,返回 PageResult<Movement>;
  2. 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 数量判断翻页结束

验证要点

测试路径按这个顺序走,能覆盖主流程与边界:

  1. 前置数据:用测试类或 App 先给用户 106 发布 2~3 条动态(含纯文字、带图两种形态);
  2. 基本查询 :GET /movements/all?userId=106&page=1&pagesize=10,确认返回 items 数量正确、按发布时间倒序;
  3. 字段完整性 :核对单条 item 的 id(字符串而非 ObjectId 嵌套)、nickname/avatar(用户资料已拼入)、imageContent(medias 已转数组)、createDate(已格式化);
  4. 分页:pagesize=1 时翻页,确认第二条动态出现在 page=2;
  5. 空态:换一个从未发动态的 userId,确认返回空 items 而非报错;
  6. 占位字段 :确认 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 层的完整代码展开过了,之后是查询圈子个人动态:测试与总结,会用真实数据把这条链路完整跑一遍。

相关推荐
denlaku1 小时前
JDK 23 新特性详解
java
凯瑟琳.奥古斯特1 小时前
力扣1016高效解法:剪枝优化技巧
开发语言·c++·算法·leetcode·职场和发展
code2cat1 小时前
Java高级篇之Hibernate:让对象映射到表,理解持久化与事务
java·后端·hibernate·1024程序员节
骑着蜗牛撵大象3272 小时前
飞牛fnOS部署Damselfly:Compose编排、照片目录与7100固定访问
java·docker·rpc·dubbo·fnos·照片管理·damselfly
坤坤子吖2 小时前
C++继承:友元、静态成员、多继承与组合
开发语言·c++·笔记·学习·1024程序员节
技术小甜甜2 小时前
【Cursor】Skill 被标黄色警告,怎么改成合格的 SKILL.md
开发语言·javascript·ai编程·cursor·1024程序员节·ai agent·skills
金銀銅鐵3 小时前
[Java] 用 CLI 展示 EnumSet 增减元素时的内部变化(下)
java·后端·ai编程
小小龙学IT3 小时前
Python Polars 高性能数据处理引擎深度解析
开发语言·python·rust·1024程序员节
Nuanyt3 小时前
Hot 100 刷题笔记 · 数组与哈希专题(3 题)
java·笔记·算法·leetcode·哈希算法·1024程序员节