4.2 用户体系:禁言与信用分

场景:同一用户换个号继续发广告

垃圾广告治理了一周就遇到新问题:封了一个账号,对方换微信号重新登录,新号继续发。按 openid 封禁看似一劳永逸,可惜对方一个手机号能注册多个微信号,openid 层面的封禁效果递减。最无奈的是,每次封号我们都要人工操作一遍后台,夜里不可能即时响应,封号动作本身就滞后于违规行为。我们意识到单一维度封禁不够,需要一个按行为维度累积的信用体系。

那周我们还统计了一个数字:同一个手机号注册的小号在两周内发了六十七条广告,平均每条存活不到五分钟就被审核拦下,但这六十七次尝试说明对手根本不在乎封号成本。治理思路由此从堵变为养:堵是单次拦截,养是让账号的信用慢慢损耗,直到违规者无利可图。

设计目标由此清晰:让正常用户完全无感,让违规用户逐步失去权限。核心是信用分,初始一百,违规扣分,恢复靠时间与良好行为。信用分低于阈值触发对应处罚层级,而不是一刀切封号,这样既给边缘用户改过机会,也把严重违规者快速隔离,整个体系无人值守也能自动运转,这正是我们四个人最需要的设计。

阶梯式处罚上线一个月后,我们回头看了数据:广告发帖量下降百分之六十八,而日活几乎没有波动。说明受罚的只是少数违规者,正常用户没有受到任何波及,这正是这套体系设计时最想看到的结果——治理动作的副作用趋近于零。而封号量本身也在逐月下降,说明阶梯的威慑力在起作用,大部分用户在被扣第一次分之后就收敛了。

方案:信用分驱动处罚阶梯

信用分存在 t_user 的 credit 字段,每次违规按规则扣分并留痕,处罚由分值自动触发,运营只在特殊情况下介入:低于八十分禁止发帖、低于六十分禁止评论、低于四十分禁止点赞、低于二十直接封号。每条违规记录包含原因、扣分数、操作人,申诉成功可回补:

分值区间 限制 触发条件 恢复方式
八十到一百 正常 无 无需
六十到八十 禁发帖 审核违规 每周回五分
四十到六十 禁评论 多次违规 每周回三分
二十到四十 禁互动 严重违规 仅人工
零到二十 封号 屡教不改 申诉解封

违规扣分规则也一并公开给用户,透明化反而降低了运营工单量。规则表本身就是一份契约,用户知道自己行为的后果,试探行为肉眼可见地减少:

违规类型 扣分 说明
广告引流 二十 屡犯加倍
辱骂攻击 十五 含人身攻击
违规内容 四十 严重违规
恶意刷屏 十 一小时内多次
申诉成功 全额回补 人工核实

扣分规则不是拍脑袋定的,参考了同行的做法再加我们的违规样本分布调了一轮。广告类扣分轻是因为量大,重判会大量误伤正常商家;违规内容扣分重是因为性质严重,一次就该令其痛。规则表每季度复盘一次,每次调整都会在公告区同步给用户。

配置与代码

用户表只存核心字段,昵称头像等频繁变动的信息走缓存,信用分变更全部留痕。留痕表 t_credit_log 单独建,避免日志写入影响主表查询,这是分表的最低成本做法:

