投资社区发帖功能:设计方案与实现总结

一条帖子从点击发布到出现在圈子,背后要过权限、防重、限流、解密、版本落库五道关,再排队穿过网易盾、AI 与人工三层审核。任何一层漏判,用户看到的就是发不出去、审核卡住或内容错乱。这篇复盘把发帖与审核两条链路完整拆开。
本篇你能学到
- 用主表加版本表的双指针设计,让编辑中的内容不影响已上架版本。
- 用 Redis SETNX 加分钟/天双窗口计数器,挡住重复提交和刷帖。
- 用显式状态机统一编排网易盾、AI、人工与降级四路审核结果。
- 用 AFTER_COMMIT 加 Redis Stream,把发帖与审核解耦且不产生幽灵消息。
- 用 FOR UPDATE 行锁修复多图回调并发导致的审核卡死。
一、需求、链路与数据模型
做社区发帖,表面看是把一段文字和几张图片存进数据库,实际要同时解决三个问题:谁能发、内容怎么审、上架后怎么分发。先看需求与链路的全貌,再进入代码。
1. 需求背景与目标
- 用户侧:多轮用户调研显示,用户对投资类社区的认知是“可以发言表达情绪的地方”和“可以看到投资牛人经验分享”的社区。
- 平台侧:产品长期缺乏专属的留客抓手。
- 竞品压力:多家头部理财与投资类产品均已开设内容社区,部分平台已通过社区运营带动销售转化,并在发力内容创作激励。
产品现状的痛点也很直接:
- 社交互动欠缺:缺少发言评论功能,用户间互动困难;
- 内容生态薄弱:话题深度不足、产量少、缺乏独家内容;KOL 发帖耗时长且缺少激励机制。
核心目标是建设并优化发帖功能,让用户更好、更方便地发表内容,充实内容池,达成“头部用户持续分享投资经验、海量用户踊跃发言”的社区。
把目标翻译成工程语言,就是三件事:发得出去、审得过来、上架后能被分发。后面的链路和数据模型都围绕这三件事展开。
2. 端到端链路与消息边界
先看一条帖子从提交到上架的完整路径。
用户发帖
↓
[网易盾审核](文本异步 + 图片异步,并发提交)
↓ 通过
[AI 智能审核](提交审核任务 → 定时任务轮询结果)
↓ 通过
[上架](发布到圈子动态索引 + 热点推荐)
↓ 不通过
[驳回](推送拒绝消息给用户)链路里有三条消息通道,各管一段:
- 发帖成功后 → 事务提交后推送 Redis Stream 队列(
post:audit:queue); - 网易盾审核结果 → 通过 RabbitMQ 队列回传(文本结果队列 / 图片结果队列);
- AI 审核结果 → 通过定时任务轮询刷新(
RefreshAIAuditResultJob)。
为什么是三套通道,而不是一套消息队列全包?发帖后的审核任务需要事务提交后可靠入队,还要能重投,Redis Stream 的 PEL 机制正好合适。网易盾是外部系统主动回调,RabbitMQ 的文本、图片两条队列能把两类结果分开消费。AI 审核耗时不可控,提交后只能靠定时任务轮询查询结果。
关键接口清单如下:
- App 侧:签署发帖协议、发帖上传图片、晒收益/晒持仓/晒操作;
- 服务侧:发帖权限校验、发布新帖、草稿保存/查询/删除、帖子查询与删除、帖子列表(游标分页)等;
- 审核侧:更新人审结果、刷新 AI 审核结果、文本/图片消息消费服务;
- 管理后台:帖子批量上下线、加精、禁止评论、回复、导出、看板统计等 20 余个管理接口。
这四组接口把用户端、服务端、审核侧和运营后台隔开。后面讲的所有流程,最终都落在这些接口和它们背后的消息上。
3. 发帖数据模型:10 张表与双版本指针
发帖侧共 10 张表,核心是“主表 + 版本表”的版本化设计:
| 表 | 用途 |
|---|---|
t_community_user | 社区用户信息及发帖授权 |
t_filter_roster | 黑/白/灰名单(发帖权限管控) |
t_post_info | 帖子主表(状态、当前版本指针、发布时间) |
t_post_versions | 帖子版本表(内容快照 + 综合审核状态 + 网易盾审核/文本/图片/AI/人审子状态) |
t_post_circles | 帖子-圈子关联 |
t_post_topics | 帖子-话题关联 |
t_post_images | 帖子图片资源 |
t_post_link_card | 帖子链接卡片 |
t_trending_index_stock | 帖子-股票关联 |
t_post_draft | 帖子-草稿关联 |
设计要点有三条:
- 帖子采用“主表 + 版本表”的版本化设计,每次编辑生成新版本,审核基于版本进行;
- 主表持有
currentVersionNo/pendingVersionNo双指针:编辑发帖后新版本先挂到 pending 指针,审核通过后再切换 current 指针并上架; - 版本表同时承载审核状态字段(综合审核状态 + 网易盾审核/文本/图片/AI/人审子状态),是后续审核流程(第三章起)的状态载体。
双指针解决的是编辑场景的冲突。用户编辑已上架的帖子时,新内容不能直接覆盖旧版本,否则审核还没通过,线上展示就变了。新版本先挂 pending,审核通过后再切 current,线上始终展示最近一次过审的内容。
版本表则把“内容”和“审核状态”绑在一起。每一次提交审核都对应一个版本,审核结果只回写这个版本,不会串到别的版本上。编辑次数再多,历史版本也能追溯。
4. 审核侧数据模型:日志、临时表与两张索引
审核侧另有 4 张表,分别承担日志、临时映射和分发索引:
| 表 | 用途 |
|---|---|
t_audit_logs | 审核全流程日志表(from 状态 → to 状态、事件、操作人) |
t_post_images_auditing | 审核中图片临时表(网易盾审核图片回调只带 guid,凭该表反查 versionId;回调处理后删除) |
t_circle_trending_index | 帖子圈子动态索引(上架后的分发入口,通过终态时重建) |
t_hot_recall | 热点推荐召回索引表:通过终态时插入(幂等) |
版本表 t_post_versions 在审核各阶段被持续更新:auditTaskId(网易盾审核任务标识)、textMachineStatus / imageMachineStatus(网易盾审核子状态)、AI 审核任务与子状态、综合审核状态等。
临时表的设计只服务一个边界:网易盾的图片回调只带图片 guid,不带帖子版本号。提交审核时把 guid 与 versionId 的映射写进 t_post_images_auditing,回调到达时才能反查版本。处理完立即删除,表里只保留在途数据。
预期结果是,发帖侧 10 张表负责内容与关联,审核侧 4 张表负责过程与分发,两边通过版本 ID 对齐。后面无论讲状态机还是回调,都能落回这些表。
二、发帖主流程:六步校验与版本落库
发帖接口是内容进入系统的第一道闸门。重复提交、刷帖、超大内容、抓包篡改、空帖,都要在这里挡住。核心类是 PostServiceImpl。
1. 发帖入口:权限、防重、限流、解密、校验
publishPost 把一次发帖拆成六步:
publishPost(请求):
① 权限校验:
- 已签约(社区用户表存在记录)
- 可发布(不在黑名单 && 灰度开关放行)
② 防重复提交:Redis SETNX 5 秒过期,未获取到令牌则拒绝
③ 圈子数量校验:非灰度时期最多加入 10 个圈子
④ 内容处理:
- AES 解密 Base64 内容(防传输抓包),失败抛业务异常
- 文本长度校验(≤ 5000)
- 计算帖子类型:图文(1) / 纯文本(2) / 纯图片(3),均为空则拒绝空帖
⑤ 根据 postId 是否存在分流:
- 不存在 → insertPost(新增)
- 存在 → updatePost(编辑出新版本)
⑥ 事务提交后 → 发布 PostAuditMessageDTO 事件 → 推送 Redis Stream 进入审核第①步先看两件事:用户是否已签约,以及是否可发布。已签约的判断标准是社区用户表存在记录,可发布要求不在黑名单且灰度开关放行。没有这两条,后面的校验都不用做。
第②步用 Redis SETNX 挡重复提交,令牌 5 秒过期。用户手抖连点两次,第二次拿不到令牌,直接拒绝。这个时间窗不需要太长,覆盖一次正常提交的间隔即可。
第③步限制圈子数量,非灰度时期最多加入 10 个圈子。圈子是分发入口,不限数量会让一条帖子铺满大量圈子,既影响体验,也增加审核和索引写入的压力。
第④步才做内容处理。前端传的是 Base64 内容,服务端先 AES 解密,防止传输过程被抓包篡改,解密失败直接抛业务异常。随后校验文本长度不超过 5000,再根据文本和图片是否为空计算帖子类型:图文为 1,纯文本为 2,纯图片为 3。三者都为空就拒绝空帖。
第⑤步按 postId 分流。不存在就新增,存在就编辑。编辑不是覆盖旧记录,而是生成新版本,具体在下一小节展开。第⑥步等事务提交后再发审核消息,保证回滚的帖子不会进入审核队列。
2. 新增、编辑与草稿:新版本先挂 pending
新增帖子走 insertPost,顺序是先把主表、版本表、关联表落齐,再返回审核消息 DTO:
insertPost(发布请求):
① 发布限流检查(分钟级 + 天级双重)
② 保存主表 t_post_info
③ 保存版本表 t_post_versions(初始化网易盾审核/人审等审核状态为待审核)
④ 关联圈子 t_post_circles(批量插入)
⑤ 关联图片 t_post_images(上传阶段已预插入,此处批量认领)
⑥ 关联股票 t_trending_index_stock(转换 + 去重后批量插入)
⑦ 关联话题 t_post_topics(批量插入)
⑧ 记录结构化日志
返回 PostAuditMessageDTO(postId, versionId)新增时图片不是在这里才插入。上传阶段已经预插入 t_post_images,发帖时只是把预插入的记录认领到当前帖子,避免大文件上传和发帖事务绑在一起。股票关联要先转换再去重,同一只股票重复出现只保留一条。
编辑帖子走 updatePost,重点在版本号和图片差量:
updatePost(发布请求):
① 清除旧审核通知的已读标记
② 校验帖子归属 + 审核中禁止编辑(状态处于审核中集合则拒绝)
③ 创建新版本(版本号 = 旧版本号 + 1,写入内容快照与初始审核状态)
④ 更新主表 pendingVersionNo 指向新版本
⑤ 图片差量处理:
- 新图在旧版本中已存在 → 批量 clone(复用历史图片记录)
- 全新图片 → 批量 claim(认领上传阶段预插入的记录)
⑥ 重新关联圈子 / 股票 / 话题(旧关联失效,新关联插入)
返回 PostAuditMessageDTO编辑时先清掉旧审核通知的已读标记,用户重新提交后能看到新的审核结果。审核中的帖子禁止编辑,避免同一个版本被反复改写。新版本号等于旧版本号加一,先挂到 pendingVersionNo,审核通过后再切 current。
图片差量处理是为了省存储和避免重复上传。新图在旧版本里已经存在的,直接批量 clone 复用历史记录;全新图片才去 claim 上传阶段预插入的记录。圈子、股票、话题则全部重新关联,旧关联失效,新关联插入。
配图

