小程序内容安全检测怎么做:msgSecCheck 与 imgSecCheck 落地

2026-10-10 01:16:06 2 次浏览
微信小程序内容安全msgSecCheckimgSecCheck云开发UGC审核

两次被拒之后,先把链路想清楚

场景不复杂:一个小程序,带评论区和兴趣社区,用户能发文字也能发图。头一轮提审被拒,理由是「用户生成内容未接入安全检测」;补了一版自研敏感词过滤再提,还是被拒,拒绝理由一字不差。审核员要的不是你自己做了过滤,而是接入官方安全检测接口——msgSecCheck(文本安全检测)和 imgSecCheck(图片安全检测)。

结论先说:UGC 内容必须在服务端过官方接口,客户端过滤和自研词库只能算辅助。 这不是建议,是审核口径。

小程序内容安全检测链路示意

本文把两种接入形态、access_token 管理、误杀兜底、频控降级这些动手时才会碰到的问题一次讲清,代码以微信云开发云函数(Node.js)为主,附带自建后端形态的对照。

msgSecCheck:文本检测,直接上 2.0 版

先说一个容易栽跟头的细节:这个接口有新旧两个版本,路径不一样。1.0 版是 /wxa/msg_sec_check,只传一个 content 字段,没有上下文,官方已不推荐新接入;2.0 版是 /wxa/security/msg_sec_check,要求带 version、openid、scene。网上不少老教程还在教 1.0 的写法,照着抄,接口能通,但判定能力差一截,提审时也可能被认定「检测不到位」。

2.0 版的必填项里,scene(场景)取值:1 资料、2 评论、3 论坛、4 社交日志,评论场景就传 2。openid 必须是内容产生者本人的,不要图省事由前端传入——前端传的东西都能伪造,而且它直接影响判定结果。

返回结构里有三个关键字段:result.suggest(pass / risk / review)、result.label(风险标签分类)、trace_id(本次检测的追踪标识)。处理原则:pass 放行,risk 拦截,review 不自动放行也不直接拒,进人工复核队列。 很多团队只在 risk 时拦截,review 直接放行,这是漏审的高发点。

云开发形态的调用

云开发走云调用,不需要自己管 access_token,这是它省心的地方。下面是评论场景的完整云函数示例。

// 依赖:wx-server-sdk@~2.6.3(package.json 中声明)
// 部署方式:云函数目录下 npm install 后右键上传并部署
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })

// 审核场景码:1 资料 2 评论 3 论坛 4 社交日志
const SCENE = { COMMENT: 2 }

exports.main = async (event) => {
  // 从云函数调用上下文取当前用户 openid,切勿由前端传入,可被伪造
  const { OPENID } = cloud.getWXContext()

  // 这里只做判空,长度与格式校验应放在入口层统一处理
  const content = (event.content || '').trim()
  if (!content) {
    return { ok: false, reason: 'EMPTY' }
  }

  // version 固定为 2,表示使用文本安全 2.0 版接口
  const res = await cloud.openapi.security.msgSecCheck({
    version: 2,
    scene: SCENE.COMMENT,      // 评论场景固定传 2
    openid: OPENID,            // 必填:内容产生者的 openid
    title: event.title,        // 可选:帖子标题,能显著提升判定准确度
    nickname: event.nickname,  // 可选:用户昵称作为上下文
    content,
  })

  // suggest 有三种取值:pass 放行 / review 存疑 / risk 违规
  const { suggest, label } = res.result
  // review 意味着模型拿不准,转人工复核而不是直接放行
  if (suggest === 'review') {
    return { ok: false, reason: 'REVIEW', traceId: res.trace_id, label }
  }
  // risk 直接拦截,记录 trace_id 便于用户申诉时回查
  if (suggest === 'risk') {
    return { ok: false, reason: 'RISK', traceId: res.trace_id, label }
  }
  // 只有 pass 才允许内容入库并对外展示
  return { ok: true, traceId: res.trace_id }
}

自建后端的调用与 access_token 管理

自建后端走 HTTPS 直调,绕不开 access_token 的管理。两个经验教训:一是用 getAccessToken 或 stable_token 接口集中刷新,存到 Redis 这类共享存储,不要每个服务实例各自刷新,新 token 生成会让旧 token 失效,互相顶掉之后整晚都是 40001 报错;二是刷新逻辑放进定时任务而不是请求路径里,请求时只读缓存,缓存未命中直接报错降级,别在用户请求里同步等 token 刷新。

imgSecCheck:1M 限制和 base64 的坑

图片检测比文本麻烦得多。imgSecCheck 接口收的是原始二进制图片,jpg / png 格式,大小不超过 1M,频率限制是单个 appId 100 次/分钟、100,000 次/天。下面两个坑,基本都是照着直觉写就会踩。

