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

封面:社区发帖设计

一条帖子从点击发布到出现在圈子,背后要过权限、防重、限流、解密、版本落库五道关,再排队穿过网易盾、AI 与人工三层审核。任何一层漏判,用户看到的就是发不出去、审核卡住或内容错乱。这篇复盘把发帖与审核两条链路完整拆开。

本篇你能学到 ​

  • 用主表加版本表的双指针设计,让编辑中的内容不影响已上架版本。
  • 用 Redis SETNX 加分钟/天双窗口计数器,挡住重复提交和刷帖。
  • 用显式状态机统一编排网易盾、AI、人工与降级四路审核结果。
  • 用 AFTER_COMMIT 加 Redis Stream,把发帖与审核解耦且不产生幽灵消息。
  • 用 FOR UPDATE 行锁修复多图回调并发导致的审核卡死。

一、需求、链路与数据模型 ​

做社区发帖,表面看是把一段文字和几张图片存进数据库,实际要同时解决三个问题:谁能发、内容怎么审、上架后怎么分发。先看需求与链路的全貌,再进入代码。

1. 需求背景与目标 ​

  • 用户侧:多轮用户调研显示,用户对投资类社区的认知是“可以发言表达情绪的地方”和“可以看到投资牛人经验分享”的社区。
  • 平台侧:产品长期缺乏专属的留客抓手。
  • 竞品压力:多家头部理财与投资类产品均已开设内容社区,部分平台已通过社区运营带动销售转化,并在发力内容创作激励。

产品现状的痛点也很直接:

  • 社交互动欠缺:缺少发言评论功能,用户间互动困难;
  • 内容生态薄弱:话题深度不足、产量少、缺乏独家内容;KOL 发帖耗时长且缺少激励机制。

核心目标是建设并优化发帖功能,让用户更好、更方便地发表内容,充实内容池,达成“头部用户持续分享投资经验、海量用户踊跃发言”的社区。

把目标翻译成工程语言,就是三件事:发得出去、审得过来、上架后能被分发。后面的链路和数据模型都围绕这三件事展开。

2. 端到端链路与消息边界 ​

先看一条帖子从提交到上架的完整路径。

text
用户发帖
   ↓
[网易盾审核](文本异步 + 图片异步,并发提交)
   ↓ 通过
[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 把一次发帖拆成六步:

text
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:

text
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,重点在版本号和图片差量:

text
updatePost(发布请求):
    ① 清除旧审核通知的已读标记
    ② 校验帖子归属 + 审核中禁止编辑(状态处于审核中集合则拒绝)
    ③ 创建新版本(版本号 = 旧版本号 + 1,写入内容快照与初始审核状态)
    ④ 更新主表 pendingVersionNo 指向新版本
    ⑤ 图片差量处理:
       - 新图在旧版本中已存在 → 批量 clone(复用历史图片记录)
       - 全新图片             → 批量 claim(认领上传阶段预插入的记录)
    ⑥ 重新关联圈子 / 股票 / 话题(旧关联失效,新关联插入)
    返回 PostAuditMessageDTO

编辑时先清掉旧审核通知的已读标记,用户重新提交后能看到新的审核结果。审核中的帖子禁止编辑,避免同一个版本被反复改写。新版本号等于旧版本号加一,先挂到 pendingVersionNo,审核通过后再切 current。

图片差量处理是为了省存储和避免重复上传。新图在旧版本里已经存在的,直接批量 clone 复用历史记录;全新图片才去 claim 上传阶段预插入的记录。圈子、股票、话题则全部重新关联,旧关联失效,新关联插入。

配图

图 1:发帖主流程,权限、防重、限流、解密校验、版本落库、事务消息

图 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 小时过期

限流的核心逻辑是时间片计数,第一次写入时设置过期:

text
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 拒绝时转人工复核),各阶段事件由统一的状态机编排推进。全景如下。