图 1:发帖主流程,权限、防重、限流、解密校验、版本落库、事务消息
草稿管理支持 SAVE / DISCARD 两种操作,区分自动保存(tempContent)与手动保存(savedContent)两套内容:
- 首次新建:手动保存生成并展示最新版本;异常退出展示最后一次自动保存版本;放弃保存则清除自动缓存、不生成草稿;
- 二次编辑:手动保存覆盖旧版;异常兜底覆盖旧版自动保存内容;放弃则恢复上一版有效草稿;
- 草稿图片做差量处理(删除多余 + 绑定新增)。
辅助逻辑还有两处:getUserUnionId 优先读 Redis 缓存(24 小时),未命中查库并回写,无记录则判“无发帖权限”;图片上传前已按用户加分钟/天双重限流控制。
3. 缓存与限流:SETNX 与双窗口计数器
发帖和图片上传共用一套缓存与限流组件,核心类是 PostCacheHelper:
| 功能 | 实现方式 | 关键参数 |
|---|---|---|
| 防重复提交 | Redis SETNX + 过期 | 5 秒过期 |
| 发帖限流 | 时间窗口计数器,双维度 | 分钟级 10 次 / 天级 200 次 |
| 图片上传限流 | 同上 | 分钟级 10 次 / 天级 200 次 |
| 灰度开关 | Redis 开关读写 | key:post:gray:switch(示例) |
| UnionId 缓存 | 普通 KV 缓存 | 24 小时过期 |
限流的核心逻辑是时间片计数,第一次写入时设置过期:
checkLimit(prefix, userId, 维度, maxCount):
key = prefix + userId + 时间片(yyyyMMddHHmm 或 yyyyMMdd)
计数 = Redis 自增(key)
若 计数 == 1: # 首次写入,设置过期(分钟级 2 分钟 / 天级 25 小时)
设置过期时间
若 计数 > maxCount: 返回 false(限流触发)
# 限流维度枚举
MINUTE: key 格式 yyyyMMddHHmm,过期 2 分钟
DAY: key 格式 yyyyMMdd, 过期 25 小时分钟级 key 精确到分钟,过期 2 分钟,覆盖当前分钟和边界余量;天级 key 精确到天,过期 25 小时,多出的一小时用来覆盖跨天边界。计数在 Redis 自增,第一次写入时才设置过期,避免每次请求都刷新 TTL。
初始化时 @PostConstruct 默认将灰度开关置为开启。灰度开关控制新用户是否进入发帖灰度,配合权限校验里的“灰度开关放行”使用。
预期结果是,同一用户 5 秒内重复点击只成功一次;分钟级超过 10 次或天级超过 200 次会被拒绝;图片上传同样受双窗口限制。新增返回帖子与版本 ID,编辑只改 pending 指针,线上已上架版本不受影响。
三、审核状态机:九种事件与终态集合
审核结果来自三条异步链路:网易盾回调、AI 轮询、人工操作。如果每个入口都写一段 if-else 去改状态,状态迟早会乱。统一的状态机把所有流转收口到 AuditStateMachine#fireEvent,这是整个审核流程的编排器。
审核流程从帖子版本进入待审核开始,到上架或驳回的终态结束,依次经历网易盾审核、AI 审核(AI 拒绝时转人工复核),各阶段事件由统一的状态机编排推进。全景如下。
帖子版本进入审核(发帖事务提交后推送 Redis Stream)
↓
网易盾审核提交(第四章):消费审核消息 → 幂等/降级判断 → 并发提交网易盾(文本 + 图片)
↓
网易盾审核结果回调(第四章):网易盾经 RabbitMQ 回传文本/图片结果
├─ 文本与图片全部通过 → 聚合 → NETEASE_PASS
└─ 任一拒绝 → 拒绝短路 → NETEASE_REJECT(终态·下架)
↓
AI 智能审核(第四章):调 AI 审核提交接口发起 → 定时任务轮询查询接口刷新结果
├─ AI 拒绝 → AI_REJECT(非终态)→ 转人工审核 → MANUAL_PASS / MANUAL_REJECT
├─ AI 通过 → AI_PASS(终态·上架)
└─ 网易盾审核降级 → DEGRADE_PASS 直进 AI 审核 / DEGRADE_REJECT 直接下架
↓
终态处理:上架(发布圈子动态索引 + 热点推荐 + 主表上架 + 旧版本过期)
驳回(推送拒绝消息 + 主表下架)1. 事件枚举与终态划分
状态机一共九种事件:
| 事件 | 含义 | 是否终态 |
|---|---|---|
SUBMIT_NETEASE | 提交网易盾审核 | 否 |
NETEASE_PASS | 网易盾审核通过 | 否 |
NETEASE_REJECT | 网易盾审核拒绝 | 是(拒绝终态) |
AI_PASS | AI 通过 | 是(通过终态) |
AI_REJECT | AI 拒绝 | 否(转人工审核) |
MANUAL_PASS | 人审通过 | 是(通过终态) |
MANUAL_REJECT | 人审拒绝 | 是(拒绝终态) |
DEGRADE_PASS | 网易盾审核全局降级-通过 | 否 |
DEGRADE_REJECT | 网易盾审核全局降级-拒绝 | 是(拒绝终态) |
事件集合划分:TERMINAL_EVENTS(终态)、TERMINAL_REJECT_EVENTS(拒绝终态)、TERMINAL_PASS_EVENTS(通过终态 = AI_PASS + MANUAL_PASS)。
TRIGGER_AI_EVENTS 单独表示触发 AI 审核的事件集合,包含 NETEASE_PASS 和 DEGRADE_PASS。
把终态单独分组,是为了在 fireEvent 里统一处理上架、下架、通知和索引重建。通过终态只有 AI_PASS 和 MANUAL_PASS,拒绝终态包含网易盾拒绝、人审拒绝和降级拒绝,中间的 AI_REJECT 不是终态,必须转人工复核。
2. 状态流转规则
状态机的合法流转只有下面这些:
PENDING_AUDIT ──SUBMIT_NETEASE──→ NETEASE_AUDITING
NETEASE_AUDITING ──NETEASE_PASS──→ AI_AUDITING
NETEASE_AUDITING ──NETEASE_REJECT──→ NETEASE_REJECTED(终态·下架)
AI_AUDITING ──AI_PASS──→ AI_PASSED(终态·上架)
AI_AUDITING ──AI_REJECT──→ AI_REJECTED(非终态,走人工)
任意中途状态 ──MANUAL_PASS──→ MANUAL_PASSED(终态·上架)
任意中途状态 ──MANUAL_REJECT──→ MANUAL_REJECTED(终态·下架)
PENDING_AUDIT / NETEASE_AUDITING ──DEGRADE_PASS──→ AI_AUDITING
PENDING_AUDIT / NETEASE_AUDITING ──DEGRADE_REJECT──→ NETEASE_REJECTED(终态·下架)每条流转同时联动更新:综合审核状态、网易盾审核子状态、AI 审核子状态、人审审核子状态、帖子终态(上架/下架)。未枚举的非法事件快速失败,这是防御性编程,宁可抛异常也不让状态漂移。
降级流转只允许从 PENDING_AUDIT 或 NETEASE_AUDITING 触发。到了 AI 审核阶段,网易盾已经不再是前置条件,再降级就没有意义了。
配图

