仿小红书源码中的内容发布,并不是把标题、正文和图片写入数据库后就结束了。
一篇图文笔记从用户选择图片开始,需要经过文件上传、媒体信息记录、草稿保存、内容审核、状态更新、搜索索引和内容分发。短视频发布还会增加视频封面、播放时长、文件转码、异步审核等处理步骤。
如果把所有操作都放在一个接口中同步执行,只要文件存储、内容审核或搜索服务响应变慢,整个发布接口就会被拖慢。用户可能长时间停留在提交页面,数据库连接和应用线程也会持续被占用。
更合理的处理方式,是把内容发布拆成多个独立环节:
媒体文件先上传,业务数据后提交;草稿与正式作品分开处理;审核任务异步执行;内容通过后再进入搜索和推荐。
在这套流程中,Spring Boot 负责业务编排,对象存储负责图片和视频文件,内容审核服务负责识别不合规信息,Quartz 负责异常任务补偿,EasyES 负责公开内容检索。

从用户点击发布开始,后台发生了什么
以一篇包含六张图片、两个话题标签的图文笔记为例,用户点击发布后,后台并不是只执行一条插入语句。
整个链路可以拆成以下步骤:
javascript
选择图片或视频
↓
获取文件上传凭证
↓
上传至对象存储
↓
返回文件地址、宽高和资源标识
↓
提交正文、分类、话题及媒体列表
↓
创建待审核内容
↓
执行文字、图片或视频审核
↓
审核通过后公开展示
文件上传和内容发布分离后,媒体上传失败不会直接影响正文数据。
用户上传三张图片后临时退出,也可以把现有内容保存到草稿箱。再次进入发布页面时,系统读取草稿中的正文、图片顺序、分类和话题,不需要重新选择所有文件。
对于短视频,文件上传独立处理后,还可以继续完成封面生成、视频时长读取、清晰度检查和文件转码。
发布接口不直接接收文件流
在仿小红书源码中,一篇内容可能包含多张高清图片,也可能包含体积较大的短视频。
如果客户端把文件流、正文、话题标签和分类信息一次性提交,接口会长时间占用连接。网络出现波动时,还可能出现正文提交成功,但部分图片上传失败的情况。
因此,内容发布接口更适合只接收已经上传完成的媒体信息。
javascript
{
"title": "城市周边徒步记录",
"contentText": "整理了几处适合周末散步和拍照的位置。",
"contentType": "IMAGE_TEXT",
"categoryId": 8,
"topicIds": [16, 31],
"mediaList": [
{
"fileUrl": "/content/image/2026/07/001.jpg",
"mediaType": "IMAGE",
"width": 1080,
"height": 1440,
"sortNumber": 1
},
{
"fileUrl": "/content/image/2026/07/002.jpg",
"mediaType": "IMAGE",
"width": 1080,
"height": 1350,
"sortNumber": 2
}
]
}
媒体数据不能只保存文件地址。
仿小红书源码的发现页通常采用双列瀑布流布局,客户端需要根据封面图片宽高比例提前计算卡片高度。如果接口只返回图片地址,页面需要等待图片加载完成后才能确定高度,容易出现卡片跳动和布局重排。
多图内容还要保存排序字段。用户在发布页面调整图片顺序后,内容详情、作品编辑和个人主页都必须按照相同顺序展示。
短视频媒体可以额外保存以下信息:
- 视频时长;
- 视频宽高;
- 视频封面;
- 文件大小;
- 转码状态;
- 审核状态;
- 播放地址。

