小程序内容安全检测怎么做:msgSecCheck 与 imgSecCheck 落地
两次被拒之后,先把链路想清楚
场景不复杂:一个小程序,带评论区和兴趣社区,用户能发文字也能发图。头一轮提审被拒,理由是「用户生成内容未接入安全检测」;补了一版自研敏感词过滤再提,还是被拒,拒绝理由一字不差。审核员要的不是你自己做了过滤,而是接入官方安全检测接口——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 官方文档:2.0 版参数、scene 取值与频控说明
- imgSecCheck 官方文档:图片格式、大小限制与错误码
- getAccessToken 官方文档:自建后端形态的 token 获取
微信小程序、msgSecCheck、imgSecCheck、内容安全、UGC 审核、云开发、access_token