图 2:审核状态机,网易盾、智能审核、人工复核,最后上架或驳回
从图里能看到三条出口:网易盾拒绝直接下架,AI 通过或人审通过上架,人审拒绝下架。中间的 AI 拒绝只是一次转人工,不是终态。状态机的价值就在这张图上,所有异步结果最终都收敛到有限的几个状态。
3. fireEvent 主流程:乐观锁、日志与终态处理
fireEvent 是所有事件的唯一入口,主流程如下:
fireEvent(postId, versionId, event, 操作人, 事件类型, 备注, 风险类别):
# @Transactional 事务内执行
① 查询当前版本;不存在则抛异常
② resolveTransition(当前状态, event) 解析合法流转
状态不满足触发条件 → 返回空 → 抛异常跳过
③ 原子更新版本表(带当前状态条件的更新,乐观锁防并发)
更新行数 == 0 → 抛异常(可能已被并发处理)
④ 记录审核日志(t_audit_logs:from状态 → to状态)
⑤ 若 event ∈ TRIGGER_AI_EVENTS:
注册事务提交后回调 → 异步发起 AI 审核请求
⑥ 若 event ∈ 终态事件:
├─ 拒绝终态: 更新审核时间 + 推送拒绝消息给用户(站内信入库)
└─ 通过终态: 发布到圈子(先删后插全量重建圈子动态索引)
+ 插入热点推荐召回索引(幂等:先查存在性)
⑦ 标记上一版本过期(expired = 1),保证版本一致性
⑧ 更新主表:帖子上/下架状态、清空待审核版本指针、
更新当前版本指针、更新发布时间(下架时置空)关键设计点有四个:
- 乐观锁:状态更新语句携带
expectedAuditStatus条件,并发冲突时更新行数为 0,快速失败; - AI 审核发起放在事务提交后(
TransactionSynchronization.afterCommit):避免长事务和外部接口超时导致事务挂起;提交成功后回写aiAuditTaskId与AI 审核中状态; - 拒绝通知:推送内容截取前 10 字符(去空格)防止超长;
- 发布索引重建:先删后插保证编辑后重新上架时圈子索引为最新。
预期结果是,任一审核结果到达后,版本状态、子状态、主表指针、上下架状态、索引与通知都在同一套流程里收敛。重复或非法事件会在解析流转或条件更新时被拦住,不会造成二次上架或二次下架。
四、审核主流程:网易盾提交、结果回调与 AI 轮询
审核链路的难点不在状态定义,而在外部系统不可控。网易盾可能超时,回调可能重复,图片可能多张并发,AI 审核可能几分钟才出结果。
核心类分别是 RedisStreamMsgConsumer(网易盾审核提交)、MsgConsumerServiceImpl(网易盾审核结果回调)、AiAuditServiceImpl(AI 审核)。
1. 网易盾审核提交:入队、幂等与并发提交
审核消息由 PostAuditEventPublisher 在发帖事务提交后推送:
onPostAuditEvent(PostAuditMessageDTO): # @Async 异步执行
# @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true)
try:
Redis Stream(post:audit:queue).add(dto)
日志: 审核消息已入队
catch 异常:
仅记录日志,不抛出 # 入队失败不影响已提交事务
# 兜底:定时任务扫描 PENDING_AUDIT 状态的版本补推这里有三个细节:AFTER_COMMIT 确保仅事务成功提交才发消息,事务回滚时事件自动丢弃,不会产生“幽灵消息”;fallbackExecution = true 兼容非事务上下文的调用;异步线程池执行,不阻塞发帖主流程。
队列基础设施由两块组成:RedisStreamConfig 在应用启动时(@PostConstruct)确保 Stream 和 Consumer Group 存在,不存在则创建。
RedisStreamConsumerConfig 配置 StreamMessageListenerContainer 消费者容器,绑定消费者与 Consumer Group。
可靠性靠四层机制兜底:
| 机制 | 说明 |
|---|---|
| PEL(Pending Entries List) | 消费异常不 ACK,消息留在 PEL 由下次轮询重投 |
| 手动 ACK | 成功路径处理完成后显式确认 |
| 死信处理 | 重试超限后主动 ACK 并写入死信队列 |
| 兜底定时任务 | RedisMsgConsumeRetryJob 重试消费失败消息;扫描 PENDING_AUDIT 版本补推 |
审核消息体刻意保持极简,只带两个定位字段:
{
"postId": 1001,
"versionId": 1002
}| 字段 | 类型 | 说明 |
|---|---|---|
postId | Long | 帖子主表 ID |
versionId | Long | 本次送审的版本 ID |
消息体只携带“定位坐标”而不携带内容。消费者凭 versionId 反查内容快照、图片列表等全部审核上下文,消息与业务数据解耦。帖子即使随后再次编辑,本次审核仍锚定提交事务时的版本。
消费入口是 RedisStreamMsgConsumer.onMessage,先做幂等,再做降级判断:
onMessage(stream 消息):
解析 postId / versionId
try:
① 幂等校验:按版本状态查询,必须是 PENDING_AUDIT(待审核)
- 查不到 → 记录告警并直接 ACK 丢弃(版本已处理/过期)
② 生成网易盾审核任务唯一标识 taskId:
格式 = postId_versionId_时间戳_随机串
设置 TraceId 用于全链路日志追踪
③ 降级判断(读 Redis 开关):
- 开关打开 → 触发 DEGRADE_PASS 事件(跳过网易盾审核直进 AI 审核)
- 开关关闭 → submitToAudit() 提交网易盾
→ 触发 SUBMIT_NETEASE 事件(状态机推进为网易盾审核中)
finally:
手动 ACK + 清除 TraceId幂等校验只认 PENDING_AUDIT。版本已经处理过或已过期,消息直接 ACK 丢弃,不重复送审。降级开关打开时,跳过网易盾直接触发 DEGRADE_PASS,网易盾故障时业务不中断。
真正提交网易盾时,网络请求和数据库快照分开处理:
submitToAudit(帖子版本):
# ---------- 步骤①:网络请求(事务外,避免大事务包裹 HTTP 调用)----------
任务列表 = []
若 有文本:
任务列表 += 异步执行 ContentFilterService.submitTextAudit(文本请求)
若 有图片:
对每张图片 guid:
任务列表 += 异步执行 submitImageAudit(图片请求)
等待所有任务完成(CompletableFuture.allOf,超时 5 秒)
超时 → 取消未完成任务 + 抛业务异常
汇总成功提交的文本结果与图片 guid 集合
# ---------- 步骤②:数据库快照(TransactionTemplate 编程式事务)----------
事务内:
更新版本表 auditTaskId = taskId
有文本时: textMachineStatus = AUDITING
有图片提交成功时:
imageMachineStatus = AUDITING
批量更新图片表审核状态
批量插入 t_post_images_auditing 临时记录
# 用途:网易盾审核图片回调只有 guid,凭该表反查 versionId
更新版本表(条件更新:仅当状态仍为 PENDING_AUDIT 时生效,乐观锁思想)网络请求放事务外,是为了避免 HTTP 超时把数据库事务拖成长事务。文本和图片并发提交,总超时控制在 5 秒,超时后取消未完成任务并抛业务异常。数据库快照放在 TransactionTemplate 里,只做几次更新和批量插入。
网易盾接口统一由 ContentFilterServiceImpl 执行,OkHttp 统一执行器加统一响应反序列化与业务状态校验:
| 方法 | 用途 |
|---|---|
submitTextAudit | 文本异步审核(结果经 RabbitMQ 文本结果队列回调) |
submitImageAudit | 图片异步审核(结果经 RabbitMQ 图片结果队列回调) |
文本提交的请求体如下,contentId 就是版本表的 auditTaskId:
{
"contentId": "1001_1002_1780558064231_a1b2c3d4",
"content": "帖子正文(纯文本)",
"source": "community_post",
"businessType": 2
}| 字段 | 说明 |
|---|---|
contentId | 审核任务标识(即版本表 auditTaskId),格式 postId_versionId_时间戳_8位随机串,回调凭它反查帖子版本 |
content | 送审文本内容 |
source | 业务来源标识(接入方与网易盾约定) |
businessType | 业务类型枚举(本场景固定值) |
图片提交按每张图片独立一次请求,只传分组和 guid:
{
"groupId": "25",
"guid": "026d213c6a6a47c5b42eced77349a94d"
}| 字段 | 说明 |
|---|---|
groupId | 网易盾侧图片分组标识(配置项) |
guid | 图片唯一标识;提交成功后随快照写入 t_post_images_auditing,回调凭它反查 versionId |
整个提交环节的设计可以归纳成一张表:
| 设计点 | 实现方式 |
|---|---|
| 幂等 | 版本状态必须为 PENDING_AUDIT 才处理 |
| 并发 | CompletableFuture + 自定义线程池 auditExecutor |
| 避免大事务 | 网络请求在事务外,仅数据库操作在 TransactionTemplate 内 |
| 可靠性 | PEL + 手动 ACK + 兜底定时任务 |
| 降级 | Redis 开关 → 直接 DEGRADE_PASS |
| 可观测性 | 子任务耗时记录(taskCostMap)+ 总耗时日志 |
2. 网易盾结果回调:文本与图片如何聚合
网易盾审核结果经 RabbitMQ 回传,文本与图片分别走独立的回调队列(文本结果队列 / 图片结果队列,队列名按内部规范命名,此处从略)。由 MsgConsumerServiceImpl 的文本/图片两个消费者处理。
回调到达后统一采用下面的模式,先锁版本行,再判断状态,最后决定是否触发事件:
处理回调消息:
try:
设置 TraceId
事务内:
① FOR UPDATE 行锁查询帖子版本的综合审核状态
② 状态分支判断 → 决定是否触发状态机事件
③ 条件更新版本表(乐观锁,带期望状态)
④ (图片)更新图片子表 + 删除临时审核记录
事务外:
若更新成功且需要触发事件 → stateMachine.fireEvent(...)
finally:
清除 TraceId文本回调带 returnCode 和命中标签,301 表示通过,302 表示不通过:
{
"returnCode": 302,
"returnMsg": "不通过!",
"contentId": "1001_1002_1780558064231_a1b2c3d4",
"netEaseInfo": {
"action": 2,
"labels": "[{\"level\":2,\"label\":200,\"hint\":[\"加微信\",\"免费荐股\"]}]"
},
"userType": "03",
"censorType": 0
}| 字段 | 说明 |
|---|---|
returnCode | 审核结论:301=通过,302=不通过(消费侧归一化为 isPass) |
returnMsg | 结论描述 |
contentId | 任务标识 → 反查帖子版本(文本回调的关联键) |
netEaseInfo.action | 处置动作:0=通过,2=拒绝 |
netEaseInfo.labels | 命中标签(字符串化 JSON):label 分类码 + level 级别 + hint 命中词;拒绝时随 NETEASE_REJECT 落库为违规标签快照 |
userType / censorType | 账号类型与审核策略信息 |
图片回调是数组结构,name 就是提交时的 guid:
[
{
"name": "026d213c6a6a47c5b42eced77349a94d",
"action": 2,
"resultType": 1,
"censorType": 0,
"labels": [
{
"label": 210,
"level": 2,
"rate": 0.96,
"subLabels": [
{
"subLabel": 21000,
"rate": 0.96,
"details": { "hitInfos": ["图片中命中二维码"] }
}
]
},
{ "label": 200, "level": 0, "rate": 1.0, "subLabels": [] }
]
}
]| 字段 | 说明 |
|---|---|
name | 图片 guid(图片回调的关联键)→ 查 t_post_images_auditing 反查 versionId,处理完删除临时记录 |
action | 处置动作:0=通过,2=拒绝(消费侧归一化为 isPass) |
resultType / censorType | 结果类型与审核策略信息 |
labels[] | 命中标签:label 分类码、level 级别、rate 置信度、subLabels 细分命中(含命中内容与命中位置) |
文本回调的处理规则是“拒绝短路,通过等待图片”:
handleTextAuditMessage(文本审核消息):
事务内(FOR UPDATE 锁定版本行):
① 若综合状态 = 网易盾审核拒绝(终态拦截):
仅更新文本子状态快照,不触发事件
② 若综合状态 = 网易盾审核中:
- 文本通过:
文本子状态 = 通过
若图片子状态已通过 → 聚合: 网易盾审核状态=通过, 触发 NETEASE_PASS
否则 → 等待图片回调(仅更新子状态)
- 文本拒绝:
拒绝短路: 文本子状态=拒绝, 网易盾审核状态=拒绝
触发 NETEASE_REJECT(附违规标签)
③ 其他状态 → 忽略该回调图片回调的处理规则是“多图等待,最后一张收尾”:
handleImageAuditMessage(图片审核消息):
凭 guid 查 t_post_images_auditing 反查 versionId
(查不到 → 已处理过,幂等直接返回成功)
事务内(FOR UPDATE 锁定版本行):
① 锁内查询剩余未审核图片数量 auditingImgs
② 批次处理: 若 auditingImgs > 1
仅更新当前图片子表 + 删除该图临时审核记录
不触发状态机
③ 最后一张图片(auditingImgs == 1):
- 综合状态 = 网易盾审核拒绝(终态拦截): 仅更新图片子状态快照
- 综合状态 = 网易盾审核中:
计算图片整体结果:
当前图通过 且 无历史拒绝图片 → 通过
否则 → 拒绝
- 图片整体拒绝 → 拒绝短路: 触发 NETEASE_REJECT
- 图片整体通过 且 文本已通过 → 聚合: 触发 NETEASE_PASS
- 图片整体通过 但 文本未结束 → 等待文本回调
- 其他 → 忽略
更新版本表 + 更新图片子表 + 删除临时审核记录
事务外:
触发状态机事件两条回调规则合起来,就是文本和图片的聚合逻辑。文本拒绝立即短路,不再等图片;文本通过则等图片全部完成;图片最后一张完成时,如果文本已通过就触发 NETEASE_PASS,否则继续等文本。终态拦截保证已经拒绝的版本不会再被后续回调拉起。
多图并发回调曾引发“审核流程提前结束”的线上问题,无锁 COUNT 判断被并发穿透,根因分析与修复方案独立记录在第五章。
3. AI 智能审核:提交、轮询与转人工
AI 审核分四步:
- 触发条件:网易盾审核通过(或降级通过),状态机在事务提交后异步发起;
- 获取审核任务号:调用 AI 审核提交接口(提交标题加内容),返回审核任务号
auditId; - 查询审核结果:调用 AI 审核结果查询接口(批量最多 10 个),由定时任务
RefreshAIAuditResultJob轮询刷新; - 结果处理:AI 通过 → 执行上架;AI 未通过 → 转人工审核(AI_REJECT 非终态)。
接口实现集中在 AiAuditServiceImpl:
| 方法 | 说明 |
|---|---|
submitAudit | 提交审核(AI 审核提交接口),返回 auditId |
getAuditResults | 批量查询审核结果(AI 审核结果查询接口,≤10 个/次) |
submitAudit(请求):
响应 = HTTP POST(AI 审核提交接口, JSON 序列化请求体)
校验业务状态 status == 1
返回 auditId
getAuditResults(auditIds):
校验: 列表非空 && 数量 ≤ 10
响应 = HTTP POST(AI 审核结果查询接口, {auditId 列表})
校验业务状态
返回审核详情列表
统一请求执行器:
- 请求体 > 300 字符截断、响应体 > 500 字符截断(防长文本撑爆日志)
- 记录请求耗时
- HTTP 非 200 / IO 异常 / 反序列化失败 → 统一抛业务异常提交请求的 title 允许为空,空时截取正文前 10 字:
{
"title": "帖子标题(无标题时截取正文前 10 字)",
"content": "帖子正文(纯文本)",
"userCode": "",
"sourceName": "社区发帖",
"subType": "post",
"reviewType": "AI",
"infoType": "doc",
"url": ""
}| 字段 | 说明 |
|---|---|
title | 标题;无标题时取正文前 10 字(去空格) |
content | 正文纯文本 |
userCode | 用户标识(本链路传空,审核结果只与内容挂钩) |
| 其余字段 | 来源 / 子类型 / 审核类型等信息字段,按接口约定传默认值 |
提交响应返回审核任务号,落版本表 aiAuditTaskId:
{
"status": 1,
"errmsg": "success",
"data": { "auditId": "165034801957441536" }
}查询请求一次最多带 10 个任务号:
{
"auditId": ["165025584215232512", "161727017060929536"]
}查询响应里既有结论,也有拒绝原因和改写建议:
{
"status": 1,
"errmsg": "success",
"data": [
{
"auditId": "161727017060929536",
"status": "3",
"taskResult": "Reject",
"reviewer": "AI",
"updateTime": "2026-03-23 14:52:21",
"reason": [
{
"HitModel": "收益承诺",
"Keywords": "业绩确定性提升",
"Reference": "《广告法》第二十五条",
"Reason": "存在承诺收益的表述,可能使投资人认为收益有保障",
"Optimization": "可修改为「业绩有望提升」等非承诺性表述"
}
]
}
]
}| 字段 | 说明 |
|---|---|
auditId | 审核任务号,与提交响应一一对应 |
status | 审核状态码:1=AI 审核中,2=AI 审核通过,3=AI 审核未通过,4=待人工审核(逻辑判断唯一依据;statusCn 仅展示用) |
taskResult | 结论:Pass / Reject |
reviewer | 审核方:AI / 人工 |
updateTime | 状态更新时间 |
reason[] | 拒绝原因明细:HitModel 命中模型、Keywords 命中原文片段、Reference 法规依据、Reason 判定说明、Optimization 改写建议 |
状态码里只有 status 参与逻辑判断,statusCn 只用于展示。AI 返回 3 时转人工,返回 4 时本来就在等人审,返回 2 才走上架。批次查询限制 10 个,是为了控制单次接口耗时和响应体大小。
预期结果是,网易盾通过后 AI 审核自动发起,定时任务按批拉取结果。AI 通过直接上架,AI 拒绝转人工,人工通过或拒绝再走终态处理。整条链路不阻塞发帖响应,也不依赖任何一个外部接口一直可用。
五、多图回调并发修复与审核侧设计要点
审核链路最怕的不是慢,而是卡在中间态。多图回调那次线上问题,正好把状态机、数据库锁和事务边界三件事同时暴露出来。
1. 问题现象与根因:两个线程都以为还有别人
一个帖子版本可以包含多张图片,每张图片审核完成后网易盾分别回调一次。正确的预期是:同一版本下所有图片回调处理完成后,才进入主表状态更新与状态机流程。
实际线上出现了审核卡死,日志有四个特征:
- 两个线程都进入“本轮更新图片”分支;
- 两个线程都打印“存在尚未审核完的图片数量:1”;
- 没有任何线程进入“最后一张图片”分支;
- 主表状态未更新,审核流程卡死。
原始代码是典型的 Check-Then-Act 无锁并发缺陷:
# 原始(有缺陷)逻辑
auditingImgs = 无锁 SELECT COUNT(剩余未审核图片数) # ① 判断
若 auditingImgs > 1:
事务内 { 更新当前图片子表 + 删除临时记录 } # ② 动作
返回
若 auditingImgs == 1: # 最后一张
更新主表 + 触发状态机同一帖子版本两张图片的审核回调几乎同时到达时,两个线程的执行时序如下:
线程 A(图片 A) 线程 B(图片 B)
──────────────────── ────────────────────
查询剩余数量 → 2(记录均未删)
查询剩余数量 → 2(A 还没删)
删除图片 A 临时记录
删除图片 B 临时记录
判断: 2 > 1 → 只更新子表
判断: 2 > 1 → 只更新子表
结束(无人判定自己是最后一张)本质是判断与动作不在同一原子边界内。无锁 COUNT 和删除临时记录之间没有锁,两个线程的判断都基于“过期快照”。它们都在对方删除前读取了计数,因此都认为“还有别的图片在审核”,最终无人触发主表更新,审核流程提前结束或卡住。
2. 修复方案:提前加锁、合并事务、锁内重读
修复围绕三个点展开:
- 提前加锁:事务内首先
SELECT ... FOR UPDATE锁定帖子版本主表行,把同一版本的所有图片回调串行化; - 合并事务边界:判断 + 更新子表 + 更新主表合并进同一事务;
- 锁内重新读取剩余数量:COUNT 查询放在持锁之后,保证读到的是最新值。
修复后的流程变成一条串行链路:
# 修复后流程
图片回调到达
↓
进入事务
↓
SELECT ... FOR UPDATE 锁定帖子版本行(version_id)
↓
锁内查询剩余未审核图片数量
↓
若 剩余数量 > 1:
更新当前图片子表
删除当前图片临时审核记录
提交事务(不触发状态机)
↓
若 剩余数量 == 1(最后一张):
更新当前图片子表
删除当前图片临时审核记录
更新帖子主表(版本表)审核状态(带期望状态的条件更新)
提交事务
事务外触发状态机修复后的并发时序是“先到先做,后到等待”:
线程 A(图片 A) 线程 B(图片 B)
──────────────────── ────────────────────
事务开启
FOR UPDATE 锁住版本行
事务开启
FOR UPDATE → 被阻塞(等 A)
锁内 COUNT = 2 → 非最后一张
删除图片 A 临时记录
提交事务,释放锁
获得行锁
锁内 COUNT = 1(A 已删)→ 最后一张
删除图片 B 临时记录
更新主表审核状态
提交事务
事务外触发状态机配图