草稿与正式发布不是同一个流程
草稿保存强调灵活,正式发布强调完整。
用户保存草稿时,可能只输入一段正文,还没有选择分类;也可能上传了两张图片,但准备稍后继续补充。此时不应该要求所有字段完整,更不需要立即进入内容审核。
草稿阶段只需要检查基础数据:
| 校验项目 | 处理目的 |
|---|---|
| 用户身份 | 防止未登录状态保存 |
| 文件归属 | 防止引用其他用户资源 |
| 媒体数量 | 避免超过系统限制 |
| 文件类型 | 阻止不支持的格式 |
| 草稿状态 | 避免修改已删除数据 |
用户点击正式发布后,再执行完整校验,包括正文长度、内容分类、话题标签、媒体数量、内容类型和可见范围。
草稿保存后只出现在个人主页的作品管理中,不进入发现页、关注页、附近页、话题页和内容搜索结果。
用状态流转管理内容生命周期
一篇图文或短视频从创建到公开,往往会经历多个阶段。
如果内容表只有"正常"和"删除"两个状态,无法区分草稿、待审核、审核失败和后台下架等场景。
可以将内容状态划分为:
|------------|-----------|
| 状态 | 说明 |
| DRAFT | 草稿,只有作者可见 |
| WAIT_AUDIT | 已提交,等待审核 |
| AUDITING | 审核任务处理中 |
| PUBLISHED | 已公开展示 |
| REJECTED | 审核未通过 |
| HIDDEN | 作者主动隐藏 |
| OFFLINE | 后台下架 |
| DELETED | 逻辑删除 |
状态划分后,不同页面可以使用明确的查询条件。
发现页、关注页、附近页、内容搜索和话题聚合,只返回 PUBLISHED 状态的数据。个人作品管理则可以展示草稿、审核中、已发布和审核未通过的作品。
收藏管理和浏览记录可以保留历史关系,但内容已经删除或下架时,不能继续返回完整详情。

Spring Boot只负责提交核心业务数据
正式发布时,核心事务只处理内容主表、媒体表、话题关系和审核任务,不应在事务中等待审核结果。
javascript
@Transactional(rollbackFor = Exception.class)
public Long publish(ContentPublishDTO dto, Long userId) {
validatePublishRequest(dto, userId);
Content content = new Content();
content.setUserId(userId);
content.setTitle(dto.getTitle());
content.setContentText(dto.getContentText());
content.setContentType(dto.getContentType());
content.setCategoryId(dto.getCategoryId());
content.setStatus(ContentStatus.WAIT_AUDIT);
content.setCreateTime(LocalDateTime.now());
contentMapper.insert(content);
contentMediaService.saveBatch(
content.getId(),
dto.getMediaList()
);
contentTopicService.bindTopics(
content.getId(),
dto.getTopicIds()
);
auditTaskService.create(content.getId());
return content.getId();
}
这里的关键点是:数据库事务只创建审核任务,不直接执行远程审核请求。
内容审核属于外部调用,响应时间不可控。如果审核过程被放进数据库事务,只要调用超时,数据库连接就会长时间无法释放。
核心数据先完成入库,再由审核任务继续处理,可以降低事务复杂度,也方便记录失败原因和执行次数。

图文与短视频采用不同审核节奏
图文内容和短视频内容虽然共用发布入口,但审核方式并不相同。
图文内容分段审核
一篇图文笔记通常包含标题、正文、话题标签和多张图片。
可以按照以下顺序处理:
javascript
标题与正文审核
↓
话题文字审核
↓
多张图片并行审核
↓
汇总全部审核结果
↓
更新作品最终状态
只有文字和所有图片都通过,作品才能进入公开状态。
如果六张图片中有一张审核未通过,审核任务需要记录对应媒体 ID 和失败原因。用户进入作品编辑页面后,可以直接定位问题图片并完成替换。
短视频异步审核
短视频审核通常耗时更长,不适合让发布接口同步等待。
系统提交视频审核任务后,可以保存审核任务编号,通过回调接口或定时查询获取结果。视频封面和发布文案可以先完成审核,但视频结果没有确定前,内容不能公开。
审核任务表可以保存:
- 业务内容 ID;
- 审核类型;
- 任务状态;
- 外部任务编号;
- 已重试次数;
- 失败原因;
- 创建时间;
- 下次执行时间;
- 完成时间。
Quartz补偿长期未完成的审核任务
文件存储、内容审核和网络通信都可能发生异常。
例如,审核请求已经发出,但系统在保存结果前重启;或者视频审核已经完成,回调接口暂时不可用。此时内容可能长期停留在审核中。
Quartz 可以定时扫描异常任务:
- 长时间处于等待审核状态的内容;
- 已创建但尚未执行的审核任务;
- 调用失败且未达到重试上限的任务;
- 已提交但尚未取得结果的视频任务;
- 审核通过但后续处理未完成的内容。
javascript
@Component
public class AuditTaskRetryJob {
@Resource
private AuditTaskService auditTaskService;
@Scheduled(cron = "0 */5 * * * ?")
public void retryAuditTask() {
List<AuditTask> tasks =
auditTaskService.findRetryTasks(100);
for (AuditTask task : tasks) {
try {
auditTaskService.execute(task.getId());
} catch (Exception exception) {
auditTaskService.markFailed(
task.getId(),
exception.getMessage()
);
}
}
}
}
补偿任务不能无限重试。
任务表应记录最大重试次数和下次执行时间。第一次失败后可以间隔一分钟,第二次间隔五分钟,后续逐步延长,避免短时间内频繁调用审核服务。
超过重试上限后,任务可以转为人工处理状态,同时保留错误信息和执行记录。
应用部署多个节点时,还需要增加数据库锁或 Redis 分布式锁,避免同一条任务被多个节点重复处理。