text
帖子版本进入审核(发帖事务提交后推送 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_PASSAI 通过是(通过终态)
AI_REJECTAI 拒绝否(转人工审核)
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. 状态流转规则 ​

状态机的合法流转只有下面这些:

text
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:审核状态机,网易盾、智能审核、人工复核,最后上架或驳回

图 2:审核状态机,网易盾、智能审核、人工复核,最后上架或驳回

从图里能看到三条出口:网易盾拒绝直接下架,AI 通过或人审通过上架,人审拒绝下架。中间的 AI 拒绝只是一次转人工,不是终态。状态机的价值就在这张图上,所有异步结果最终都收敛到有限的几个状态。

3. fireEvent 主流程:乐观锁、日志与终态处理 ​

fireEvent 是所有事件的唯一入口,主流程如下:

text
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 在发帖事务提交后推送:

text
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 版本补推

审核消息体刻意保持极简,只带两个定位字段:

json
{
  "postId": 1001,
  "versionId": 1002
}
字段类型说明
postIdLong帖子主表 ID
versionIdLong本次送审的版本 ID

消息体只携带“定位坐标”而不携带内容。消费者凭 versionId 反查内容快照、图片列表等全部审核上下文,消息与业务数据解耦。帖子即使随后再次编辑,本次审核仍锚定提交事务时的版本。

消费入口是 RedisStreamMsgConsumer.onMessage,先做幂等,再做降级判断:

text
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,网易盾故障时业务不中断。

真正提交网易盾时,网络请求和数据库快照分开处理:

text
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:

json
{
  "contentId": "1001_1002_1780558064231_a1b2c3d4",
  "content": "帖子正文(纯文本)",
  "source": "community_post",
  "businessType": 2
}
字段说明
contentId审核任务标识(即版本表 auditTaskId),格式 postId_versionId_时间戳_8位随机串,回调凭它反查帖子版本
content送审文本内容
source业务来源标识(接入方与网易盾约定)
businessType业务类型枚举(本场景固定值)

图片提交按每张图片独立一次请求,只传分组和 guid:

json
{
  "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 的文本/图片两个消费者处理。

回调到达后统一采用下面的模式,先锁版本行,再判断状态,最后决定是否触发事件:

text
处理回调消息:
    try:
        设置 TraceId
        事务内:
            ① FOR UPDATE 行锁查询帖子版本的综合审核状态
            ② 状态分支判断 → 决定是否触发状态机事件
            ③ 条件更新版本表(乐观锁,带期望状态)
            ④ (图片)更新图片子表 + 删除临时审核记录
        事务外:
            若更新成功且需要触发事件 → stateMachine.fireEvent(...)
    finally:
        清除 TraceId

文本回调带 returnCode 和命中标签,301 表示通过,302 表示不通过:

json
{
  "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:

json
[
  {
    "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 细分命中(含命中内容与命中位置)

文本回调的处理规则是“拒绝短路,通过等待图片”:

text
handleTextAuditMessage(文本审核消息):
    事务内(FOR UPDATE 锁定版本行):
        ① 若综合状态 = 网易盾审核拒绝(终态拦截):
           仅更新文本子状态快照,不触发事件
        ② 若综合状态 = 网易盾审核中:
           - 文本通过:
               文本子状态 = 通过
               若图片子状态已通过 → 聚合: 网易盾审核状态=通过, 触发 NETEASE_PASS
               否则 → 等待图片回调(仅更新子状态)
           - 文本拒绝:
               拒绝短路: 文本子状态=拒绝, 网易盾审核状态=拒绝
               触发 NETEASE_REJECT(附违规标签)
        ③ 其他状态 → 忽略该回调

图片回调的处理规则是“多图等待,最后一张收尾”:

text
handleImageAuditMessage(图片审核消息):
    凭 guid 查 t_post_images_auditing 反查 versionId
    (查不到 → 已处理过,幂等直接返回成功)

    事务内(FOR UPDATE 锁定版本行):
        ① 锁内查询剩余未审核图片数量 auditingImgs
        ② 批次处理: 若 auditingImgs > 1
           仅更新当前图片子表 + 删除该图临时审核记录
           不触发状态机
        ③ 最后一张图片(auditingImgs == 1):
           - 综合状态 = 网易盾审核拒绝(终态拦截): 仅更新图片子状态快照
           - 综合状态 = 网易盾审核中:
               计算图片整体结果:
                 当前图通过 且 无历史拒绝图片 → 通过
                 否则 → 拒绝
               - 图片整体拒绝 → 拒绝短路: 触发 NETEASE_REJECT
               - 图片整体通过 且 文本已通过 → 聚合: 触发 NETEASE_PASS
               - 图片整体通过 但 文本未结束 → 等待文本回调
               - 其他 → 忽略
           更新版本表 + 更新图片子表 + 删除临时审核记录
    事务外:
        触发状态机事件

两条回调规则合起来,就是文本和图片的聚合逻辑。文本拒绝立即短路,不再等图片;文本通过则等图片全部完成;图片最后一张完成时,如果文本已通过就触发 NETEASE_PASS,否则继续等文本。终态拦截保证已经拒绝的版本不会再被后续回调拉起。

多图并发回调曾引发“审核流程提前结束”的线上问题,无锁 COUNT 判断被并发穿透,根因分析与修复方案独立记录在第五章。

3. AI 智能审核:提交、轮询与转人工 ​

AI 审核分四步:

  1. 触发条件:网易盾审核通过(或降级通过),状态机在事务提交后异步发起;
  2. 获取审核任务号:调用 AI 审核提交接口(提交标题加内容),返回审核任务号 auditId;
  3. 查询审核结果:调用 AI 审核结果查询接口(批量最多 10 个),由定时任务 RefreshAIAuditResultJob 轮询刷新;
  4. 结果处理:AI 通过 → 执行上架;AI 未通过 → 转人工审核(AI_REJECT 非终态)。

接口实现集中在 AiAuditServiceImpl:

方法说明
submitAudit提交审核(AI 审核提交接口),返回 auditId
getAuditResults批量查询审核结果(AI 审核结果查询接口,≤10 个/次)
text
submitAudit(请求):
    响应 = HTTP POST(AI 审核提交接口, JSON 序列化请求体)
    校验业务状态 status == 1
    返回 auditId

getAuditResults(auditIds):
    校验: 列表非空 && 数量 ≤ 10
    响应 = HTTP POST(AI 审核结果查询接口, {auditId 列表})
    校验业务状态
    返回审核详情列表

统一请求执行器:
    - 请求体 > 300 字符截断、响应体 > 500 字符截断(防长文本撑爆日志)
    - 记录请求耗时
    - HTTP 非 200 / IO 异常 / 反序列化失败 → 统一抛业务异常

提交请求的 title 允许为空,空时截取正文前 10 字:

json
{
  "title": "帖子标题(无标题时截取正文前 10 字)",
  "content": "帖子正文(纯文本)",
  "userCode": "",
  "sourceName": "社区发帖",
  "subType": "post",
  "reviewType": "AI",
  "infoType": "doc",
  "url": ""
}
字段说明
title标题;无标题时取正文前 10 字(去空格)
content正文纯文本
userCode用户标识(本链路传空,审核结果只与内容挂钩)
其余字段来源 / 子类型 / 审核类型等信息字段,按接口约定传默认值

提交响应返回审核任务号,落版本表 aiAuditTaskId:

json
{
  "status": 1,
  "errmsg": "success",
  "data": { "auditId": "165034801957441536" }
}

查询请求一次最多带 10 个任务号:

json
{
  "auditId": ["165025584215232512", "161727017060929536"]
}

查询响应里既有结论,也有拒绝原因和改写建议:

json
{
  "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 无锁并发缺陷:

text
# 原始(有缺陷)逻辑
auditingImgs = 无锁 SELECT COUNT(剩余未审核图片数)     # ① 判断
若 auditingImgs > 1:
    事务内 { 更新当前图片子表 + 删除临时记录 }          # ② 动作
    返回
若 auditingImgs == 1:                                  # 最后一张
    更新主表 + 触发状态机

同一帖子版本两张图片的审核回调几乎同时到达时,两个线程的执行时序如下:

text
线程 A(图片 A)                     线程 B(图片 B)
────────────────────                ────────────────────
查询剩余数量 → 2(记录均未删)
                                    查询剩余数量 → 2(A 还没删)
删除图片 A 临时记录
                                    删除图片 B 临时记录
判断: 2 > 1 → 只更新子表
                                    判断: 2 > 1 → 只更新子表
结束(无人判定自己是最后一张)

本质是判断与动作不在同一原子边界内。无锁 COUNT 和删除临时记录之间没有锁,两个线程的判断都基于“过期快照”。它们都在对方删除前读取了计数,因此都认为“还有别的图片在审核”,最终无人触发主表更新,审核流程提前结束或卡住。

2. 修复方案:提前加锁、合并事务、锁内重读 ​

修复围绕三个点展开:

  1. 提前加锁:事务内首先 SELECT ... FOR UPDATE 锁定帖子版本主表行,把同一版本的所有图片回调串行化;
  2. 合并事务边界:判断 + 更新子表 + 更新主表合并进同一事务;
  3. 锁内重新读取剩余数量:COUNT 查询放在持锁之后,保证读到的是最新值。

修复后的流程变成一条串行链路:

text
# 修复后流程
图片回调到达
  ↓
进入事务
  ↓
SELECT ... FOR UPDATE 锁定帖子版本行(version_id)
  ↓
锁内查询剩余未审核图片数量
  ↓
若 剩余数量 > 1:
    更新当前图片子表
    删除当前图片临时审核记录
    提交事务(不触发状态机)
  ↓
若 剩余数量 == 1(最后一张):
    更新当前图片子表
    删除当前图片临时审核记录
    更新帖子主表(版本表)审核状态(带期望状态的条件更新)
    提交事务
    事务外触发状态机

修复后的并发时序是“先到先做,后到等待”:

text
线程 A(图片 A)                     线程 B(图片 B)
────────────────────                ────────────────────
事务开启
FOR UPDATE 锁住版本行
                                    事务开启
                                    FOR UPDATE → 被阻塞(等 A)
锁内 COUNT = 2 → 非最后一张
删除图片 A 临时记录
提交事务,释放锁
                                    获得行锁
                                    锁内 COUNT = 1(A 已删)→ 最后一张
                                    删除图片 B 临时记录
                                    更新主表审核状态
                                    提交事务
                                    事务外触发状态机

配图

图 3:多图回调并发修复,无锁计数会漏判,加行锁串行收尾

图 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 之一。

状态机触发仍然放在事务外:

text
if 事务结果.更新成功 && 事务结果.需要触发事件:
    stateMachine.fireEvent(...)      # 事务外

这样安排有三个原因:状态机可能涉及外部调用、事件发布、异步任务,不应放进数据库事务;事务内只保证数据库状态一致性;事务提交成功后再触发状态机,避免“事务回滚但状态机已执行”。

但必须配套补偿/重试机制。否则可能出现“数据库状态已更新、状态机未流转、下游等不到事件”的中间态。这也是前面强调兜底定时任务的原因。

性能影响可以接受:行锁持有时长等于一次短事务,只有数次索引查询和索引更新;只锁同一版本行,不同帖子互不阻塞;图片回调本就是低频事件,串行化的性能损耗可接受。

4. 经验教训与设计要点 ​

这次事故沉淀出六条通用经验:

  1. 不要依赖普通查询做并发判断:COUNT → if(count==1) → doFinalAction() 在并发下多个线程可能同时查到相同结果;
  2. Check-Then-Act 必须保证原子性:判断和动作要么同一事务 + 先加锁,要么用唯一约束 / 乐观锁 / 分布式锁;
  3. 数据库悲观锁适合“同一业务对象串行处理”:本场景即“同一帖子版本的多个图片回调串行”,SELECT ... FOR UPDATE 是标准解法;
  4. 事务边界要覆盖完整判断链路:只在更新阶段加事务而判断放在事务外,判断结果可能已过期;
  5. 加锁查询必须放在事务内:不在事务内锁会很快释放,无法保护后续逻辑;
  6. 行锁依赖索引:加锁前必须确认 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、人审和终态处理压到一张图里,整体时序如下:

text
用户发帖
  │  权限/防重/限流/解密/校验
  ▼
主表+版本表+关联落库(事务)
  │  事务提交后 → 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 → 终态: 推送拒绝消息 + 主表下架

预期结果是,任何一条帖子都能在图上找到自己的位置:要么走到上架,要么走到驳回,要么停在某个可重试的中间态。中间态有兜底任务补推,终态只处理一次,重复回调被幂等和条件更新挡住。

常见错误与排错 ​

  1. 现象:审核一直停在“网易盾审核中”,日志里没有任何线程进入“最后一张图片”分支。原因:多图回调并发时,无锁 COUNT 被两个线程同时读到旧值,判断和删除不在同一原子边界。解法:事务内先 SELECT ... FOR UPDATE 锁版本行,锁内重新 COUNT,再更新子表和主表。
  2. 现象:状态机更新行数为 0,日志抛“状态不满足触发条件”。原因:重复回调或并发事件已经推进过状态,乐观锁条件更新没命中。解法:按 PENDING_AUDIT 等当前状态做幂等判断,重复消息直接 ACK,不重复触发。
  3. 现象:发帖成功但帖子一直不进审核。原因:事务回滚导致 AFTER_COMMIT 事件没触发,或入队异常被吞掉。解法:确认监听器使用 AFTER_COMMIT 加 fallbackExecution = true,并依赖 RedisMsgConsumeRetryJob 扫描 PENDING_AUDIT 版本补推。
  4. 现象:图片回调报“找不到 versionId”。原因:t_post_images_auditing 临时记录没写入,或已被前一次回调删除。解法:提交成功时批量插入临时记录,回调查不到时按幂等处理直接返回成功,避免重复消费报错。
  5. 现象:一个帖子的图片回调把其他帖子的回调也堵住。原因:version_id 没有索引,FOR UPDATE 锁范围扩大,接近表锁。解法:确认 version_id 上有主键、唯一索引或普通索引,让行锁只锁目标版本。

本篇小结 ​

  • 主表加版本表的双指针,让编辑中的内容不影响已上架版本。
  • 发帖入口按权限、防重、限流、解密、校验、版本落库的顺序挡住脏数据。
  • 审核状态机用九种事件和终态集合收口网易盾、AI、人工与降级结果。
  • 网易盾提交用 AFTER_COMMIT 加 Redis Stream 解耦,回调用 FOR UPDATE 加幂等保证聚合正确。
  • 多图回调必须先锁版本行再 COUNT,判断和动作在同一事务内完成。