坑一:base64 传图报 47001。 有人把图片 base64 编码后塞进 JSON 发给接口,结果收到 errcode 47001(body 解析失败)。这个接口不吃 base64 字符串,要原始二进制流,Content-Type 用 application/octet-stream 之类的流式类型直接把 buffer 发出去。

坑二:手机原图超限。 现在手机随手一张照片 3 到 8M,直接转发必被拒。正确姿势是前端先用 wx.compressImage 压一道,服务端再用图像库兜底压到 1M 以内,两层都做,别指望单侧。走上传链路时,客户端 wx.uploadFile 把文件传到自有后端,后端拿到 buffer、确认大小,再原样转发给微信接口:

// 依赖:express@^4.19.0、multer@^1.4.5、axios@^1.7.0(Node 18+)
// multer 用内存存储,图片过完检测不落盘
const express = require('express')
const multer = require('multer')
const axios = require('axios')

const app = express()
// 内存存储:检测失败的图片没有留存价值,不必写磁盘
const upload = multer({ storage: multer.memoryStorage() })

// token 从集中缓存读取,刷新交给独立定时任务
async function getAccessToken() {
  const token = await redis.get('wx:access_token')
  // 缓存未命中就抛错走降级,不在请求路径里同步刷 token
  if (token) return token
  throw new Error('token 未就绪,检查定时刷新任务')
}

// 注册路由:客户端先 wx.uploadFile 把图片 POST 到这里
app.post('/sec/img', upload.single('image'), async (req, res) => {
  const buf = req.file?.buffer
  // 没拿到二进制,多半是前端表单字段名和接口约定不一致
  if (!buf) return res.status(400).json({ code: 'NO_FILE' })

  // imgSecCheck 只认原始二进制,塞 base64 会报 47001
  if (buf.length > 1024 * 1024) {
    // 超过 1M 直接回 413,让前端走压缩重传
    return res.status(413).json({ code: 'TOO_LARGE' })
  }

  try {
    // 关键:把 Buffer 作为请求体直传,Content-Type 用流式类型
    const { data } = await axios.post(
      `https://api.weixin.qq.com/wxa/img_sec_check?access_token=${await getAccessToken()}`,
      buf,
      // header 不要手工拼 JSON,保持流式二进制即可
      { headers: { 'Content-Type': 'application/octet-stream' } }
    )
    // errcode 0 = 通过;87014 = 图片含违规内容
    if (data.errcode === 0) return res.json({ ok: true })
    if (data.errcode === 87014) return res.json({ ok: false, reason: 'RISK' })
    // 频控等其它错误码上抛,交给降级逻辑入队重试
    return res.status(503).json({ code: 'UPSTREAM', errcode: data.errcode })
  } catch (e) {
    // 网络异常同样不阻断业务,标记待复核后异步补检
    res.status(503).json({ code: 'UPSTREAM_ERROR' })
  }
})

云开发形态下稍有不同:图片先传云存储,云函数里 cloud.downloadFile 拿到 buffer,再走 cloud.openapi.security.imgSecCheck(media 字段传 { contentType, value: buffer })。另外官方在推 mediaCheckAsync(异步版媒体检测),新项目值得评估,本篇以同步接口为例讲清楚链路。

常见错误码速查:

错误码 含义 处置
47001 body 格式错误 多为把 base64 / JSON 发给了图片接口,改传二进制
87014 内容含有违法违规内容 拦截并记录 trace_id,给用户友好提示
45011 频率超限 触发降级:入队重试或标记待复核
40001 access_token 无效 检查刷新逻辑,多半是多实例互顶

两种接入形态怎么选

维度 云开发(云函数) 自建后端
接口调用 cloud.openapi.security.*,免 token HTTPS 直调,需自管 access_token
token 管理 官方托管 stable_token + Redis,定时刷新
图片链路 先传云存储,云函数 downloadFile 取 buffer uploadFile 直达后端,少一跳
额外成本 冷启动延迟、按量计费 服务器、域名、HTTPS 证书
适用场景 小团队快速落地、过审优先 已有后端、需要统一风控中台

经验是:如果项目本来就用云开发做后端,文本和图片检测都留在云函数里,别为了检测单起一套自建服务;如果已经有成熟后端,那就在后端加一个独立的安全检测模块,业务方统一调用,方便日后切 mediaCheckAsync 时只改一处。

原理剖析:为什么 2.0 版非要 openid 和 scene

这是很多人到提审都没想明白的问题,值得单独拆开讲。