CREATE TABLE t_user (
  uid BIGINT PRIMARY KEY AUTO_INCREMENT,
  openid VARCHAR(64) UNIQUE NOT NULL,
  nickname VARCHAR(32) DEFAULT '',
  avatar VARCHAR(255) DEFAULT '',
  credit INT DEFAULT 100,
  status TINYINT DEFAULT 1,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

扣分与限制检查写在违规处理入口,保证所有审核违规走同一条路径,避免不同模块各自为政。单入口这件事看着是约束,实际上救过我们:有次要给某类违规加倍扣分,只改了 RULES 一个常量就全站生效,如果各模块各自实现,一定会漏改几处:

const RULES = { ad: -20, abuse: -15, porn: -40 };
async function punish(uid, rule) {
  const delta = RULES[rule] || -10;
  await mysql.query('UPDATE t_user SET credit = credit + ? WHERE uid = ?',
    [delta, uid]);
  await creditLog(uid, rule, delta);
  if (delta < 0) await redis.del('user:perm:' + uid); // 失效权限缓存
}

权限校验中间件按分值放行,缓存五分钟,给热门用户的小特权开关也挂在这层。校验逻辑刻意写得极其简单:只读一个数字做区间判断,因为这段代码是每次写操作都要走的,越复杂越容易出性能问题:

// middleware/perm.js
async function checkPerm(uid, action) {
  const key = 'user:perm:' + uid;
  let lvl = await redis.get(key);
  if (lvl === null) {
    lvl = await userModel.getCredit(uid);
    await redis.setex(key, 300, lvl);
  }
  const need = { post: 80, comment: 60, like: 40 };
  if (lvl < need[action]) return false;
  return true;
}

恢复任务每周跑一次,只给近期无违规记录的用户加分,违规者重置回血进度,堵死打卡薅分的路子:

// 每周信用恢复
async function creditHeal() {
  const rows = await mysql.query(
    'SELECT uid FROM t_user WHERE credit < 100 '
    + 'AND uid NOT IN (SELECT uid FROM t_credit_log '
    + 'WHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY))');
  for (const r of rows) {
    await mysql.query('UPDATE t_user SET credit = LEAST(credit + 3, 100) WHERE uid = ?', [r.uid]);
  }
}

踩坑

第一个坑是扣分操作的原子性。高并发时一条违规多次处理,扣分重复执行,用乐观锁版本号兜底后才稳定。第二个坑是缓存未及时失效,扣分后用户仍在五分钟缓存窗口里继续发言,把权限缓存的过期改成事件驱动,扣分即删缓存。第三个坑是误判申诉无门,早期没有申诉入口,被误伤的用户只能卸载走人,工单系统加了申诉按钮后,人工核查发现误判率百分之十二,回补了信用分。第四个坑是恢复机制被薅,最初的恢复规则是每登录一天回两分,结果有人每天登录打卡回血然后继续违规,改成发帖与互动行为良好才回血、违规重置回血进度。第五个坑是封号连带内容处理,封号后历史帖子留在信息流,被举报帖子仍可访问,封号动作统一级联隐藏其全部内容。第六个坑是设备维度对抗难度升级,对手后来用上了自动化脚本批量注册,我们又加了注册时间间隔限制和同设备注册频率上限,把注册成本拉高。第七个坑是信用分在缓存里与库里不一致,极端情况下用户被封后正好命中旧缓存还能发帖,最后把权限校验改为写操作强制查库、读操作走缓存的双通道。第八个坑是信用分运营手动调整缺乏审计,有次运营手滑把某个用户加了两百分,事后靠 credit_log 才发现,之后所有人工调整都必须填写原因并双人复核。第九个坑是白名单滥用,早期为了让大客户出入方便给了一批豁免,后来豁免名单成了治理盲区,整顿后白名单只保留运营负责人一个,其余全部关闭。

小结

信用分做处罚阶梯的好处是粒度可控、可回补、可申诉。用户体系的设计必须假设系统会误判,所以每一步都要留人工纠偏入口。权限校验集中在中间件、变更事件驱动失效缓存,两条原则让体系跑得稳。还有一个价值观层面的收获:处罚体系的目的不是惩罚用户,而是提高作恶成本、保护正常用户的体验,带着这个目的去设计,很多规则的取舍会自然清晰。比如恢复机制留的是改过自新的口子,而不是空子。这一点想通了,后面的设计都顺理成章。更多用户状态管理的细节,可以对照 IM 即时通讯实战 的用户状态设计,权限中间件的写法还能看 PHP 接口实战。

延伸阅读