UGC(用户生成内容)业务里,审核是绕不开的环节。评论、昵称、个人签名、文章标题、聊天消息,任何允许用户输入文本的位置,都可能出现垃圾广告、辱骂攻击、违规链接等内容。直接在业务接口里写几行includes判断只能应付演示,真正做审核需要一条独立的处理链路,而这条链路的入口通常就是一个叫 moderation endpoint 的接口。在 JavaScript 技术栈里,这个端点用 Node.js 和 Express 可以快速搭出来,但要落到生产环境,还要正确设计本地规则、第三方审核服务调用、超时降级、缓存、限流和日志。下面从零实现一个可以本地运行的内容审核端点,再逐步补齐生产环境需要的工程细节。
1. 先搞清楚审核端点在内容链路里的位置
1.1 审核端点解决什么问题
很多项目最初是在业务接口里直接做关键词判断:
if (req.body.content.includes('某些词')) { return res.status(400).json({ error: '内容不合法' }); }这种写法的问题不是“不能用”,而是“无法演进”。随着业务上线,审核规则会越来越复杂,判定逻辑会从几行字符串判断变成多级规则、模型打分、人工复审的完整流程。如果这些逻辑都写在评论接口、头像接口、签名接口里,每一处业务代码都会变成需要同步修改的重灾区。
moderation endpoint 的核心作用是做一个独立的内容安检入口:把“内容是否允许通过”从业务逻辑里抽出来,单独成为一个服务端点。业务接口拿到用户输入后,先调用审核端点,得到pass、review或block三个结论中的一个,再决定写入存储、进入人工复审队列还是直接拒绝。
这样做的好处有三个:
- 审核规则集中在一条链路上,改规则不用改业务接口。
- 外部审核服务的调用、超时、重试只影响审核端点,不影响核心写入链路。
- 每次审核的请求、结果、耗时都有统一入口可以记录,方便追溯和复盘。
1.2 审核端点应该返回什么
审核端点本质上是一个纯判定服务,不负责入库,也不负责通知用户。它的输入是待审核内容以及少量上下文,输出是一份结构化的判定结果。推荐的最小返回结构如下:
| 字段 | 类型 | 含义 |
|---|---|---|
| requestId | string | 请求唯一标识,用于串联日志 |
| action | string | 最终动作:pass、review、block |
| score | number | 风险分数,0 到 100,越高越危险 |
| matchedRules | array | 命中的本地规则列表 |
| reviewedBy | string | 本次判定来源:local、remote、remote+local |
| fromCache | boolean | 是否命中缓存 |
| elapsedMs | number | 本次审核耗时 |
其中action是下游最关心的字段。业务方拿到pass就走正常入库,拿到review就投递到人工复审队列,拿到block就拒绝并记录日志。
注意:审核端点的返回值要稳定,不能今天返回
pass、fail,明天改成approved、rejected。下游系统依赖这个字段做分支处理,字段语义一旦变化,线上就会出现漏审或误杀。
1.3 同步审核和异步审核的区别
审核端点本身只是把判定能力暴露出来,具体是同步调用还是异步消费,取决于业务场景。
| 维度 | 同步审核 | 异步审核 |
|---|---|---|
| 调用方式 | 业务接口直接请求审核端点 | 内容先写入,再投递到消息队列审核 |
| 用户等待 | 接口多一次网络耗时 | 无需等待审核结果 |
| 风险窗口 | 审核通过才入库,基本无风险窗口 | 审核完成前内容已可见,存在短暂风险窗口 |
| 适用场景 | 昵称、个人签名、评论标题等短文本 | 文章、长评论、图片等可事后处理的内容 |
| 实现成本 | 较低 | 需要引入队列和消费端 |
实际项目中,高风险短字段通常用同步审核,低风险或海量内容用异步审核。如果材料里没有特殊说明,本文先实现同步审核端点,异步扩展放在最后一节介绍。
2. 环境准备与项目骨架
2.1 环境要求
下面的示例代码使用 Node.js 18 以上的版本,因为代码里用到了原生fetch和AbortController,这两个能力在 Node 18 之后可以不用额外安装依赖。
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Node.js | 18 及以上 | 需要原生 fetch、AbortController、randomUUID |
| npm | 9 及以上 | 通常随 Node 安装 |
| express | 4.x | Web 框架,4.x 启动代码稳定 |
| dotenv | 16.x | 读取 .env 配置文件 |
如果实际环境 Node 版本较低,可以把fetch替换成axios,但代码里需要额外处理超时和取消请求的逻辑。
2.2 初始化项目和安装依赖
mkdir js-moderation-endpoint cd js-moderation-endpoint npm init -y npm install express dotenv安装完成后,在项目根目录创建.env和.env.example两个文件。.env.example用于记录配置项模板,提交到代码仓库;.env存放本机实际配置,加入.gitignore。
PORT=3000 MODERATION_API_URL=http://localhost:4000/mock-moderation MODERATION_API_KEY= MODERATION_TIMEOUT_MS=2000 MODERATION_RETRIES=2 MODERATION_FALLBACK_ACTION=review PASS_SCORE_THRESHOLD=60 REVIEW_SCORE_THRESHOLD=80 MODERATION_CACHE_TTL_SEC=300 MODERATION_CACHE_MAX_SIZE=10000 MAX_CONTENT_LENGTH=20002.3 目录结构
js-moderation-endpoint/ ├── .env.example ├── package.json ├── mockModerationServer.js └── src/ ├── server.js ├── config/ │ └── index.js ├── routes/ │ └── moderation.js └── lib/ ├── cache.js ├── localRules.js ├── moderator.js └── remoteModerationClient.js各文件职责如下:
server.js:创建 Express 应用,注册路由,启动监听。config/index.js:集中读取环境变量,统一管理配置。routes/moderation.js:暴露POST /api/v1/moderation端点。lib/localRules.js:纯本地规则,跑得快,不依赖网络。lib/remoteModerationClient.js:调用第三方审核服务,带超时和重试。lib/moderator.js:编排本地规则、远程服务和缓存,输出最终判定。lib/cache.js:内存缓存,避免相同内容反复请求外部服务。mockModerationServer.js:本地模拟第三方审核服务,方便离线联调。
3. 实现审核端点核心代码
3.1 先定义一个本地规则引擎
本地规则的价值在于成本低、不依赖网络。对明显违规的内容,本地规则直接拦截即可,不必把每个请求都发给第三方审核服务。这里的规则设计成“一条规则返回一个分数”,后面再汇总成风险总分。
// src/lib/localRules.js const SPAM_RE = /(加微信|免费领取|点击抽奖)/i; const URL_RE = /(https?:\/\/|www\.)[^\s]+/gi; const REPEAT_RE = /(.)\1{5,}/; function checkLength(content) { if (content.length > 500) { return { rule: 'length', score: 30, message: '内容过长' }; } return null; } function checkUrl(content) { const matches = content.match(URL_RE) || []; if (matches.length >= 3) { return { rule: 'too_many_urls', score: 45, message: '链接数量异常' }; } if (matches.length > 0 && /(点击|加|优惠)/.test(content)) { return { rule: 'url_with_ads_keyword', score: 40, message: '链接携带广告词' }; } return null; } function checkSpamKeywords(content) { if (SPAM_RE.test(content)) { return { rule: 'spam_keyword', score: 50, message: '命中垃圾广告关键词' }; } return null; } function checkRepeat(content) { if (REPEAT_RE.test(content)) { return { rule: 'repeat_char', score: 10, message: '存在连续重复字符' }; } return null; } function runLocalRules(content) { return [ checkLength(content), checkUrl(content), checkSpamKeywords(content), checkRepeat(content) ].filter(Boolean); } module.exports = { runLocalRules };这段代码里每个检查函数都返回null或一个规则对象,规则对象包含规则名、分数和说明。这样设计的好处是:返回结果天然适合写入日志,对账时能看出某条内容到底因为什么规则被扣分。
实际项目的关键词表不会这样硬编码,通常从配置中心或数据库读取,并且会区分命中次数、权重、脱敏处理。这里为了演示可运行的最小闭环,先写成常量。
3.2 再包一个远程审核服务客户端
远程审核服务负责做语义级别的判断,例如识别变体写法、伪造信息、上下文风险。不同服务商的接口返回结构差别很大,所以这里把客户端单独拆出来,方便未来替换实现。
// src/lib/remoteModerationClient.js const config = require('../config'); class ModerationUnavailableError extends Error { constructor(message) { super(message); this.name = 'ModerationUnavailableError'; } } async function requestRemoteModeration({ content, contentType, userId }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.moderation.timeoutMs); try { const response = await fetch(config.moderation.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.moderation.apiKey}` }, body: JSON.stringify({ content, contentType, userId }), signal: controller.signal }); if (!response.ok) { throw new Error(`moderation api http ${response.status}`); } const data = await response.json(); // 不同服务商返回结构不同,这里按约定结构解析 return { score: data.score ?? 0, action: data.action ?? 'pass', categories: data.categories ?? [], raw: data }; } finally { clearTimeout(timer); } } async function callWithRetry(params) { let lastError; for (let attempt = 0; attempt <= config.moderation.retries; attempt++) { if (attempt > 0) { await new Promise((resolve) => setTimeout(resolve, 200 * 2 ** (attempt - 1))); } try { return await requestRemoteModeration(params); } catch (error) { lastError = error; console.warn(`remote moderation attempt ${attempt + 1} failed: ${error.message}`); } } throw lastError; } module.exports = { ModerationUnavailableError, callWithRetry };这里有两个关键点:
- 超时用
AbortController实现,避免第三方服务慢响应时拖垮自己的接口。 - 重试采用指数退避:第一次失败等 200 毫秒,第二次等 400 毫秒。重试次数由环境变量控制,默认 2 次。
ModerationUnavailableError用来区分“审核服务挂了”和“内容本身有问题”两种场景,便于路由层返回不同的 HTTP 状态码。
3.3 用 Moderator 编排审核流程
审核不是简单地把本地规则和远程结果相加,它需要决定执行顺序、如何汇总分数、缓存怎么用、服务不可用时怎么降级。
// src/lib/moderator.js const config = require('../config'); const cache = require('./cache'); const { runLocalRules } = require('./localRules'); const { callWithRetry, ModerationUnavailableError } = require('./remoteModerationClient'); function decide(localReasons, remoteResult) { let totalScore = 0; for (const reason of localReasons) { totalScore += reason.score; } if (remoteResult) { totalScore = Math.max(totalScore, remoteResult.score); } let action = 'pass'; if (totalScore >= config.moderation.reviewScoreThreshold) { action = 'block'; } else if (totalScore >= config.moderation.passScoreThreshold) { action = 'review'; } return { action, score: Math.min(totalScore, 100), matchedRules: localReasons, reviewedBy: remoteResult ? 'remote+local' : 'local' }; } async function moderate({ content, contentType, userId }) { const key = cache.getCacheKey(content, contentType); const cached = cache.get(key); if (cached) { return { ...cached, fromCache: true }; } const localReasons = runLocalRules(content); let remoteResult = null; try { remoteResult = await callWithRetry({ content, contentType, userId }); } catch (error) { console.error('remote moderation unavailable', error.message); if (config.moderation.fallbackAction === 'block') { throw new ModerationUnavailableError('fallback action is block'); } } const decision = decide(localReasons, remoteResult); cache.set(key, decision, config.moderation.cacheTtlSec * 1000); return { ...decision, fromCache: false }; } module.exports = { moderate };为什么要先跑本地规则再调远程服务?因为本地规则便宜,能拦截大量明显垃圾内容,减少外部服务的调用量。如果本地已经命中高风险规则,可以直接返回,不需要再等一次网络请求。
远程服务失败时的降级策略需要谨慎设定。MODERATION_FALLBACK_ACTION=review表示服务不可用时进入人工复审,这是比较稳妥的默认值;如果业务对合规要求极高,可以改成block,但要做好服务抖动导致大量内容被拒绝的心理准备。
3.4 路由和服务启动
路由层只做三件事:校验输入、调用审核逻辑、统一返回格式。
// src/routes/moderation.js const express = require('express'); const config = require('../config'); const moderator = require('../lib/moderator'); const { ModerationUnavailableError } = require('../lib/remoteModerationClient'); const router = express.Router(); router.post('/', async (req, res) => { const { content, contentType = 'comment', userId } = req.body || {}; if (typeof content !== 'string' || content.trim().length === 0) { return res.status(400).json({ error: 'content 必须是非空字符串' }); } if (content.length > config.limits.maxContentLength) { return res.status(400).json({ error: `content 长度不能超过 ${config.limits.maxContentLength}` }); } try { const startedAt = Date.now(); const result = await moderator.moderate({ content, contentType, userId }); res.json({ ...result, requestId: req.id, elapsedMs: Date.now() - startedAt }); } catch (error) { if (error instanceof ModerationUnavailableError) { return res.status(503).json({ error: '审核服务暂不可用,请稍后重试' }); } throw error; } }); module.exports = router;输入校验不能省略。content不是字符串、内容为空、内容超长都要在入口直接拒绝,避免脏数据进入规则引擎。
// src/server.js const express = require('express'); const crypto = require('crypto'); const config = require('./config'); const moderationRouter = require('./routes/moderation'); const app = express(); app.use(express.json({ limit: '10kb' })); app.use((req, res, next) => { req.id = req.get('X-Request-Id') || crypto.randomUUID(); res.setHeader('X-Request-Id', req.id); next(); }); app.use('/api/v1/moderation', moderationRouter); app.listen(config.port, () => { console.log(`moderation endpoint listening on ${config.port}`); });express.json的limit设置为10kb,和业务层的内容长度限制叠加,避免超大请求体占用内存。
4. 关键参数与设计取舍
4.1 阈值怎么定
审核端点的核心参数集中在阈值配置上。常见的错误是只定一个“风险分 > 50 就拒绝”,完全不管中间地带。
| 参数名 | 默认值 | 含义 |
|---|---|---|
| PASS_SCORE_THRESHOLD | 60 | 分数低于该值,动作判定为 pass |
| REVIEW_SCORE_THRESHOLD | 80 | 分数达到该值,动作判定为 block |
| MODERATION_TIMEOUT_MS | 2000 | 远程审核请求超时时间 |
| MODERATION_RETRIES | 2 | 远程审核失败后的重试次数 |
| MODERATION_FALLBACK_ACTION | review | 远程服务不可用时的降级动作 |
| MODERATION_CACHE_TTL_SEC | 300 | 审核结果缓存有效期 |
| MODERATION_CACHE_MAX_SIZE | 10000 | 内存缓存最大条数 |
阈值的含义要结合动作解释:passScoreThreshold到reviewScoreThreshold之间的内容是“拿不准”,应该进入人工复审,而不是直接放行或拒绝。如果把reviewScoreThreshold调得太低,大量普通内容会被误杀;调得太高,风险内容可能直接通过。
阈值上线后要做小流量验证。实际项目中,先用review兜住所有“拿不准”的内容,观察一段时间后根据人工复审的命中率再调整阈值。
4.2 同步、异步和缓存的取舍
同步接口的好处是结果实时,坏处是响应时间变长。接口慢的主要来源是远程审核服务,因此缓存是必选项,不是可选项。
上面的cache.js使用简单内存 Map:
// src/lib/cache.js const config = require('../config'); const cache = new Map(); function getCacheKey(content, contentType) { // 生产环境建议对 content 做 sha256,避免过长 key 占用内存 return `${contentType}:${content}`; } function get(key) { const item = cache.get(key); if (!item) return null; if (Date.now() > item.expireAt) { cache.delete(key); return null; } return item.value; } function set(key, value, ttlMs) { if (cache.size >= config.moderation.cacheMaxSize) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } cache.set(key, { value, expireAt: Date.now() + ttlMs }); } module.exports = { getCacheKey, get, set };同样的内容在短时间内重复提交时,缓存能直接返回结果,节省一次远程调用。但缓存也会带来一个新问题:审核规则调整后,旧的缓存结果还会继续生效一段时间。因此在生产环境,规则变更后必须主动清理相关缓存或等待 TTL 过期。
注意:不要把用户 ID 放进缓存 key。同一个内容由不同用户提交,审核结论应该一致,使用内容哈希作为 key 更合理。
4.3 超时、重试和降级策略
第三方审核服务不是永远可用。接口设计上要回答三个问题:等多久、试几次、挂了怎么办。
- 超时:默认 2000 毫秒。调太短,正常网络波动会导致误判;调太长,业务接口总耗时不可控。
- 重试:默认 2 次,指数退避。重试只适用于“请求失败”,不适用于“返回了 block”,后者不是网络问题。
- 降级:默认
review。宁可让人工复审多干活,也不能让风险内容静默通过,更不能让业务接口直接 500。
这三种策略的组合要在配置里写清楚,并且通过日志确认每次降级都发生了。生产环境如果长期出现降级,说明第三方服务的稳定性或超时配置有问题,不是正常状态。
5. 本地运行与验证
5.1 启动模拟审核服务
为了不依赖真实第三方服务,先写一个本地模拟服务。它只做两件事:内容里包含__PROBLEM__标记时返回高风险,否则返回放行。
// mockModerationServer.js const express = require('express'); const app = express(); app.use(express.json()); app.post('/mock-moderation', (req, res) => { const { content } = req.body || {}; const flagged = String(content || '').includes('__PROBLEM__'); res.json({ action: flagged ? 'block' : 'pass', score: flagged ? 95 : 0, categories: flagged ? ['custom_flag'] : [] }); }); app.listen(4000, () => { console.log('mock moderation server listening on 4000'); });node mockModerationServer.js模拟服务启动后,再启动审核端点:
node src/server.js如果.env里MODERATION_API_URL设置为http://localhost:4000/mock-moderation,审核端点的远程客户端就会把请求转发给模拟服务。
5.2 用 curl 验证三类请求
正常内容应该返回pass:
curl -X POST http://localhost:3000/api/v1/moderation \ -H 'Content-Type: application/json' \ -d '{"content":"这是一条正常评论","contentType":"comment","userId":"u_1001"}'{ "requestId": "5d0b8b71-5e3c-4649-9df6-3c2f1e59a4d8", "action": "pass", "score": 0, "matchedRules": [], "reviewedBy": "remote+local", "fromCache": false, "elapsedMs": 11 }命中垃圾广告关键词的内容应该进入review:
curl -X POST http://localhost:3000/api/v1/moderation \ -H 'Content-Type: application/json' \ -d '{"content":"加微信免费领取资料","contentType":"comment","userId":"u_1002"}'{ "requestId": "9a2f1c12-70b1-45a8-9b65-0f6e8f1d3c5a", "action": "review", "score": 50, "matchedRules": [ { "rule": "spam_keyword", "score": 50, "message": "命中垃圾广告关键词" } ], "reviewedBy": "local", "elapsedMs": 8 }包含__PROBLEM__标记的内容应该被block:
curl -X POST http://localhost:3000/api/v1/moderation \ -H 'Content-Type: application/json' \ -d '{"content":"这是一条__PROBLEM__内容","contentType":"comment","userId":"u_1003"}'{ "requestId": "4f0b2e77-9a11-4b31-9a10-02b0c4d128e7", "action": "block", "score": 95, "matchedRules": [], "reviewedBy": "remote+local", "fromCache": false, "elapsedMs": 120 }验证时不要只看 action,还要确认缓存是否生效:第二次提交相同内容时,fromCache应该变成true,elapsedMs会明显变小。
5.3 前端接入示例
前端接入时只需要按协议调用审核端点,然后把action映射成页面提示。
async function submitComment(content) { const response = await fetch('/api/v1/moderation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, contentType: 'comment', userId: currentUserId }) }); const result = await response.json(); if (result.action === 'block') { showError('内容存在风险,请修改后重试'); return; } if (result.action === 'review') { showMessage('内容已提交,等待审核通过后展示'); return; } submitToStorage(content); }这里要强调一点:前端的审核调用只是用户体验优化,真正的安全边界在后端。任何人都可以绕过前端直接调用入库接口,因此审核端点必须在服务端被强制调用。
另外,表单提交按钮不要写成href="javascript:void(0)"的形式。这样做在禁用 JavaScript 时按钮毫无反应,还容易造成事件绑定混乱。推荐用普通按钮配合preventDefault处理提交逻辑。
6. 常见问题排查
6.1 审核接口变慢、经常超时
现象:线上接口 P99 耗时从 50 毫秒涨到 3 秒,日志里大量remote moderation attempt 1 failed。
可能原因:
- 第三方审核服务本身响应慢。
- 超时时间设置过长,重试次数叠加放大了最坏耗时。
- 同一条内容没有命中缓存,每次都发起远程请求。
检查方式:
- 查看第三方服务调用耗时分布。
- 检查缓存命中率。
- 检查网关或负载均衡是否有连接数限制。
处理建议:
- 把
MODERATION_TIMEOUT_MS从 2000 降低到 1000 或 800。 - 把重试次数从 2 降到 1,超时场景不做重试。
- 对热点内容加长缓存 TTL。
6.2 远程审核服务返回结构对不上
现象:本地联调正常,换真实服务后score一直是undefined,所有内容都被判定为review或block。
原因:不同服务商的返回字段名不同,比如有的返回riskScore,有的返回score,有的嵌套在data里。
检查方式:先用 curl 直接请求真实服务,打印原始返回体。
解决方式:在requestRemoteModeration里按实际返回结构做字段映射,不要假设data.score一定存在。解析层要有默认值,解析失败时宁可进入review,也不要让整个接口崩溃。
6.3 本地规则误杀正常内容和漏审
误杀的例子:正常用户写“我关注你了,记得回关”被SPAM_RE命中,因为规则里包含“关注”模式匹配过宽。
漏审的例子:用户把敏感词拆成“加 微 信”,本地关键词规则完全匹配不到。
处理建议:
- 本地规则只处理高置信度的垃圾模式,拿不准的全部交给远程服务或人工。
- 关键词规则匹配前对文本做归一化:去掉空格、全角转半角、统一大小写。
- 本地规则要有独立的 AB 开关,误杀严重时可以快速关闭。
6.4 重复内容不断触发远程调用
现象:同一句话被 100 个用户提交,远程服务被调用 100 次。
原因:缓存 key 设计不合理,或缓存容量太小频繁被淘汰。
解决方式:使用内容哈希作为缓存 key,比如sha256(contentType + content);扩大缓存容量;在缓存层加上统计指标,观察命中率和淘汰率。
6.5 生产环境重启后缓存丢失
原因:内存缓存在进程重启后清空,这本身不是 bug,但如果瞬间涌入大量请求,所有请求都会同时打向远程服务,称为缓存击穿。
解决方式:
- 单机部署时,在进程内缓存之外加一层分布式缓存,例如 Redis。
- 远程服务不可用时,降级逻辑要兜底,避免把故障扩散到业务接口。
- 新版本发布时先预热热点缓存。
7. 生产环境加固与最佳实践
7.1 本地、测试、生产的差异
| 维度 | 本地学习环境 | 测试环境 | 生产环境 |
|---|---|---|---|
| 远程审核服务 | 模拟服务 | 测试环境真实服务 | 生产真实服务 |
| 缓存 | 内存 Map | Redis 单机或集群 | Redis 集群 |
| 限流 | 可省略 | 建议开启 | 必须开启 |
| 日志 | 控制台输出 | 结构化日志 | 结构化日志落盘并采集 |
| 密钥管理 | .env 文件 | 环境变量 | 密钥管理服务 |
| 监控告警 | 无 | 最小监控 | 耗时、错误率、缓存命中率、降级次数全部监控 |
生产