4.3 内容审核:安全与合规检查
发帖链路跑通的那天晚上我正准备庆祝,第二天早上就收到平台通知:有用户发了条涉赌内容被投诉。图文应用只要开门接 UGC,审核就不是可选项而是硬门槛。这一节聊我们怎么把审核卡进发布链路:文本同步过、图片异步过、状态机兜底、失败可重试。
场景:合规检查卡在哪一步
需求拆开是这样的:
- 用户发文时不能被等死,提交后先入库再审
- 审核结果要写回文章记录,拒绝时给用户一个能看懂的提示
- 审核服务偶尔会抽风,不能因为它挂了导致所有帖子发不出去
- 自己也要能加关键词,挡住引流、联系方式这类平台规则禁止的内容
落地方案是在云开发控制台的扩展市场里安装内容审核扩展,然后云函数里就能直接调用审核接口,不用自己去对接鉴权和签名。安装过程在本书目录第三篇的云函数一节有截图步骤,这里默认已经装好。
方案:状态机加两级审核
4.1 的记录里 status 字段就是为这一节留的,整套流转规则如下:
| status | 含义 | 公开列表展示 | 用户侧提示 |
|---|---|---|---|
| 0 | 草稿 | 否 | 无 |
| 1 | 待审核 | 否,仅作者可见 | 发布成功,审核中 |
| 2 | 审核通过 | 是 | 正常展示 |
| 3 | 审核拒绝 | 否 | 内容含违规信息,请修改 |
审核时机选在发布之后而不是发布之前:用户点发布立即返回成功,函数在后台异步跑审核,体验上没有等待感。代价是列表要多一道 status 过滤,这个成本可以接受。
文本审核的函数:
// cloudfunctions/checkArticle/index.js
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
exports.main = async (event, context) => {
const { postId } = event
const res = await db.collection('articles').doc(postId).get()
const post = res.data[0]
if (!post) return { code: 404 }
try {
// 同步文本审核,违规时抛出 errCode 87014
await cloud.openapi.security.msgSecCheck({ content: post.title + post.content })
await db.collection('articles').doc(postId)
.update({ data: { status: 2, updatedAt: Date.now() } })
return { code: 0, msg: '审核通过' }
} catch (e) {
if (e.errCode === 87014) {
await db.collection('articles').doc(postId)
.update({ data: { status: 3, updatedAt: Date.now() } })
return { code: 3, msg: '内容含违规信息,请修改后重试' }
}
console.error('审核服务异常', e)
return { code: 500, msg: '审核服务繁忙,已进入重试队列' }
}
}
注意最后那个分支:审核服务本身报错时,文章保持 status 为 1 留在待审队列里,由定时任务每小时扫一遍补审,而不是直接判死。把服务故障和内容违规分开处理,是这条链路最关键的设计。
图片审核接口用法类似,但有限频和尺寸要求,大图要先压缩:
| 内容类型 | 审核方式 | 耗时量级 | 注意事项 |
|---|---|---|---|
| 纯文本 | 同步接口 | 几十毫秒 | 标题正文拼一起送审 |
| 单张图片 | 同步接口 | 秒级 | 限大小,超大图先压缩 |
| 批量图片 | 异步任务 | 分钟级 | 配回调或轮询状态 |
| 历史数据 | 定时全量重审 | 小时级 | 注意接口配额,分批休眠 |
发布链路里再串一个兜底的定时重审函数,把审核异常留下的 status 为 1 的记录补完:
// cloudfunctions/retryPendingCheck/index.js
// 定时触发器每小时跑一次
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
exports.main = async () => {
const hourAgo = Date.now() - 60 * 60 * 1000
const res = await db.collection('articles')
.where({ status: 1, updatedAt: _.lt(hourAgo) })
.limit(50)
.get()
for (const post of res.data) {
try {
await cloud.openapi.security.msgSecCheck({ content: post.title + post.content })
await db.collection('articles').doc(post._id).update({ data: { status: 2, updatedAt: Date.now() } })
} catch (e) {
if (e.errCode === 87014) {
await db.collection('articles').doc(post._id).update({ data: { status: 3, updatedAt: Date.now() } })
} else {
console.error('重审仍失败', post._id, e.errCode)
}
}
await new Promise(r => setTimeout(r, 300)) // 限频保护
}
return { checked: res.data.length }
}
limit 加休眠是为了把批量扫描对正常发布审核的QPS影响压到最小,这个函数本身也是可以随时手动触发的救火按钮。
踩坑与排查
第一个坑是误伤。第一批被拒的帖子里有一条只是写了加我微信聊二手交易,被判为违规营销。审核模型的规则不能改,我们的做法是在合规前提下调整产品文案引导,同时在拒绝提示里给出修改方向,而不是直接甩一句违规了事。用户体验和合规不是对立的,对立的是偷懒的提示。
第二个坑是把敏感细节回传给前端。最初拒绝原因里带了命中的原文片段,安全 review 时被指出这等于把违规内容又展示了一遍。合规的姿势是只给通用提示,细节留在云函数日志里供运营查。
第三个坑是限频。上了批量重审的定时任务后,短时间几百次调用把接口配额打满,正常发布审核全部排队超时。后来每次调用之间 sleep 几百毫秒,并且重审只挑最近一周更新过的记录,配额问题再没出现。
第四个坑是成本失控。审核接口按条计费,有阵子有人写脚本高频发帖,账单里审核费用涨得比存储还快。临时加了发布频率限制,一个人十分钟内最多提交五篇,配合定时任务统计异常账号。安防和费用在这里是同一件事,详见监控与成本一节。
接入审核那晚我又把活动页翻出来看了一眼,云开发 CloudBase 的活动页里的扩展包通常比单接口计费划算,流量起来后可以重点对比一下。
小结
审核这件事的要点不在调通接口,而在把异常路径想全:服务挂了留队列、限频就分批、误伤给提示、成本要设闸。状态机是骨架,重试机制是保险丝。下一节是发布链路的最后一段:内容合规之后,怎么把 Markdown 渲染成好看的页面,见文章排版一节。如果你想看更多合规与运营结合的案例,腾讯云实践里有专门的生产事故复盘。
延伸阅读
- Python 云开发实战目录:审核前后链路的章节索引
- 腾讯云实践:内容安全事故复盘合集
- AI 工具包实战:AI 辅助内容识别的另一种思路
