Java 项目实战: 交友项目 - 查询单条动态:动态详情页的接口与实现

概述

在好友动态、推荐动态列表里点开一张卡片,进入的就是动态详情页。这里解决这个页面的数据来源问题:按动态 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 按发布 → 列表 → 详情的顺序走一遍闭环:

  1. POST /movements 发布一条动态(携带表单参数 textContent 与图片);
  2. GET /movements?page=1&pagesize=10 查好友动态列表,从 items 里取第一条的 id(注意取的是 _id 十六进制串,不是 pid);
  3. GET /movements/{id} 传上一步的 id,核对返回字段与列表里那条一致:textContent、imageContent、likeCount 等;
  4. GET /comments?movementId={id}&page=1&pagesize=10 查评论列表,核对 commentType=2 的记录才出现在结果里(点赞/喜欢记录不混入);
  5. 反向验证:随便编一个 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 字段,马上会正面登场。

相关推荐
yuniko-n3 小时前
【JUC】CAS
java·开发语言
captain3763 小时前
Spring Web MVC(2)
java·前端·spring boot·后端·spring·mvc
紧跟先前的步伐3 小时前
Go AI Agent 实战 17|模型适配层设计:让模型可替换
开发语言·人工智能·后端·ai·golang
小灰灰搞电子3 小时前
Qt 中使用 QSettings 存储结构体数据详解
开发语言·qt
代码山河3 小时前
调试技巧:IDEA Debug模式入门与使用
java·学习·架构·教程·面向对象·项目
外收内放3 小时前
FastAPI猜字谜实战(5):会话管理
开发语言·python·学习·fastapi
Ai-_Man3 小时前
Microsoft Copilot对话数据怎么导入到另外一个Microsoft Copilot账号里去?AI导出鸭
开发语言·javascript·人工智能·microsoft·chatgpt·ecmascript·copilot
H.莓飛3 小时前
【数据结构】归并与外排序
linux·服务器·c语言·开发语言·数据结构·visual studio
文丑颜不良啊3 小时前
LeetCode851~875题目解析
java·leetcode