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 渲染成好看的页面,见文章排版一节。如果你想看更多合规与运营结合的案例,腾讯云实践里有专门的生产事故复盘。

延伸阅读