图 3:多图回调并发修复,无锁计数会漏判,加行锁串行收尾
修复后只有一个线程能拿到版本行锁,另一个线程阻塞等待。等第一个线程提交释放锁,第二个线程在锁内重新 COUNT,读到的是最新值 1,于是由它完成主表更新和状态机触发。
3. 为什么 FOR UPDATE 有效,索引为什么关键
FOR UPDATE 解决的是数据行竞争,不是事务本身:
FOR UPDATE对查询命中的行加排他锁,作用不是“执行事务”而是锁数据行;- 线程 B 对同一行执行
SELECT ... FOR UPDATE/UPDATE/DELETE均会被阻塞,直到线程 A 提交释放锁; - 普通
SELECT不阻塞但可能读到旧数据,这也解释了原代码为何读到过期计数; - 操作不同
version_id的行互不影响,只串行化同一帖子版本的回调,不同帖子之间并发不受影响。
行锁依赖索引,这一点必须提前确认:
- 推荐:
WHERE version_id字段为主键 / 唯一索引 / 普通索引 → 数据库可精准定位并只锁相关行; - 危险:
version_id无索引 → 锁范围扩大,接近表锁效果 → 处理帖子 A 的线程会阻塞帖子 B/C/D 的处理线程 → 并发性能急剧下降,可能造成接口超时、线程池打满、消息堆积; - 检查要求:确认表结构存在
PRIMARY KEY (version_id)/UNIQUE KEY uk_version_id/ 至少KEY idx_version_id之一。
状态机触发仍然放在事务外:
if 事务结果.更新成功 && 事务结果.需要触发事件:
stateMachine.fireEvent(...) # 事务外这样安排有三个原因:状态机可能涉及外部调用、事件发布、异步任务,不应放进数据库事务;事务内只保证数据库状态一致性;事务提交成功后再触发状态机,避免“事务回滚但状态机已执行”。
但必须配套补偿/重试机制。否则可能出现“数据库状态已更新、状态机未流转、下游等不到事件”的中间态。这也是前面强调兜底定时任务的原因。
性能影响可以接受:行锁持有时长等于一次短事务,只有数次索引查询和索引更新;只锁同一版本行,不同帖子互不阻塞;图片回调本就是低频事件,串行化的性能损耗可接受。
4. 经验教训与设计要点
这次事故沉淀出六条通用经验:
- 不要依赖普通查询做并发判断:
COUNT → if(count==1) → doFinalAction()在并发下多个线程可能同时查到相同结果; - Check-Then-Act 必须保证原子性:判断和动作要么同一事务 + 先加锁,要么用唯一约束 / 乐观锁 / 分布式锁;
- 数据库悲观锁适合“同一业务对象串行处理”:本场景即“同一帖子版本的多个图片回调串行”,
SELECT ... FOR UPDATE是标准解法; - 事务边界要覆盖完整判断链路:只在更新阶段加事务而判断放在事务外,判断结果可能已过期;
- 加锁查询必须放在事务内:不在事务内锁会很快释放,无法保护后续逻辑;
- 行锁依赖索引:加锁前必须确认 WHERE 条件字段有索引,否则锁范围退化为近似表锁。
一句话记录:多张图片审核回调并发时,不能用无锁 SELECT COUNT 判断是否最后一张;必须先对帖子主表加 FOR UPDATE 行锁,再在锁内查询剩余图片数量并执行子表/主表更新,才能保证同一帖子版本的审核流程串行且正确结束。
发帖侧的设计要点可以归纳如下:
| 主题 | 设计决策 | 目的 |
|---|---|---|
| 帖子模型 | 主表 + 版本表,双版本指针 | 编辑不影响已上架内容,审核以版本为单位 |
| 发帖防重 | Redis SETNX 5 秒 | 防重复提交 |
| 发帖限流 | 分钟 + 天双窗口计数器 | 防刷帖 |
| 内容安全传输 | AES 加密 Base64 | 防抓包 |
| 事务与消息 | AFTER_COMMIT 事务提交后才推送审核消息 | 防“幽灵消息”(回滚后消息仍在) |
| 发帖与审核解耦 | 发帖落库后经 Redis Stream 异步进入审核 | 发帖响应不被审核拖慢 |
审核侧的设计要点如下:
| 主题 | 设计决策 | 目的 |
|---|---|---|
| 审核编排 | 显式状态机(事件 + 流转规则 + 终态集合) | 网易盾审核/AI/人审/降级多路结果统一编排 |
| 网易盾审核提交 | CompletableFuture + 线程池 + 5 秒超时 | 文本/图片并发提交,控制总时延 |
| 事务边界 | 网络调用在事务外,DB 操作用编程式事务 | 避免大事务包裹 HTTP |
| 状态并发 | 条件更新(乐观锁)+ FOR UPDATE(悲观锁) | 状态流转原子性 |
| 回调幂等 | 版本状态前置校验 + guid 临时表反查 + 记录删除判重 | 重复消息安全 |
| 短路策略 | 文本/图片任一拒绝即整体判为网易盾审核拒绝 | 快速下架风险内容 |
| 聚合策略 | 文本与图片均通过才触发 NETEASE_PASS | 完整聚合后进 AI 审核 |
| 降级策略 | Redis 开关 → DEGRADE_PASS 直进 AI 审核 | 网易盾故障时业务不中断 |
| AI 审核异步化 | 提交后定时任务轮询结果 | AI 耗时不可控,避免阻塞 |
| 人审兜底 | AI_REJECT 非终态转人工 | 拒绝内容有人工复核出口 |
| 消息可靠性 | PEL + 手动 ACK + 死信 + 定时任务兜底重推 | 消息不丢不卡 |
| 状态机触发时机 | 一律事务提交后触发 | 防回滚脏流转,配合补偿机制 |
5. 整体时序
把发帖、网易盾、AI、人审和终态处理压到一张图里,整体时序如下:
用户发帖
│ 权限/防重/限流/解密/校验
▼
主表+版本表+关联落库(事务)
│ 事务提交后 → Redis Stream(post:audit:queue)
▼
网易盾审核消费者
│ 幂等校验(PENDING_AUDIT) → 降级开关判断
│ ├─降级──→ DEGRADE_PASS ─────────────┐
│ └─正常──→ 并发提交网易盾(文本+图片) │
│ 插入图片临时表 → SUBMIT_NETEASE
▼ │
网易盾审核回调(RabbitMQ 文本/图片队列) │
│ FOR UPDATE 锁版本行 │
│ 文本/图片聚合 或 拒绝短路 ▼
│ NETEASE_PASS / NETEASE_REJECT → 状态机
▼
NETEASE_PASS → (事务提交后) 调 AI 提交接口获取 auditId
│ 定时任务轮询 AI 查询接口刷新结果
▼
AI_PASS → 终态处理:
│ 发布圈子动态索引 + 热点推荐 + 主表上架 + 旧版本过期
AI_REJECT → 转人工审核 → MANUAL_PASS / MANUAL_REJECT
NETEASE_REJECT → 终态: 推送拒绝消息 + 主表下架预期结果是,任何一条帖子都能在图上找到自己的位置:要么走到上架,要么走到驳回,要么停在某个可重试的中间态。中间态有兜底任务补推,终态只处理一次,重复回调被幂等和条件更新挡住。
常见错误与排错
- 现象:审核一直停在“网易盾审核中”,日志里没有任何线程进入“最后一张图片”分支。原因:多图回调并发时,无锁 COUNT 被两个线程同时读到旧值,判断和删除不在同一原子边界。解法:事务内先
SELECT ... FOR UPDATE锁版本行,锁内重新 COUNT,再更新子表和主表。 - 现象:状态机更新行数为 0,日志抛“状态不满足触发条件”。原因:重复回调或并发事件已经推进过状态,乐观锁条件更新没命中。解法:按
PENDING_AUDIT等当前状态做幂等判断,重复消息直接 ACK,不重复触发。 - 现象:发帖成功但帖子一直不进审核。原因:事务回滚导致 AFTER_COMMIT 事件没触发,或入队异常被吞掉。解法:确认监听器使用
AFTER_COMMIT加fallbackExecution = true,并依赖RedisMsgConsumeRetryJob扫描PENDING_AUDIT版本补推。 - 现象:图片回调报“找不到 versionId”。原因:
t_post_images_auditing临时记录没写入,或已被前一次回调删除。解法:提交成功时批量插入临时记录,回调查不到时按幂等处理直接返回成功,避免重复消费报错。 - 现象:一个帖子的图片回调把其他帖子的回调也堵住。原因:
version_id没有索引,FOR UPDATE锁范围扩大,接近表锁。解法:确认version_id上有主键、唯一索引或普通索引,让行锁只锁目标版本。
本篇小结
- 主表加版本表的双指针,让编辑中的内容不影响已上架版本。
- 发帖入口按权限、防重、限流、解密、校验、版本落库的顺序挡住脏数据。
- 审核状态机用九种事件和终态集合收口网易盾、AI、人工与降级结果。
- 网易盾提交用 AFTER_COMMIT 加 Redis Stream 解耦,回调用 FOR UPDATE 加幂等保证聚合正确。
- 多图回调必须先锁版本行再 COUNT,判断和动作在同一事务内完成。