
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 审核端点应该返回什么审核端点本质上是一个纯判定服务不负责入库也不负责通知用户。它的输入是待审核内容以及少量上下文输出是一份结构化的判定结果。推荐的最小返回结构如下字段类型含义requestIdstring请求唯一标识用于串联日志actionstring最终动作pass、review、blockscorenumber风险分数0 到 100越高越危险matchedRulesarray命中的本地规则列表reviewedBystring本次判定来源local、remote、remotelocalfromCacheboolean是否命中缓存elapsedMsnumber本次审核耗时其中action是下游最关心的字段。业务方拿到pass就走正常入库拿到review就投递到人工复审队列拿到block就拒绝并记录日志。注意审核端点的返回值要稳定不能今天返回pass、fail明天改成approved、rejected。下游系统依赖这个字段做分支处理字段语义一旦变化线上就会出现漏审或误杀。1.3 同步审核和异步审核的区别审核端点本身只是把判定能力暴露出来具体是同步调用还是异步消费取决于业务场景。维度同步审核异步审核调用方式业务接口直接请求审核端点内容先写入再投递到消息队列审核用户等待接口多一次网络耗时无需等待审核结果风险窗口审核通过才入库基本无风险窗口审核完成前内容已可见存在短暂风险窗口适用场景昵称、个人签名、评论标题等短文本文章、长评论、图片等可事后处理的内容实现成本较低需要引入队列和消费端实际项目中高风险短字段通常用同步审核低风险或海量内容用异步审核。如果材料里没有特殊说明本文先实现同步审核端点异步扩展放在最后一节介绍。2. 环境准备与项目骨架2.1 环境要求下面的示例代码使用 Node.js 18 以上的版本因为代码里用到了原生fetch和AbortController这两个能力在 Node 18 之后可以不用额外安装依赖。组件建议版本说明Node.js18 及以上需要原生 fetch、AbortController、randomUUIDnpm9 及以上通常随 Node 安装express4.xWeb 框架4.x 启动代码稳定dotenv16.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。PORT3000 MODERATION_API_URLhttp://localhost:4000/mock-moderation MODERATION_API_KEY MODERATION_TIMEOUT_MS2000 MODERATION_RETRIES2 MODERATION_FALLBACK_ACTIONreview PASS_SCORE_THRESHOLD60 REVIEW_SCORE_THRESHOLD80 MODERATION_CACHE_TTL_SEC300 MODERATION_CACHE_MAX_SIZE10000 MAX_CONTENT_LENGTH20002.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 ? remotelocal : 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_ACTIONreview表示服务不可用时进入人工复审这是比较稳妥的默认值如果业务对合规要求极高可以改成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_THRESHOLD60分数低于该值动作判定为 passREVIEW_SCORE_THRESHOLD80分数达到该值动作判定为 blockMODERATION_TIMEOUT_MS2000远程审核请求超时时间MODERATION_RETRIES2远程审核失败后的重试次数MODERATION_FALLBACK_ACTIONreview远程服务不可用时的降级动作MODERATION_CACHE_TTL_SEC300审核结果缓存有效期MODERATION_CACHE_MAX_SIZE10000内存缓存最大条数阈值的含义要结合动作解释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 验证三类请求正常内容应该返回passcurl -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: remotelocal, fromCache: false, elapsedMs: 11 }命中垃圾广告关键词的内容应该进入reviewcurl -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__标记的内容应该被blockcurl -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: remotelocal, fromCache: false, elapsedMs: 120 }验证时不要只看 action还要确认缓存是否生效第二次提交相同内容时fromCache应该变成trueelapsedMs会明显变小。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); }这里要强调一点前端的审核调用只是用户体验优化真正的安全边界在后端。任何人都可以绕过前端直接调用入库接口因此审核端点必须在服务端被强制调用。另外表单提交按钮不要写成hrefjavascript: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 本地、测试、生产的差异维度本地学习环境测试环境生产环境远程审核服务模拟服务测试环境真实服务生产真实服务缓存内存 MapRedis 单机或集群Redis 集群限流可省略建议开启必须开启日志控制台输出结构化日志结构化日志落盘并采集密钥管理.env 文件环境变量密钥管理服务监控告警无最小监控耗时、错误率、缓存命中率、降级次数全部监控生产