上下文改变判定阈值。 1.0 版只看文本本身,模型不知道这段文字出现在什么场景。「微商」两个字出现在用户资料昵称里,和刷屏出现在评论区,风险等级显然不同。2.0 版把 scene、openid、title、nickname 一起送进判定模型,官方可以按场景调整策略,同样一句话在评论区和在社交日志里的判定结果可能不一样。你传的上下文越真实,误杀越少。

openid 的作用不止是上下文,还有溯源。 每次检测都有 trace_id,配合 openid 能定位到具体用户和具体内容。用户申诉时,你拿 trace_id 回查,官方复核也有依据。如果 openid 是前端传的假值,这条溯源链就断了——这也是为什么必须从服务端调用上下文里取。

为什么官方把内容安全做在服务端,而不是给个客户端 SDK? 道理不复杂:客户端检测能被绕过,抓包改包就能跳过;词库或模型下发到端上等于公开;判定标准的迭代权必须握在官方手里,热点变化时官方调接口策略,所有小程序同步生效,开发者不用追。所以任何「前端先过滤一遍」的方案,在审核口径里都不算数,检测必须发生在服务端、在内容对外展示之前。

误杀与漏杀的取舍。 suggest=risk 直接拦是共识,难的是 review。自动放行会漏审,直接拒会误伤。合理的做法是把 review 进人工队列,用业务特征加权排序(新注册账号优先、带联系方式的高优先),让有限的审核人力先看风险高的。安全检测本质上是在误杀率和漏杀率之间找平衡点,社区型产品宁可误杀多一点,配合申诉渠道兜底。

频控、降级与白名单兜底

imgSecCheck 的频控(100 次/分钟)比 msgSecCheck(4000 次/分钟)紧得多,评论高峰期图文一起来,图片频控很容易撞线。降级逻辑要提前设计好,不能等线上报错再想。整体判定流程可以先看这张时序图:

sequenceDiagram
    participant U as 客户端
    participant S as 云函数/后端
    participant W as 微信安全接口
    participant D as 业务数据库
    U->>S: 提交评论(文字+图片)
    S->>W: msgSecCheck 2.0(content+scene+openid)
    W-->>S: suggest: pass/risk/review + trace_id
    S->>W: imgSecCheck(图片二进制 ≤1M)
    W-->>S: errcode: 0 / 87014 / 频控
    alt 全部通过
        S->>D: 内容入库,记录 trace_id
        D-->>U: 发布成功
    else 存疑或频控
        S->>D: 标记「待复核」入库,异步补检
        D-->>U: 先个人可见,复核通过后公开
    else 明确违规
        S-->>U: 拦截并提示,不产生任何记录
    end

图片这条支线遇到频控时的处理,单独展开成流程图更直观:

flowchart TD
    A[用户上传图片] --> B{大小 ≤ 1M?}
    B -- 否 --> C[前端压缩或服务端兜底压缩] --> D
    B -- 是 --> D[调用 imgSecCheck]
    D --> E{errcode}
    E -- 0 --> F[通过,入库并公开]
    E -- 87014 --> G[拦截,记录 trace_id]
    E -- 45011 频控 --> H[入消息队列延迟重试]
    H --> I{重试是否成功}
    I -- 是 --> F
    I -- 否 --> J[标记待复核入库,仅个人可见]
    J --> K[异步补检通过后转公开]
    E -- 其它错误 --> J

白名单兜底是另一件实际绕不开的事。影视名、行业术语、品牌商品名,都可能被误判成 risk。做法是维护一张业务白名单,检测命中 risk 后先过一遍白名单,命中就放行并打日志。两个纪律要守住:白名单必须人工维护、留变更记录,别让它变成绕过检测的后门;白名单只对「整体命中」生效,句子改造过的变体依然要拦。检测结果连同 trace_id 一起落库,申诉、复盘都有据可查。

提审前的自检清单

  • 所有用户输入路径(评论、回复、昵称、签名)都在服务端过 msgSecCheck 2.0,且带真实 openid 和 scene
  • 用户上传的图片在对外展示前过 imgSecCheck,超限先压缩
  • access_token 集中管理,定时刷新,多实例共享
  • 频控降级逻辑存在,review 有复核队列,日志里有 trace_id
  • 客户端过滤可以有,但不能作为兜底手段——它只是体验优化

最后澄清一个常见误区:有人以为接了接口就能一劳永逸。接口本身在演进,1.0 版已不建议新接入,异步版的 mediaCheckAsync 在逐步成为推荐方向。把检测封装成独立服务层,业务代码只依赖「提交内容、拿到结论」这个抽象,将来换接口版本就不用动业务。内容安全没有一次性做完的工程,链路想清楚、分层留好扩展点,比堆代码重要。你在落地时踩过什么别的坑,欢迎评论区聊聊。

参考与延伸

微信小程序、msgSecCheck、imgSecCheck、内容安全、UGC 审核、云开发、access_token

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。