审核通过后才进入公开内容体系
审核通过并不只是把内容状态改成 PUBLISHED。
后台还需要继续完成搜索索引、话题聚合、发现页分发和关注通知等操作。
建立EasyES内容索引
内容搜索如果一直使用数据库模糊查询,数据量增长后会对主库产生较大压力,也很难完成关键词相关度排序。
审核通过后,可以将以下字段写入 EasyES:
- 内容标题;
- 正文摘要;
- 作者信息;
- 内容分类;
- 话题名称;
- 发布时间;
- 内容类型;
- 点赞数量;
- 评论数量;
- 收藏数量。
用户搜索关键词时,可以同时匹配标题、正文和话题标签,再结合发布时间与互动热度排序。
待审核、审核失败、隐藏和下架内容不能进入搜索索引。作品状态失效后,也要及时删除对应索引,避免内容继续出现在搜索结果中。
更新话题聚合
用户发布内容时添加的话题标签,只能在审核通过后计入公开作品数量。
一篇作品可以关联多个话题。例如同时加入"城市徒步"和"周末生活",审核通过后,这篇作品会出现在两个话题页面中。
作品隐藏、删除或下架后,话题聚合数据也要同步更新。
加入发现页候选内容
发现页虽然采用双列瀑布流布局,但决定内容顺序的是后台推荐逻辑。
初期可以根据发布时间、点赞数、评论数、收藏数和浏览完成度计算内容热度。用户近期浏览过的分类、收藏过的话题和关注的作者,也可以作为推荐信号。
已浏览内容可以适当降低推荐权重,减少发现页反复出现相同作品。

互动操作必须先验证作品状态
仿小红书源码中的公开作品支持点赞、评论、回复、收藏和关注作者。
但用户每次执行互动前,后台都要检查作品是否仍处于公开状态。
已经被作者隐藏、后台下架或逻辑删除的内容,不应该继续接受新的点赞和评论。否则页面虽然无法访问,互动数量却还在持续增加。
评论表通常需要记录:
|---------------|--------|
| 字段 | 作用 |
| content_id | 所属作品 |
| user_id | 评论用户 |
| parent_id | 父评论 |
| reply_user_id | 被回复用户 |
| content_text | 评论正文 |
| image_url | 评论图片 |
| audit_status | 评论审核状态 |
| create_time | 创建时间 |
一级评论和回复可以使用同一张表,通过 parent_id 区分层级,通过 reply_user_id 标记被回复用户。
评论成功后生成评论通知,回复则生成回复通知。点赞、关注和系统消息也应写入通知记录。
WebSocket 只负责实时推送,不能替代数据库存储。用户离线期间产生的评论通知、点赞提醒和关注提醒,需要在下次登录后重新读取。
单聊消息也遵循相同原则。文字、图片和表情消息先写入消息表,再通过 WebSocket 推送。接收方进入会话后,后台更新消息已读状态。
Redis只缓存热点,不保存最终状态
发现页、内容详情、热门搜索和用户主页属于高频访问区域,可以使用 Redis 降低数据库压力。
适合缓存的数据包括:
- 热门作品 ID;
- 内容详情基础信息;
- 用户主页统计;
- 话题热度;
- 热门搜索词;
- 点赞、评论和收藏数量;
- 推荐候选内容;
- 未读消息数量。
但作品是否审核通过、内容是否删除、商品是否上架、订单是否支付等最终状态,仍然要以数据库为准。
Redis 数据可能过期或丢失,系统必须能够从数据库恢复真实业务状态。
热门搜索可以使用 ZSet 保存关键词和搜索次数,Quartz 定期汇总近期搜索数据,过滤无效词和重复词,再形成热门搜索列表。

