对任何一个内容平台来说,合规不是"加分项",是"底线"。
广告平台拒审、应用商店下架、甚至整个站点被搜索引擎降权,根源常常不是技术故障,而是内容不合规——有人发了不该发的内容,而你的平台没有挡住。广告联盟(比如 AdSense)对内容合规尤其敏感,一次低质或违规内容,就可能触发整站拒审。
这篇拆的是这个项目怎么把"内容从进站到处置"的全流程管起来:敏感词过滤(进站就挡)、审核状态(标记待审核)、审核任务(每日给管理员发清单)、举报(让用户当哨兵)、滥用风控(Redis Lua 打分 + 六级惩罚阶梯)。你会发现,内容合规不是"一个审核接口",而是从内容进门那一刻就开始织的防线。
内容合规的第一道防线,不在审核任务里,而在内容进站的那一刻。SensitiveUtil 是一个敏感词过滤工具,它在内容写入前就把敏感词换掉:
public class SensitiveUtil {
// 敏感词文件在 resource 下
private static final String SENSITIVE_WORD = "sensitive-words/sensitive-words.txt";
private static final String REPLACEMENT = "***"; // 替换符
// 根节点(Trie 树 / DFA)
private static final ... root = new ...();
public void init() {
// 启动时从文件加载敏感词,构建一棵树
String keyword;
while ((keyword = reader.readLine()) != null) {
addWord(keyword.trim());
}
}
public static String filter(String text) {
// 扫描文本,命中敏感词就替换成 ***
// 返回过滤后的文本
}
}
敏感词匹配用的是 Trie 树(前缀树 / DFA):启动时把敏感词库里的每一个词逐字建进一棵树,过滤时一次扫描就能同时匹配所有词。为什么不用简单的字符串 contains?因为 contains 是"每个词扫一遍",词库几百上千个词就是几百上千遍;Trie 是"扫一遍文本,边走边查树",O(文本长度 × 词长度),快一个量级。敏感词过滤是高频率操作(每条内容都过),所以匹配算法必须高效。
Trie 树的另一个好处是支持"部分匹配"——比如敏感词是"赌博",文本里有"赌 博"(中间插了空格)或"赌博网站",Trie 扫描时能按树的路径去匹配,只要字符序列能沿着树走下去就算命中。这比"字符串 contains"灵活得多——敏感词变着花样出现,Trie 都能认出来。当然它也不是万能的,"赌——博"这种故意拆开的变形它认不出,那就需要配合后面的审核兜底。敏感词过滤是"挡大部分",不是"挡全部"——它负责把明显违规挡在门口,漏网的交给人工审核。
还有敏感词库的加载方式:词库放在 resources 下的一个 txt 文件(sensitive-words/sensitive-words.txt),每行一个词,启动时读进内存建树。词库外置到文件,而不是写死在代码里——因为词库要频繁更新(新词、新规),外置文件让运营可以不改代码就更新词库。这是一个小但重要的工程决策:会把敏感词库写死在代码里的,都是在给未来的自己埋坑。
回看 a18 那篇,上传确认时就已经在调它了:
if (spaceId == null) { // 公共图片敏感词过滤
picName = SensitiveUtil.filter(picName);
if (picName == null) picName = "未命名图片";
}
String tags = req.getTagName();
if (spaceId == null && StrUtil.isNotBlank(tags)) {
tags = SensitiveUtil.filter(tags);
}
注意过滤的对象和范围:公共空间的图片名称和标签会过滤,但用户自己空间的内容不强制过滤。这个取舍很有意思——公共内容会影响所有人(出现在广场、搜索、推荐里),所以强制过滤;私有空间的内容只有用户自己看,平台"提醒但不拦截"。合规的力度,要跟内容的可见范围匹配——越公开的内容,过滤越严,这是内容平台的一个基本判断。
敏感词挡的是"明显违规",但合规远不止敏感词。一张图可能是原创的、可能侵权;一个帖子可能引用了不该引的。所以除了敏感词,内容还有一个审核状态字段——图片的 reviewStatus、帖子的 status。
内容进站时的状态是 0(待审核)。这个"待审核"状态贯穿内容的一生:审核通过才公开(状态 1),审核拒绝就下架(状态 -1 或类似)。公共内容默认是"先进后审"还是"先审后进"?这个项目是先进后审——内容先能看,再由管理员审核补过。为什么?因为"先审后进"需要审核速度跟得上内容速度,对一个小团队不现实;"先进后审"配合敏感词预过滤,把"明显违规"挡在门口,剩下的由人工兜底。审核策略的选择,是"效率"和"安全"的权衡——先审后进更安全但慢,先进后审更快但依赖兜底。
"待审核"状态本身,也是一条贯穿内容生命周期的记录。内容进站 → 待审核(0)→ 审核通过(1)→ 后续如果被举报核实违规 → 下架(-1)。这个状态机让"内容当前是否合规"变成一个可查询、可流转的状态,而不是一个模糊的概念。把合规抽象成"状态",是内容治理的基石——没有状态,你就没法回答"这条内容现在能不能看、能不能搜、能不能推荐"。
而且"待审核"不是一个静态标记,它影响着内容的所有展示位。除了 a22 里说过的搜索过滤,推荐、广场、榜单这些"公共展示位"都应该只展示审核通过的内容——审核状态和所有"公开展示"咬合。一个审核没过的内容不该出现在任何人的首页上,这是内容平台的基本原则。审核状态的校验,要散落在所有"别人能看到你"的地方。
"待审核"状态还有个隐藏价值:它是 a22 那篇里 Meilisearch 索引的过滤条件。回想 a22 的索引配置,图片索引的 filterable 里有 reviewStatus——搜索时按 reviewStatus=1(已通过)过滤,未审核的内容不进搜索结果。合规状态和搜索/推荐是咬合的:审核没通过的内容,不该被搜到、不该被推荐,这在索引层就拦住了。
"先进后审"的兜底,靠的是审核任务——但但现实问题是:管理员不可能实时盯着。所以审核是批量 + 定时 + 邮件提醒的模式:
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点
public void dailyCheck() {
// 取"昨天"的时间范围
Date startTime = yesterdayStart();
Date endTime = yesterdayEnd();
// 查昨天未审核的公共图片(reviewStatus=0)
QueryWrapper<Picture> pictureWrapper = new QueryWrapper<Picture>()
.eq("reviewStatus", 0).eq("isDelete", 0).eq("isDraft", 0)
.between("createTime", startTime, endTime)
.and(w -> w.isNull("spaceId").or().eq("spaceId", 0));
// 查昨天未审核的帖子、友链
List<Picture> pictures = pictureService.list(pictureWrapper);
List<Post> posts = postService.list(postWrapper);
List<FriendLink> friendLinks = friendLinkService.list(friendLinkWrapper);
// 有未审核内容就发邮件给管理员
if (!pictures.isEmpty() || !posts.isEmpty() || !friendLinks.isEmpty()) {
emailSenderUtil.sendReviewEmail(adminEmail, generateEmailContent(...));
}
}
几个细节值得说。每天凌晨 1 点——为什么不是实时?因为审核是"人工"的,实时推给管理员,管理员也不可能实时处理;凌晨 1 点把昨天一整天的待审核内容汇总成一封邮件,管理员早上看一眼,批量处理。定时批量 + 邮件提醒,是"人工审核"场景下最务实的工程形态——不是系统偷懒,是匹配人的工作节奏。
只查"昨天"——时间窗口避免了重复提醒。昨天的内容今天审,审过的明天不再出现。范围限定"公共内容"——isNull("spaceId").or().eq("spaceId", 0),私有空间的内容由用户自己负责,只有公共内容需要平台把关。这和敏感词过滤的"范围匹配可见度"原则一脉相承。
邮件模板是独立的 HTML(review_notification.html),把图片、帖子、友链分门别类列出待审核项。给管理员的通知,要一眼能扫出"今天要审什么"——一封结构清晰的清单邮件,比一堆零散提醒高效得多。
这个任务还承担了一个"没人看也能跑"的期望:如果前一天没有未审核内容,就不发邮件(if (!pictures.isEmpty() || !posts.isEmpty() || !friendLinks.isEmpty()))。为什么要"没内容就不发"?因为发一封空邮件是噪音——管理员会习惯性忽略"没有待办"的通知,等真有待办时反而漏看。通知要"有信息才发",空通知是信任的腐蚀剂——这是所有提醒类功能的设计原则。
还有个细节:审核范围同时覆盖图片、帖子、友链三类内容,但都限定"公共"的。为什么友链也要审核?因为友链是"站点间的互链",一个违规的友链会把整个站拖下水(搜索引擎会看外链质量)。所以站点的每一个"对外露出"的入口,都在审核范围内。合规审查的覆盖面,要包括所有"可能影响站点声誉"的内容——不只是 UGC,连友情链接这种小角落都不能漏。
机器和定时任务挡不住所有问题——一个内容看起来合规,但用户觉得被冒犯了、被抄袭了、被骚扰了。这时候需要举报:让用户帮你发现机器看不出的问题。前端 ReportModal.vue 就是干这个的:
<div class="yuemu-report-item">
<div class="yuemu-report-label">举报类型 <span class="yuemu-required">*</span></div>
<div class="yuemu-report-select-wrap">
<!-- 举报类型:侵权 / 垃圾信息 / 违规内容 / 骚扰 ... -->
</div>
</div>
<!-- 举报对象:targetType + targetId -->
举报弹窗收集三样:举报对象(targetType + targetId,举报的是图片、帖子还是用户)、举报类型(侵权、垃圾、违规、骚扰等)、举报说明。这三样凑成一条举报记录,进入"待处理"状态(status=0)。
"让用户当哨兵"的价值在于:机器的判断是有限的,用户的眼睛是无限的。一个内容是否让某个群体不适、是否抄袭了某个没登记的作品,机器很难判断,但用户能。举报机制把"合规检测"从"平台单向"变成了"平台 + 用户双向"——这是内容平台必然的选择,也是它和纯机器审核的本质区别。做内容平台,一定要给用户一个"说'这个有问题'"的入口,这不是功能,是底线。
举报的"对象"设计也值得说:targetType + targetId——举报的是一个通用对象(图片/帖子/用户),而不是为每种内容写一套举报逻辑。这个抽象让"举报"成了一个横切能力:任何有 targetId 的内容,都能挂上举报按钮,不用每种内容各写一套。把"举报"抽象成"对任意对象"的操作,是复用性的胜利——一处实现,处处可用。
举报类型也要精心设计:侵权、垃圾信息、违规内容、骚扰……这些类型不是随便列的,它们对应着"内容平台最常见的违规类别",也让管理员收到举报时能快速分类处理。举报类型是"给管理员的预处理"——用户选类型,管理员按类型分流,比让管理员从零判断每一条举报高效得多。举报单上的每一个字段,都在为"管理员怎么处理"服务。
举报不能积压——一个侵权内容挂两天,伤害一直在发生。所以举报的处理节奏比内容审核快:
@Scheduled(cron = "0 0 */2 * * ?") // 每两小时
public void checkUnprocessedReports() {
// 查所有待处理的举报(status=0)
QueryWrapper<Report> reportWrapper = new QueryWrapper<Report>()
.eq("status", 0).eq("isDelete", 0);
List<Report> unprocessedReports = reportService.list(reportWrapper);
if (!unprocessedReports.isEmpty()) {
log.info("发现 {} 条未处理的举报", unprocessedReports.size());
// 发邮件给管理员
emailSenderUtil.sendReviewEmail(adminEmail, generateReportEmailContent(unprocessedReports));
}
}
注意节奏的差异:内容审核每天一次(凌晨 1 点汇总昨天),举报审核每两小时一次。为什么举报更频繁?因为举报有"时效性"——举报"这个内容有问题",管理员等一天才处理,问题内容多暴露一天;而内容审核是"新内容补过",晚一天影响不大。定时任务的频率,要贴着业务的风险等级定——风险越高的动作,节奏越快,这是定时任务设计的第一原则。
举报记录同样带状态流转:待处理(0)→ 处理中 → 已处理。管理员处理完,标记状态,未处理的举报不会重复提醒。状态机是"待办事项"类功能的核心——没有状态,就无法区分"没处理"和"处理过",提醒就会重复轰炸。
举报的处置结果也很重要:举报被核实,内容下架、用户被处理;举报不实,举报本身被驳回。有个细节——举报不是"举报了就有罪",它需要核实的流程。用户举报是一个"疑似信号",管理员核实后才知道真假。这个"举报 → 核实 → 处置/驳回"的流程,避免了"恶意举报"被滥用——有人可能用举报来报复、来搞垮竞争对手的内容。举报机制要防"举报本身被滥用"——所以核实环节必不可少,这也是为什么举报不是"一键删内容",而是"进待处理队列"。
前面几篇反复出现过限流(@BucketRateLimit),但限流只是"挡一下"。这个项目把限流和一套滥用风控联动起来了——当你的请求触发了限流,这本身就被记成一次"违规",累计到一定程度,你会被降级甚至封禁。触发点在限流切面里:
if (bucket.tryConsume(1)) {
return pjp.proceed(); // 正常放行
}
// 走到这里说明限流触发了——这本身记一次"过"
log.warn("[bucket4j] 限流触发 key={}", key);
if (abuseProperties.isEnabled()) {
// 登录用户按 User 维度,匿名用户按 IP 维度
String dimension = StpUtil.isLogin() ? "user" : "ip";
String id = StpUtil.isLogin() ? StpUtil.getLoginIdAsString() : ServletUtils.getClientIP(request);
// 每个端点的权重可能不同(敏感接口权重高)
double weight = abuseProperties.getEndpointWeights()
.getOrDefault(limit.key(), abuseProperties.getDefaultWeight());
// 记录这次违规,得到当前总分
ScoreSnapshot snapshot = abuseScoreService.recordHit(limit.key(), dimension, id, weight);
// 异步检查是否要升级惩罚
CompletableFuture.runAsync(() ->
abuseEscalationService.checkAndEscalate(dimension, id, snapshot.getTotal()));
}
throw new BusinessException(ErrorCode.TOO_MANY_REQUEST, limit.message());
这套设计的精髓是:限流不只是"拒绝",还是"记过"。一个人正常操作,永远不会触发限流,也就永远不会被记过;只有反复越过限流阈值的人,才会被记分、被升级。这比单纯的限流高级——限流是"你太快了,慢点",滥用风控是"你太多次了,我盯上你了"。
还有两个细节。维度选择——登录用户按 User(锁人不锁设备),匿名用户按 IP。因为匿名用户没有账号,IP 是唯一能定位的维度。端点权重——不同接口的违规权重不同,敏感接口(比如发帖、上传)权重高,普通查询权重低。风控的"记过"粒度,要跟着接口的敏感度走——越是能造成伤害的接口,一次越界的后果越重。
异步升级检查(CompletableFuture.runAsync)也值得注意——升级判定(读 Redis、可能封号)是慢操作,不能阻塞限流请求的返回。限流请求本身已经要被拒绝,再等一个异步判定,纯属拖时间。核心流程(拒绝请求)和外围动作(升级判定)分离,是前面几篇反复出现的同一个原则。
这里还有一层值得点透:这套滥用风控和限流是"共生"的——没有限流,就没有"记过"的触发点;没有滥用风控,限流就只是"挡一下"而没有后续。限流负责"这一刻拦住你",滥用风控负责"记住你这个人,下次对你更严"。两者配合,才从"防瞬时"升级成"防持续"。风控不是限流的替代,是限流的放大器——它让每一次限流都不白发生,都变成对违规者的累计记过。
还有那个异常处理:catch (Exception e) { log.error("评分记录失败, 限流决策不受影响", e); }——记分失败不影响限流决策。这又是"非核心失败静默降级"的原则:限流要不要拒绝这个请求,不该被"记分是否成功"影响。风控系统是"锦上添花"的防御层,它的失败不能拖垮主流程(限流)——这个层级关系,在代码里用 catch 写得明明白白。
recordHit 是滥用风控的记分核心,它用 Redis Lua 脚本保证"记录 + 算总分"的原子性:
public ScoreSnapshot recordHit(String endpointKey, String dimension, String ipOrUserId, double weight) {
// 执行 Lua 脚本,原子地记录本次违规并按时间窗口计算总分
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(RECORD_HIT_LUA); // 一段 Lua
redisScript.setResultType(String.class);
String resultJson = stringRedisTemplate.execute(redisScript, ...);
// 解析出 1m / 5m / 1h / 24h 各窗口的分 + 总分
return ScoreSnapshot.builder()
.m1(json.getInt("1m", 0)).m5(json.getInt("5m", 0))
.h1(json.getInt("1h", 0)).h24(json.getInt("24h", 0))
.total(json.getDouble("total", 0.0))
.build();
}
Lua 脚本里做的事,大概是这样:为每个"维度 + ID + 时间窗口"维护一个计数器(abuse:counter:{dim}:{id}:{win}),本次违规把 weight 加进去,然后按 1 分钟 / 5 分钟 / 1 小时 / 24 小时四个窗口分别算总分,最后把 24 小时窗口的旧计数器清理掉。返回的结果是一个 ScoreSnapshot——四个窗口的分加上总分。
为什么要用 Lua?因为"记一次分 + 算总分"必须原子——如果分两步做(先 INCR 再 GET),中间另一个请求插进来,总分就可能算错。Lua 脚本在 Redis 里是原子的(整个脚本作为一个整体执行),等于把"记分 + 汇总"锁进了一个原子操作。在高并发的计数器场景,Lua 是"既要性能又要正确"的标准答案。
还有那个注释里提到的细节:"保留浮点精度,防范低频慢速爬虫(0.1 权重)"。有些爬虫很狡猾——它不是高频猛打,而是低频慢速地扫,单次限流触发不了(限流是按频率的),但累计起来也在伤害服务。应对方式:给这类请求记一个 0.1 的小权重,低频但持续,总分照样会爬上去。风控要防的不只是"快",还有"慢而持续"——低频慢速攻击,靠的是累计。
四个时间窗口(1 分钟 / 5 分钟 / 1 小时 / 24 小时)的设计也很有讲究。为什么要同时算四个窗口?因为它们捕捉不同的违规模式:1 分钟窗口抓"瞬时爆发"(突然猛打),5 分钟窗口抓"短期攻击",1 小时窗口抓"持续骚扰",24 小时窗口抓"低频慢速累计"。多窗口并行,让风控既能抓"闪电战"也能抓"消耗战"——单一窗口要么漏掉瞬时爆发,要么漏掉慢速累计,四个窗口合起来才覆盖全。
窗口的清理也值得提:24 小时窗口的旧计数器要清理,否则 Redis 里会堆满过期数据。Lua 脚本里会顺手把滑出窗口的 key 清掉——带窗口的计数器,要记得"过期回收",不然就是内存泄漏式的积累。这也是为什么用 Redis 而不是 MySQL——这种"高频写 + 自动过期"的场景,Redis 是天然契合的。
记分不是目的,目的是惩罚要分级、要递进。checkAndEscalate 按总分把"违规者"推到不同的等级:
public void checkAndEscalate(String dimension, String id, double totalScore) {
// 按总分决定目标等级
int targetLevel = 0;
if (totalScore >= thresholds.getPermBan()) targetLevel = 6; // 永久封禁
else if (totalScore >= thresholds.getLongBan()) targetLevel = 5; // 长期封禁
else if (totalScore >= thresholds.getFreeze()) targetLevel = 4; // 账号冻结
else if (totalScore >= thresholds.getIpBan()) targetLevel = 3; // IP 封禁
else if (totalScore >= thresholds.getWarn()) targetLevel = 2; // 限速降级
else if (totalScore >= thresholds.getNotice()) targetLevel = 1; // 警告通知
// User 维度特殊:60-119 分保持 L2 强化降速(throttle=0.2),不升 L3
if ("user".equals(dimension) && targetLevel == 3 && totalScore < thresholds.getFreeze()) {
targetLevel = 2;
}
// IP 维度最多到 L3(不能封用户账号)
int maxLevel = "ip".equals(dimension) ? 3 : 6;
if (targetLevel > maxLevel) targetLevel = maxLevel;
if (targetLevel == 0) return;
// 冷却检查:冷却期内且没有更高级违规,跳过
// ... 把等级和 throttle 写进 Redis:abuse:level / abuse:throttle
}
六级阶梯,每一级都有明确的惩罚:
L1 警告通知(TTL 1小时) —— 先提醒
L2 限速降级(throttle=0.5) —— 请求额度砍一半
L3 IP 封禁(throttle=0.01) —— 额度降到 1%,等于封 IP
L4 账号冻结 —— 账号暂时不能动
L5 长期封禁 —— 账号长期停用
L6 永久封禁 —— 一票到底
这套阶梯的设计哲学很值得学习:惩罚不是"一刀切",是"渐进 + 可逆"。第一次越界只是警告(L1),再犯限速(L2),屡犯才升级到封禁。为什么?因为"初次违规者可能是误操作"——直接封号会误伤;渐进惩罚给了"犯错的人"改正的空间,也给系统留了"他改了就不升级"的余地。惩罚的阶梯,本质是"给误伤留余地"的设计——完全不惩罚管不住,惩罚太狠误伤太多,中间这段阶梯就是平衡。
六级阶梯的每一级,背后都是可配置的(AbuseProperties 里的 Thresholds 和 Durations)。也就是说,每一级"多少分触发"、"惩罚多久",都是配置文件里的一组数字,不是写死的。这让运营可以不改代码就调风控的松紧——新政策收紧(阈值调低),节假日放宽(阈值调高),都是改配置的事。风控的等级和阈值,一定要做成可配置的——写死的话,调一次风控就要发一次版,运维会疯。
还有"异步升级"里那个值得玩味的细节:升级判定(checkAndEscalate)是异步的(CompletableFuture.runAsync),但它读的是 recordHit 刚返回的 snapshot——也就是说,"这次记分"和"这次要不要升级"在时间上是连续的,只是执行上异步了。"逻辑上顺序、执行上异步"是异步化的正解——你不能为了异步牺牲正确性,只能在正确的前提下异步。
两个细节也很有意思。User 维度 60-119 分的"强化降速"——User 到 L3(IP 封禁)但分还没到冻结线时,不升 L3,而是留在 L2 但把 throttle 压到 0.2(比普通 L2 的 0.5 更狠)。因为 L3 是"封 IP",对登录用户意义不大(换个 IP 就绕过),所以对 User 维度做"更狠的降速"而不是"封 IP"。惩罚要贴着被惩罚对象的实际特性设计——IP 维度和 User 维度,惩罚方式就该不同。IP 维度最多 L3——因为 IP 是共享的(一个公司一个出口 IP),封 IP 封到 L4 会误伤一群人,所以 IP 维度到 L3 封顶。
六级阶梯之外,还有两套机制让惩罚更"智能":累犯加倍和冷却期。
累犯加倍——同一个维度(用户/IP)被惩罚的次数越多,惩罚时长成倍增长:
// 累犯计数 + 时长加倍
String recidivismKey = "abuse:recidivism:" + dimension + ":" + id;
Long recidivismCount = stringRedisTemplate.opsForValue().increment(recidivismKey);
stringRedisTemplate.expire(recidivismKey, 90, TimeUnit.DAYS);
if (recidivismCount != null && recidivismCount > 1) {
double multiplier = Math.pow(abuseProperties.getRecidivism().getMultiplier(), recidivismCount - 1);
finalDuration = (long)(baseDuration * multiplier); // 惩罚时长成倍上涨
}
第一次被 L2 限速可能只有几小时,第三次同样违规,时长变成几倍。这是"屡教不改,加倍惩罚"的工程化——惩罚不是固定的,是随累犯次数指数上涨的。这也给系统一个信号:一个反复被惩罚的维度,是"惯犯",需要更重的处置。
冷却期——升级检查有 cooldownMinutes(默认 30 分钟)的冷却:如果刚惩罚过,且没有发生更高级别的违规,冷却期内不重复惩罚。为什么?因为记分是持续累加的,一次违规后总分还在涨,如果没有冷却,升级检查会反复触发、反复发警告、反复写 Redis。冷却期让"已经惩罚过的违规"在短期内不再重复处置,只有"更严重的违规"(升级到更高等级)才打破冷却。风控也要防"重复处置的噪声"——同一件事,通知一次就够了,别反复刷屏。
前面积累了等级和 throttle 值,最后要落到"这个用户的每次请求都变慢/被挡"。getThrottleMultiplier 把惩罚翻译成限流倍率:
public double getThrottleMultiplier(String key) {
// 从限流 key 解析出维度和 ID:例如 "user_login:user:123" → dim=user, id=123
String dim = "ip"; String id = "";
if (key.contains(":user:")) { dim = "user"; id = key.substring(key.lastIndexOf(":") + 1); }
else if (key.contains(":ip:")) { dim = "ip"; id = key.substring(key.lastIndexOf(":") + 1); }
if (!id.isEmpty()) {
// 读 abuse:throttle:{dim}:{id},这就是之前升级时写进去的惩罚值
String throttleKey = "abuse:throttle:" + dim + ":" + id;
String val = stringRedisTemplate.opsForValue().get(throttleKey);
if (val != null) return Double.parseDouble(val);
}
return 1.0; // 没被惩罚,倍率 1.0(正常额度)
}
这个倍率回到 BucketRateLimitAspect 的开头——还记得吗,a19 那篇里我提过 rateLimitService.getThrottleMultiplier(key) 返回一个倍率,Bucket4j 的桶用这个倍率缩放额度。现在闭环了:滥用升级把 throttle 值写进 Redis → 下次请求的限流桶额度被缩放 → 被惩罚的人请求额度大幅缩水。throttle=0.5 就是额度减半,throttle=0.01 就是只剩 1%(等于被封)。
这套"限流 → 记过 → 升级 → 缩放额度"的闭环,是滥用风控最漂亮的部分:它让惩罚"即时生效"且"自动延续"。不用管理员手动封号,系统根据行为自动把违规者的额度一点点收紧,直到其停止违规(额度恢复正常)或越界到封禁。自动化的惩罚体系,比人工处置更快、更公平、不遗漏——这四点是它能扛住脚本和恶意用户的关键。
还要点出"throttle 是'渐变的惩罚'而不是'二元的封禁'":L2 是砍一半(0.5)、强化是砍到两成(0.2)、L3 是砍到 1%(0.01)——惩罚不是"能用/不能用"两个状态,而是一档档收紧的连续体。为什么这样设计?因为"完全封禁"会把用户彻底赶走,而"逐渐收紧"给用户一个"停下来就恢复"的期望——惩罚的可逆性,比惩罚的强度更能留住用户。一个被 L2 限速的用户,只要停止违规,额度就自动恢复;被 L6 封禁的,就真的没了。惩罚的"可逆度",就是给用户留的改正空间。
这套闭环还有一个被低估的好处:它几乎不需要人工干预。管理员不用天天盯违规、手动封号——系统根据行为自动记分、自动升级、自动收紧。管理员只需要偶尔看日志、调配置。自动化风控的本质,是把"管理员的判断"沉淀成"可配置的规则"——判断是人做的,执行是系统做的,两者分工,才能扛住规模。
前端这边,举报入口是 ReportModal.vue——任何内容旁都有个"举报"按钮,点开填类型和说明,提交就进待处理队列。这个入口的可见性很重要:用户要知道"这里能举报",才会在遇到问题时来举报。举报按钮的位置、文案、是否好找,直接决定举报机制能不能发挥作用——藏得太深的举报入口,等于没有举报机制。
审核管理在后台(管理员端):管理员每天收到审核邮件,登录后台,把待审核的图片、帖子、举报逐条处理——通过/拒绝/下架。审核页面的核心是"快速判断 + 批量操作":一屏展示内容、一眼判断合规、一个按钮处理,处理完进入下一条。审核是重复劳动,审核页面要把它做到最省力——这是审核效率的关键。
前端还有一个容易被忽略的点:举报入口在所有内容类型上的一致性。图片详情、帖子详情、用户主页……举报按钮的位置、文案、交互要统一——用户学会在一个地方举报,就知道在所有地方举报。交互的一致性,是"让用户会用"的隐性成本——每个地方都不同,用户每次都要重新学,举报率就会掉。
审核管理端还有个数据面的考虑:审核记录(谁审的、审了什么、什么结论)要留痕。这既是审计(出了问题能追责到具体的审核操作),也是运营数据(能看出哪些类型的内容审核多、通过率低)。审核不只是"处理内容",它本身也是一份值得记录的运营数据——从审核数据里,能看到内容质量的走势。
还有一层:审核状态对内容生命周期的影响。审核不通过的内容会下架(从前端隐藏),但它还在数据库里——如果只是软删除,未来重新审核还能恢复;如果硬删除,就真没了。审核的"处置"要留余地——下架是软性处置(可恢复),删除是硬性处置(不可逆),这个取舍要在审核流程里想清楚。
这条合规链路的坑,我按"误伤、节奏、耦合"三块归了类——分组看,每块的坑往往是同一个根源。
误伤,是合规系统最贵的学费。 敏感词库某个词太宽泛,大量正常内容被过滤成"***"——后来改成"精确匹配"并定期人工审词库,敏感词的粒度直接决定误伤率,宁可漏一个也别误伤一片。滥用记分上线初期,正常用户偶尔限流(一次批量操作)就被记分砍额度——调整权重和阈值,普通接口低权重、阈值宽松,只有高频越界才真升级,风控的误伤率直接决定它能不能上线。IP 维度封禁误伤共享出口也在这组——一个公司里某人违规整栋楼的 IP 被封,后来 IP 封顶 L3、抬高阈值,按 IP 惩罚要记得 IP 是共享的。
节奏的坑,都是"忘了节拍"。 审核任务最初每次发现未审核内容就发一封邮件,管理员邮箱被刷屏——改成"只查昨天的内容",时间窗口避免重复提醒,定时任务的幂等靠"只处理窗口内的新数据"。举报最初只有"待处理/已处理"两态,管理员处理到一半退出又回到待处理、重复提醒——加"处理中"状态才完整,待办事项的状态机,少一个状态就多一次误判。
耦合的坑。 recordHit 的"记分 + 算总分"一开始以为分开做也行(先 INCR 再 GET),并发下一算总分就错——改用 Lua 才原子,计数器的读写合并必须原子。滥用记分和限流耦合太紧:风控依赖限流触发(只有限流命中才记分),一放宽某接口的限流阈值,它的记分触发率就下降,调限流和调风控互相干扰——后来把限流阈值和滥用权重分开,限流管"挡不挡"、权重管"记多少分",限流和风控是两个维度,要能独立调参。还有敏感词过滤只做替换没做告警:一个用户反复发敏感词是惯犯,应该进滥用风控,但"谁在持续发"这个信号最初被扔掉了——后来敏感词命中也给滥用系统记一笔,敏感词命中本身就是滥用信号。
把这条合规链路收起来,你会发现它是一条完整的"内容安检线":
内容进站 → 敏感词过滤(Trie 树,进站就挡)
→ 标记"待审核"(reviewStatus=0)
→ 每日任务汇总未审核内容,邮件提醒管理员
→ 管理员审核:通过 / 下架 / 拒绝
→ 用户可举报(ReportModal)→ 举报进待处理 → 每2小时邮件提醒
→ 滥用风控:限流触发记分(Lua 原子记分)→ 六级升级 → 限流倍率收紧
每道闸都有它的分工:敏感词挡"明显的",审核兜"人工的",举报补"机器的盲区",滥用风控管"反复越界的"。四道闸咬合起来,才是一套"内容从进站到处置"的完整防线。
三句话总结这篇:
敏感词要进站就挡,审核要批量兜底。 Trie 树让敏感词过滤高效,每日任务 + 邮件让人工审核跟得上内容速度,"先进后审"配敏感词预过滤是务实的选择。
举报让用户当哨兵,审核要分频次。 机器看不出的问题靠用户举报;举报每两小时提醒(时效性高),内容审核每天一次(批量兜底)——定时任务的频率贴着风险等级走。
滥用风控要渐进、要原子、要自动延续。 Redis Lua 原子记分、六级惩罚阶梯、累犯加倍、冷却期、限流倍率闭环——让惩罚"即时生效、自动延续、给误伤留余地"。
最后说句实在的:内容合规没有"做完"的一天——词库要更新、阈值要调、新内容类型要覆盖。但把这四道闸的骨架立起来,合规就从"被动挨打"变成了"主动防御"。合规不是一次审核,是一套贯穿内容生命周期的防线。希望这篇能让你做内容平台时,把这道防线织在系统里,而不是事后补。