Java 项目实战: 交友项目 - 发布圈子动态:数据库表和接口文档介绍 概述发布动态这个功能要动两样东西一个movement集合、一个POST /movements接口。这里把这两样一次讲透字段为什么这么设计、媒体数据为什么嵌进文档而不是拆表、带文件上传的表单接口和之前见过的纯 JSON 接口差在哪。纲要数据模型movement集合Movement实体完整代码字段逐一分析pid / userId / textContent / medias / locationName / longitude / latitude / state / createdmedias数组嵌入 vs 单独建 media 表的取舍pid与_id业务键 ≠ 主键接口文档POST /movementsmultipart 表单第三种接口形态请求参数表 请求示例发布流程概述细节留给代码实现篇原型还原发动态页面线框图坑与验证孤儿文件、文档 16MB 上限、mongosh 样例验证这里在圈子系列中的位置圈子的整体表设计为什么选 MongoDB、四张表怎么分工在圈子表设计方案里已经讲过点赞、评论、喜欢这些互动数据的存储后面互动章节会单独展开。这里只聚焦一件事动态主表movement的字段设计以及「发动态」这一个接口的定义。时间线表movement_timeline、好友表friend只在流程图里露个脸不展开。movement 集合动态主表实体定义工程实读mf-model模块下的com.mf.model.mongo.Movement这是一个 MongoDB 实体用Document声明集合名packagecom.mf.model.mongo;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importorg.bson.types.ObjectId;importorg.springframework.data.mongodb.core.mapping.Document;importjava.util.List;//动态详情表DataNoArgsConstructorAllArgsConstructorDocument(collectionmovement)publicclassMovementimplementsjava.io.Serializable{privateObjectIdid;//主键id//redis实现使用Mongodb实现privateLongpid;//Long类型用于推荐系统的模型(自动增长)privateLongcreated;//发布时间privateLonguserId;privateStringtextContent;//文字privateListStringmedias;//媒体数据图片或小视频 urlprivateStringlongitude;//经度privateStringlatitude;//纬度privateStringlocationName;//位置名称privateIntegerstate0;//状态 0未审默认1通过2驳回}几个实读细节值得先说清没有继承BasePojo。项目里部分 Mongo 实体会抽公共字段created/updated到父类Movement没这么做created直接平铺在实体里取值是毫秒时间戳由 API 层在保存前手动赋值。没有updated字段。动态发出去之后几乎不改编辑朋友圈都不支持所以只留创建时间。没有用Field注解。Java 字段名和文档字段名一一对应textContent就存成textContent不需要改名映射。id字段没加Id但 Spring Data MongoDB 对名为id的ObjectId字段会自动识别为主键落库时若为 null 会自动生成。_class字段是 Spring Data 自动写入的类型标识代码里看不到查询文档时能看到。字段逐个看设计理由字段类型含义设计理由idObjectIdMongoDB 主键集合默认主键自动生成全局唯一pidLong发布唯一标识业务 id给推荐系统用的数字自增 id底层用 MongoDBfindAndModify $inc操作 sequence 集合保证递增工程早期注释写「redis实现」实读 IdWorker 已切到 Mongo 实现。推荐模型算相似度时需要一个纯数字、有序的标识ObjectId 那串十六进制不好使userIdLong发布者 id关联user_info表。存 id 不存昵称头像用户改昵称不用回刷所有动态textContentString文字内容原型约束 140 字以内纯文本存储mediasListString媒体地址数组只存 URL图片本体在 OSS。0 到 6 个元素图片和小视频混用同一字段longitudeString经度冗余存位置为「附近的人/同城动态」预留latitudeString纬度同上locationNameString位置名称客户端定位后传上来的中文描述如「中国上海市奉贤区人民路445弄」直接展示用stateInteger审核状态默认 0未审1 通过2 驳回。内容审核是异步链路先落库再审createdLong发布时间毫秒API 层保存前用System.currentTimeMillis()赋值按一条真实文档看更直观讲义样例_class那行是 Spring Data 自动写的类型标识{_id:ObjectId(5e82dc416401952928c211d8),pid:NumberLong(10064),userId:NumberLong(6),textContent:最悲伤却又是最痛苦的谎言就是我还好没有关系。,medias:[https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1.jpg,https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1564567349498.jpg,https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/7/1564567352977.jpg],longitude:121.588627,latitude:30.935781,state:NumberInt(0),locationName:中国上海市奉贤区人民路445弄,created:NumberLong(1585634369493),_class:com.mf.dubbo.server.pojo.Publish}顺带指个教材里的坑样例里_class写的是com.mf.dubbo.server.pojo.Publish那是另一版项目工程的类名残留这里配套代码的实体是com.mf.model.mongo.Movement字段名两者一致别被类名带偏。medias 用数组嵌入而不是单独建 media 表这是movement设计里最值得说的决定。一条动态最多 6 张图图片和动态是典型的「包含关系」图片离开动态没有独立意义也不会被单独查询、单独分页。放到关系型数据库里你可能习惯拆一张movement_media表用外键关联在 MongoDB 里直接把 URL 数组嵌进文档。两种方案对比维度数组嵌入实际方案单独建 media 集合读取一条动态一次查询文字图片全拿到查动态 按 movementId 查媒体两次查询写入与动态同文档原子写入两次写入需要自己保证一致性查「包含某张图的动态」没这需求无所谓天然支持数组膨胀风险最多 6 个 URL可控无文档大小每个 URL 约 70 字节6 个不到 1KB无有人会拿 MongoDB 文档 16MB 上限说事那条限制是针对整个文档的但这里只存 URL 不存图片本体图片二进制放在 OSS。就算存满 6 张图的 URL 加上正文离 16MB 还差好几个数量级。嵌入的前提是「子数据小、有界、只随父文档一起读写」medias三条全占所以放心嵌。这也呼应了体系结构篇说过的原则嵌入式文档表达包含关系引用表达关联关系。图片「属于」动态嵌用户「发布」动态但用户是独立实体只存userId引用。位置信息为什么冗余存三个字段longitude、latitude、locationName三个字段一起存看起来重复经纬度算一下不就有位置了反过来才对经纬度能算出距离算不出「中国上海市奉贤区人民路445弄」这个给人看的名字逆地理编码是客户端定位时就已经做好的直接传上来存住展示时零成本。更重要的是经纬度留在文档里是为后面「附近的人」「同城动态」铺路MongoDB 支持地理位置索引2dsphere建了索引就能按距离检索动态。这里一句点到检索怎么建索引、怎么算距离留给附近的人那章。pid 与 _id业务键 ≠ 主键idObjectId是存储层主键pid是业务层的发布唯一标识两套并存。这个套路前面反复出现过业务需要一个有业务含义、有序、数字化的标识时单独造一个业务键别污染主键。pid的生成在 API 层保存动态前通过 IdWorker底层 Redis 自增取号movement.setPid(idWorker.getNextId(movement));movement.setCreated(System.currentTimeMillis());mongoTemplate.save(movement);为什么推荐系统不直接用_idObjectId 虽然宏观递增但它是 12 字节的十六进制串做向量相似度、排序模型输入时不如一个干净的 Long 好处理而且pid语义上是「第几条发布的动态」数字大小直接反映先后顺序。接口文档POST /movements第三种接口形态到这篇为止项目里出现了三种接口形态值得放一起对照形态代表接口Content-Type服务端接收方式纯 JSON登录POST /login登录篇application/jsonRequestBody绑定纯文件头像上传POST /users/header头像篇multipart/form-data单个MultipartFileJSON 字段 文件混合发动态POST /movementsmultipart/form-data实体对象 MultipartFile[]发动态要同时提交文字、位置这些结构化字段和若干张图片文件于是整个请求走 multipart 表单文本字段和文件都是表单里的普通 part。注意一个容易踩的点既然是表单提交Controller 就不能加RequestBody了它只认 JSON 请求体Spring MVC 会按表单字段名自动绑定到Movement对象的同名属性上。接口定义请求路径POST /movements请求方式POSTmultipart/form-data请求参数参数类型必填说明textContentString是动态文字内容140 字以内longitudeString否经度latitudeString否纬度locationNameString否位置的中文描述imageContentMultipartFile[]否图片文件列表客户端一次上传多张或视频服务端用MultipartFile[]接收响应发布成功返回状态码 200响应体为空ResponseEntity.ok(null)内容为空时抛业务异常返回统一错误结构。请求示意用 Postman 或前端 FormData 发一个典型请求长这样POST /movements HTTP/1.1 Host: mf-server Content-Type: multipart/form-data; boundary----WebKitFormBoundary ------WebKitFormBoundary Content-Disposition: form-data; nametextContent 今天天气不错 ------WebKitFormBoundary Content-Disposition: form-data; namelongitude 121.588627 ------WebKitFormBoundary Content-Disposition: form-data; namelatitude 30.935781 ------WebKitFormBoundary Content-Disposition: form-data; namelocationName 中国上海市奉贤区人民路445弄 ------WebKitFormBoundary Content-Disposition: form-data; nameimageContent; filenamea.jpg Content-Type: image/jpeg 二进制图片数据 ------WebKitFormBoundary Content-Disposition: form-data; nameimageContent; filenameb.jpg Content-Type: image/jpeg 二进制图片数据 ------WebKitFormBoundary--多张图片用同名参数imageContent重复出现Spring MVC 会把它们收进MultipartFile[]数组。前端对应的 FormData 写法是对同一个 key 多次append(imageContent, file)。发布流程概述接口背后的完整链路为代码实现篇铺垫客户端提交表单Controller 接收 Movement MultipartFile 数组校验 textContent 非空逐张上传 OSS 换取 URL组装 Movement: userId / mediasAPI 层: 生成 pid / createdmovement 集合异步写入好友时间线三步主干图片先传 OSS 拿 URL → 组装 Movement 对象 →mongoTemplate.save落库。落库之后还要往每个好友的时间线表里写一条记录这一步项目里已经改成了Async异步执行异步改造的前因后果在发布圈子动态代码优化异步处理的改造一篇里展开这里不重复。服务端接收侧的核心代码Controller 签名部分RestControllerRequestMapping(/movements)publicclassMovementController{AutowiredprivateMovementServicemovementService;/** * 发布动态 */PostMappingpublicResponseEntitymovements(Movementmovement,MultipartFileimageContent[])throwsIOException{movementService.publishMovement(movement,imageContent);returnResponseEntity.ok(null);}}原型还原发动态页面从原型圈子.html还原发布入口和发布页两屏---------------------------------- | - 返回 发动态 | ---------------------------------- | | | ---------------------------- | | | 请填写动态140字以内 | | | | | | | | | | | ---------------------------- | | | | [图1] [图2] [图3] | | [图4] [图5] [] | | | | 定位 | | | | [ 上传 ] | | | -------------------------------------------------------------------- | 选择照片 返回 | ---------------------------------- | | | [img] [img] [img] | | [img] [img] [img] | | [img] [img] [img] | | | | 最多可选6张 | | | ----------------------------------要点文案取自原型原文「请填写动态140字以内」、「选择照片」、「上传」、「最多可选6张」。点击九宫格的「」进入相册选择列表一次最多可以选择6张照片超过6张后就不能点击了这个 6 正是服务端medias数组长度上限的产品侧来源。关于字数原型的说法是「内容不进行字数限制但是不能超过140字」听起来矛盾实际含义是服务端不做强制截断140 字是前端提示口径。「定位」控件取到经纬度和地名对应表里的longitude/latitude/locationName三字段。坑表坑说明前端传参与medias的对应前端对同名 keyimageContent多次 append服务端才收得到数组名字对不上MultipartFile[]就是 null循环上传时直接 NPE文本与图片的事务边界图片先传 OSS 成功、随后落库失败OSS 上就留下没人引用的孤儿文件。MongoDB 单文档写入是原子的但「OSS MongoDB」跨存储没有事务只能靠兜底清理或容忍孤儿文件。头像篇已经遇到过同款问题思路一致位置信息隐私经纬度是用户实时位置属于敏感数据接口层面这部分参数是选填客户端要给「不显示位置」的选项存储和展示都要考虑脱敏动态页只显示「距离你 20km」这类模糊值文档大小上限单个 MongoDB 文档上限 16MB。medias只存 URL大小无忧但如果哪天有人想直接把图片 Base64 塞进文档6 张图轻松超限红线是「媒体只存 URL」讲义样例的_class讲义文档样例里_class是旧工程类名Publish以这里实体Movement为准字段定义两者一致验证要点集合不用手动建mongoTemplate.save首次落库时自动创建。想提前验证字段设计可以手动插一条样例再查回来// mongoshuse mf// 插入一条样例动态db.movement.insertOne({pid:NumberLong(10001),userId:NumberLong(106),textContent:测试动态,medias:[https://mf-dev.oss-cn-zhangjiakou.aliyuncs.com/photo/106/1.jpg],longitude:121.588627,latitude:30.935781,locationName:中国上海市奉贤区人民路445弄,state:NumberInt(0),created:NumberLong(1585634369493)})// 查询确认db.movement.find({userId:NumberLong(106)}).pretty()查询结果里_id已由 MongoDB 自动补上 ObjectId_class也自动写入这两点正是「主键交给存储层、业务键自己管」的直观体现。API 速览接口方法参数形态说明/movementsPOSTmultipart/form-data发布动态表单字段绑定 MovementimageContent接收多文件成功返回 200 空体官方文档Spring Data MongoDB - MappingDocument、ObjectId主键映射、_class字段的官方说明MongoDB 文档大小限制16MB 上限的权威出处常见误解澄清误解澄清图片存在 MongoDB 里medias只存 OSS 的 URL图片二进制根本不进数据库medias 该拆成单独的媒体表媒体与动态是包含关系、数量有界≤6、只随动态一起读写嵌入数组是最优解拆表反而多一次查询_id就是动态 id够了推荐系统需要数字有序的pid_id是 ObjectId两套并存各司其职发布接口是 JSON 提交带文件上传整个请求是 multipart 表单Controller 不能用RequestBodymovement的created来自 BasePojo实读实体没有继承 BasePojocreated平铺在实体里、API 层手动赋值没有审核概念发出去就能刷到有state字段0 未审 / 1 通过 / 2 驳回内容审核是独立异步链路总结movement集合是动态主表pid业务自增 id给推荐系统、textContent、mediasURL 数组、位置三字段、state审核状态、created毫秒时间戳。medias数组嵌入是这个设计的题眼子数据小、有界、只随父读写嵌进文档一次查询拿全图片只存 URL16MB 上限形同虚设。位置三字段冗余存经纬度为地理索引预留locationName为直接展示省掉逆地理编码。POST /movements是项目里第三种接口形态multipart 表单同时装文本字段和多文件字段名imageContent对应MultipartFile[]。发布链路主干传 OSS → 组装对象 → 落库时间线写入已异步化细节见异步改造篇。下一篇发布圈子动态业务流程和代码步骤分析把发布链路拆成一步步的代码动作Controller、Service、API 三层怎么协作那里见。