内容素材与商品素材复用基础服务
带有商城功能的仿小红书源码,还会涉及商品分类、商品列表、商品详情、商品搜索、购物车、立即购买、订单管理和在线支付。
商品图片与内容图片都可以使用统一的文件上传和对象存储服务,也可以复用图片压缩、格式校验和内容审核能力。
但两类业务状态必须分开。
内容的 PUBLISHED 表示允许公开阅读,商品的 ON_SALE 表示允许下单购买。商品即使可以展示,也可能因为库存不足无法购买;内容则不存在库存和支付问题。
基础能力可以复用,业务模块仍然保持独立:
javascript
公共基础服务
├─ 文件上传
├─ 对象存储
├─ 内容审核
├─ 短信服务
└─ 搜索服务
业务模块
├─ 内容发布与互动
├─ 即时通讯
├─ 商品与库存
└─ 订单与支付
EasyES 可以分别建立内容索引和商品索引。
内容搜索关注标题、正文、作者和话题,商品搜索则关注商品名称、分类、价格、库存和上下架状态。
Shiro同时处理权限与数据归属
用户可以在个人主页管理作品、草稿、收藏、浏览记录、关注列表和粉丝列表,但只能操作属于自己的数据。
前端隐藏编辑或删除按钮,并不能代替服务端权限校验。用户仍然可能通过修改接口参数尝试操作其他人的作品。
Shiro 可以在接口层判断用户角色和访问权限,业务服务还要继续验证数据归属。
例如:
- 普通用户只能修改自己的作品;
- 内容审核人员可以处理审核任务;
- 内容管理员可以下架公开内容;
- 商品管理员可以维护商品信息;
- 订单管理员可以处理订单状态;
- 系统管理员可以管理账号和权限。
常见权限标识可以包括:
javascript
content:publish
content:update
content:delete
content:audit
content:offline
goods:create
goods:update
order:manage
system:notice
用户拥有 content:update 权限,并不代表可以修改所有内容。业务层仍然要比较当前用户 ID 与作品作者 ID。

如何判断一篇内容真正发布完成
数据库插入成功,只能说明主数据已经保存,不能代表作品已经进入公开内容体系。
一篇图文或短视频真正完成发布,需要满足以下条件:
javascript
媒体文件上传完整
+
内容主数据写入成功
+
分类与话题关系有效
+
文字、图片或视频审核通过
+
内容状态更新为公开
+
EasyES搜索索引创建完成
+
发现页和话题页能够正常读取
任何一个环节出现异常,系统都要保留明确状态,并通过任务重试或定时补偿继续处理。
经过这样的拆分,图文发布、短视频发布、多图上传、草稿保存、话题标签、内容搜索和双列瀑布流不再是相互独立的页面功能,而是围绕同一套内容生命周期运行。
Spring Boot负责编排业务流程,对象存储处理媒体文件,内容审核服务控制公开范围,Quartz修复异常任务,EasyES负责内容检索,Redis加速热点数据,Shiro限制操作权限。各项技术保持清晰边界后,仿小红书源码的内容发布链路才能具备更好的可维护性和扩展能力。