防沉迷的核心不是提示,是拒绝
12.1 解决的是「声明了什么」。玩家同意隐私政策、SDK 完成初始化,并不代表系统知道对面是不是未成年人。本节从登录之后开始:这次登录能不能进、能玩多久、这笔充值能不能过。
三个判定必须分开实现:
| 判定 | 输入 | 输出 | 失败时的默认动作 |
|---|---|---|---|
| 时段 | 当前时间、节假日日历 | 允许 / 拒绝、剩余分钟 | 拒绝 |
| 时长 | 本次会话开始时间 | 剩余可玩分钟 | 到时强制下线 |
| 充值 | 年龄、单笔金额、当月已消费 | 允许 / 拒绝、剩余额度 | 拒绝 |
把它们塞进一个「是否未成年」的布尔值,最后一定会出现「未成年标记为真,但充值仍然成功」这类漏洞。年龄只是输入,真正要执行的是三条独立规则。
时段:一周只有三个晚上加法定节假日
案例口径是:未成年人仅可在周五、周六、周日及法定节假日 20:00–21:00 游玩 1 小时。这句话包含四个边界,任何一个处理错都会导致误放或误拦。
| 场景 | 判定 | 剩余时间 |
|---|---|---|
| 周五 20:30 | 允许 | 30 分钟 |
| 周五 21:00 | 拒绝 | 0 |
| 周一 20:30 | 拒绝 | 0 |
| 法定节假日 20:15 | 允许 | 45 分钟 |
| 调休上班的周六 20:30 | 拒绝 | 0 |
第一个边界是星期。周五、周六、周日三天开放,不是「周末」两个字能概括的:不同地区、不同年份的调休安排会改变实际休息日。把调休上班日排除掉,比只在周末判断更接近真实口径。
第二个边界是节假日。法定节假日可能落在工作日,也可能与周末相连。日历必须是可配置的日期集合,不能写死「10 月 1 日」这类单点规则。
第三个边界是时区。判定必须固定按东八区计算,不能读设备本地时区。玩家把手机时区改到另一个时区,不应该因此多玩一小时;服务端也不能因为部署在海外机房就把 20:00 算成 UTC 20:00。
第四个边界是区间开闭。20:00 整开放,21:00 整关闭,21:00:01 已经不在窗口内。代码里用半开区间 [20:00, 21:00),比用「小于等于 21:00」更不容易在边界上多放一分钟。
登录时间也会影响剩余时长。玩家 20:59 进来,只能玩 1 分钟,而不是「进来就送 1 小时」。日累计时长由窗口本身保证:窗口只有 60 分钟,玩家不可能在窗口内玩超过 60 分钟;如果产品支持中途退出再进,服务端仍然按同一窗口计算剩余时间,不需要额外累加。
年龄分档与充值上限
充值限制有两层:单笔上限和每月累计上限。两层都过,才算允许。
| 年龄档 | 单笔上限 | 每月上限 | 案例配置 |
|---|---|---|---|
| 未满 8 周岁 | 不允许充值 | 不允许充值 | 失败关闭 |
| 8–16 周岁 | ¥50 | ¥200 | 单笔与累计同时校验 |
| 16–18 周岁 | ¥100 | ¥400 | 单笔与累计同时校验 |
| 18 周岁及以上 | 不适用 | 不适用 | 走成人支付链路 |
年龄边界按左闭右开处理:8 周岁整进入 8–16 档,16 周岁整进入 16–18 档,18 周岁整视为成人。这个选择要和实名信息的口径保持一致,不能一处按虚岁、一处按周岁。
把商品档位代进去,会看到规则的实际效果。消消的档位是 ¥6、¥18、¥30、¥68、¥128:
- 8–16 档单笔上限 ¥50,只能买 ¥6、¥18、¥30 三档,¥68 和 ¥128 会被拒绝。
- 16–18 档单笔上限 ¥100,可以买 ¥6、¥18、¥30、¥68,¥128 仍会被拒绝。
- 月累计上限 ¥200 或 ¥400,会在多次购买后触发,和单笔上限是两道独立的门。
方舟的档位是 ¥30、¥68、¥128、¥328、¥648。任何一个档位都超过 8–16 档的 ¥50 单笔上限,¥128 以上也超过 16–18 档的 ¥100 单笔上限。也就是说,方舟的高档位对未成年人基本不可售,这不是价格策略,而是限额规则的自然结果。
这也解释了一个常见误区:把未成年人充值限额理解成「月累计不超过多少」。单笔上限先拦住大额档位,月累计再拦住多次小额,两层缺一不可。
人脸抽检:只加一道门,不替代前面两道
《潮汐方舟》在实名、时长、充值限额之外另加人脸抽检;《潮汐消消》没有。差异来自付费结构:方舟内购占月流水 97%,ARPPU 是 ¥420,单笔高、频次低,冒用身份的成本更高;消消内购占 62%、广告占 38%,ARPPU 是 ¥68,单笔低、频次高,抽检的打扰成本相对更明显。
人脸抽检不能替代实名、时段和限额。它解决的是「实名信息是真的,但操作的人不一定是本人」,不是「这个账号有没有实名」。正确的顺序是:
- 实名信息先判定年龄档。
- 时段与消费限制照常执行。
- 在接近月累计上限、或出现异常设备切换时,再触发人脸抽检。
- 抽检失败按失败关闭处理,不进入支付流程。
案例里把触发点放在月累计消费达到上限的 80%。这个比例是策略参数,不是法规数字;它的作用是让抽检发生在「还能拦得住」的位置,而不是等额度用完才补。
抽检失败的体验必须明确:告诉玩家「需要监护人协助完成验证」,而不是笼统的「系统繁忙」。含糊的提示会直接把玩家推向客服,也会让家长无法判断问题出在哪。
三个判定必须在服务端
客户端可以做提示、可以做倒计时,但不能做最终判定。原因很直接:设备时间可以改,本地缓存可以清,包可以重签。把「现在能不能玩」交给客户端,等于把规则交给玩家。
服务端判定要注意三件事:
- 时间以服务端为准。客户端只上报「我现在想登录」,不参与时段计算;服务端用自己的时钟和东八区日历判断。
- 决策要带有效期。允许登录后,服务端签发一个带过期时间的会话凭证,过期时间不超过当前窗口结束时间。客户端拿旧凭证不能跨窗口继续玩。
- 失败关闭。日历服务不可用、实名校验超时、人脸服务无响应时,默认拒绝,而不是默认放行。
弱联网产品还要处理离线缓存。消消是弱联网,玩家可能在网络不稳定时进入单机玩法;缓存的准入结果必须带签名和过期时间,且不能覆盖充值判定。方舟是强联网,每次关键操作都可以回服务端,规则可以更实时,但也更依赖服务可用性。
把规则写成可测试的纯函数
下面的实现把时段、年龄分档和充值限额拆成三个纯函数。它不连接实名服务、不查数据库、不发放会话,只负责在给定输入下给出确定结论,因此可以用固定时间点做回归测试。
// file: src/biz/minor-protection.ts
// tier: L1
// verify: node --test test/biz/minor-protection.test.ts
export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
export const FACE_CHECK_RATIO = 0.8;
export interface PlayCalendar {
readonly holidays: readonly string[];
readonly makeupWorkdays: readonly string[];
}
export type PlayReason = "within-window" | "outside-window" | "not-play-day" | "invalid-time";
export interface PlayDecision {
readonly allowed: boolean;
readonly reason: PlayReason;
readonly localDate: string;
readonly localTime: string;
readonly minutesRemaining: number;
}
export type AgeBand = "under-8" | "8-16" | "16-18" | "adult";
export interface SpendLimits {
readonly singleCents: number;
readonly monthlyCents: number;
}
export type TopUpReason =
| "allowed"
| "adult"
| "no-payment-band"
| "single-limit"
| "monthly-limit"
| "invalid-amount";
export interface TopUpInput {
readonly ageYears: number;
readonly amountCents: number;
readonly monthlySpentCents: number;
readonly faceCheckEnabled: boolean;
}
export interface TopUpDecision {
readonly allowed: boolean;
readonly reason: TopUpReason;
readonly band: AgeBand;
readonly remainingMonthlyCents: number;
readonly faceCheckRequired: boolean;
}
function shifted(nowMs: number): Date {
return new Date(nowMs + SHANGHAI_OFFSET_MS);
}
export function shanghaiDayKey(nowMs: number): string {
const local = shifted(nowMs);
const year = local.getUTCFullYear();
const month = String(local.getUTCMonth() + 1).padStart(2, "0");
const day = String(local.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function isPlayDay(nowMs: number, calendar: PlayCalendar): boolean {
const key = shanghaiDayKey(nowMs);
if (calendar.holidays.includes(key)) {
return true;
}
if (calendar.makeupWorkdays.includes(key)) {
return false;
}
const weekday = shifted(nowMs).getUTCDay();
return weekday === 5 || weekday === 6 || weekday === 0;
}
export function evaluatePlayTime(nowMs: number, calendar: PlayCalendar): PlayDecision {
if (!Number.isFinite(nowMs)) {
return {
allowed: false,
reason: "invalid-time",
localDate: "",
localTime: "",
minutesRemaining: 0,
};
}
const local = shifted(nowMs);
const localDate = shanghaiDayKey(nowMs);
const hour = local.getUTCHours();
const minute = local.getUTCMinutes();
const localTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const minutesOfDay = hour * 60 + minute;
const windowStart = 20 * 60;
const windowEnd = 21 * 60;
if (!isPlayDay(nowMs, calendar)) {
return { allowed: false, reason: "not-play-day", localDate, localTime, minutesRemaining: 0 };
}
if (minutesOfDay < windowStart || minutesOfDay >= windowEnd) {
return { allowed: false, reason: "outside-window", localDate, localTime, minutesRemaining: 0 };
}
return {
allowed: true,
reason: "within-window",
localDate,
localTime,
minutesRemaining: windowEnd - minutesOfDay,
};
}
export function ageBand(ageYears: number): AgeBand {
if (!Number.isFinite(ageYears) || ageYears < 8) {
return "under-8";
}
if (ageYears < 16) {
return "8-16";
}
if (ageYears < 18) {
return "16-18";
}
return "adult";
}
export function spendLimits(band: AgeBand): SpendLimits | null {
switch (band) {
case "under-8":
return { singleCents: 0, monthlyCents: 0 };
case "8-16":
return { singleCents: 5_000, monthlyCents: 20_000 };
case "16-18":
return { singleCents: 10_000, monthlyCents: 40_000 };
case "adult":
return null;
}
}
export function checkTopUp(input: TopUpInput): TopUpDecision {
const band = ageBand(input.ageYears);
const validAmount = Number.isFinite(input.amountCents) && input.amountCents > 0;
const validSpent = Number.isFinite(input.monthlySpentCents) && input.monthlySpentCents >= 0;
if (!validAmount || !validSpent) {
return {
allowed: false,
reason: "invalid-amount",
band,
remainingMonthlyCents: 0,
faceCheckRequired: false,
};
}
const limits = spendLimits(band);
if (limits === null) {
return {
allowed: true,
reason: "adult",
band,
remainingMonthlyCents: 0,
faceCheckRequired: false,
};
}
const remaining = Math.max(0, limits.monthlyCents - input.monthlySpentCents);
if (limits.singleCents === 0) {
return {
allowed: false,
reason: "no-payment-band",
band,
remainingMonthlyCents: remaining,
faceCheckRequired: false,
};
}
if (input.amountCents > limits.singleCents) {
return {
allowed: false,
reason: "single-limit",
band,
remainingMonthlyCents: remaining,
faceCheckRequired: false,
};
}
if (input.amountCents > remaining) {
return {
allowed: false,
reason: "monthly-limit",
band,
remainingMonthlyCents: remaining,
faceCheckRequired: false,
};
}
const nextMonthlySpent = input.monthlySpentCents + input.amountCents;
return {
allowed: true,
reason: "allowed",
band,
remainingMonthlyCents: remaining - input.amountCents,
faceCheckRequired:
input.faceCheckEnabled && nextMonthlySpent >= limits.monthlyCents * FACE_CHECK_RATIO,
};
}
export function formatMinorDecision(
accountId: string,
play: PlayDecision,
topUp: TopUpDecision,
): string {
const playState = play.allowed ? "allow" : play.reason;
const topUpState = topUp.allowed ? "allow" : topUp.reason;
return `${accountId} date=${play.localDate} time=${play.localTime} play=${playState} minutes=${play.minutesRemaining} topup=${topUpState} band=${topUp.band} face=${topUp.faceCheckRequired ? "yes" : "no"}`;
}
代码里有几个刻意的选择。
第一,时间统一加东八区偏移后再用 UTC 取值。这样不依赖运行机器的本地时区,测试在哪个时区跑都得到同一结果。夏令时不在考虑范围内,因为东八区没有夏令时。
第二,isPlayDay 先看节假日,再看调休上班日,最后才看星期。节假日优先级最高:一个日期如果既是法定节假日又被误标为调休上班,按允许处理,避免因为配置冲突误伤玩家。
第三,时段用半开区间。20:00 允许,21:00 拒绝,剩余分钟按整分钟向下取。玩家 20:59:30 进来,剩余 1 分钟而不是 0 分钟,避免出现「刚进来就被踢」的边界体验。
第四,年龄非法时归入最严格的档位,而不是抛异常或放行。ageBand 对 NaN、负数、未满 8 周岁统一返回 under-8,这是失败关闭原则在输入层的体现。
第五,充值判定先校验金额,再套限额。金额为 0、负数或非有限数时直接返回 invalid-amount,不进入年龄档逻辑,避免非法输入被当成「小额允许」。
第六,人脸抽检是独立字段,不改变 allowed。抽检触发时充值仍然可以进入下一步,但必须先完成验证;把抽检和限额混成一个布尔值,会让「额度够但需要验证」这种状态无法表达。
formatMinorDecision 输出一行稳定文本,把日期、时间、可玩状态、剩余分钟、充值状态、年龄档和抽检标记放在一起,方便客服和日志排查。
双案例对照:消消与方舟
消消是弱联网、混合变现,月付费率 2.8%,商品档位集中在 ¥6 到 ¥128。它的防沉迷重点是时段判定和低档位累计,单笔拦截主要发生在 ¥68 和 ¥128 两档;弱网下的缓存准入要带过期时间,否则玩家可以通过断网绕过窗口。
方舟是强联网、内购为主,月付费率 6.5%,ARPPU 是 ¥420,档位从 ¥30 到 ¥648。它的防沉迷重点是高客单价的单笔拦截和人脸抽检;实名、时段、限额、抽检四道门必须都过,任何一道失败都不能进入支付。
两款产品共用同一套账号中台,但规则参数不能共用。消消的窗口缓存可以更长,方舟的会话凭证必须更短;消消可以不做人脸抽检,方舟不能省。把参数做成配置,而不是复制两份代码,是共用中台的前提。
取舍:严格判定会损失一部分体验
每收紧一次规则,都会同时减少违规和增加摩擦。把 21:00 的边界从「小于等于」改成「小于」,会少放一批卡点玩家;把充值限额从「按月重置」改成「按自然月累计」,会让跨月消费的玩家在月初重新获得额度,但也可能被用来拆分订单。
案例里的做法是「规则严格、提示清楚、申诉可达」:
- 拒绝时告诉玩家具体原因:不在可玩时段、今日额度已用完、单笔超过限额、需要监护人验证。
- 给出下一次可玩时间或剩余额度,减少重复尝试和客服工单。
- 保留申诉入口,尤其是实名信息错误、人脸误判这类需要人工复核的情况。
- 所有判定记录规则版本、输入摘要和结果,便于事后审计。
代价是客服工作量上升。消消月活 320 万、月新增 42 万,即使未成年人占比不高,边界咨询也会形成稳定工单;方舟用户少,但单笔金额高,每一次误判的争议成本更大。收益是规则可解释、可审计,出现问题时能定位到具体判定,而不是一句「系统判定」。
小结
- 防沉迷是三条独立规则:时段、时长、充值限额,不能压缩成一个「是否未成年」布尔值。
- 时段固定按东八区、半开区间
[20:00, 21:00)计算,节假日与调休上班日都要进日历。 - 年龄按 8、16、18 三个左闭右开边界分档,单笔上限和月累计上限同时校验。
- 人脸抽检是补充门,不替代实名、时段和限额;失败必须关闭,不能放行。
- 判定放服务端,决策带有效期,异常时失败关闭;弱网缓存不能覆盖充值判定。
阅读导航:上一节:12.1 隐私政策、权限与数据采集清单 · 下一节:12.3 商店审核材料与驳回应对 。
常见错误与边界
- 用设备本地时间判断可玩时段。改时区或改系统时间就能绕过,必须用服务端时钟和固定东八区。
- 把周末简单等同于周五到周日。调休上班的周六仍然开放,会直接违反口径;日历要能排除调休工作日。
- 21:00 用小于等于判断。边界上会多放一分钟,应该用半开区间,21:00 整即拒绝。
- 只做单笔限额,不做月累计。玩家可以拆成多笔小额绕过,两层限额必须同时存在。
- 年龄分档边界按虚岁或四舍五入。8、16、18 三个边界会整体偏移,必须与实名口径统一为周岁左闭右开。
- 未成年人充值失败后仍调用支付 SDK。限额判定必须在拉起支付之前,失败就不应该产生订单。
- 人脸抽检失败时允许重试到成功。无限重试等于没有抽检,应该限制次数并转人工或拒绝。
- 把抽检当成实名替代。抽检解决「操作者是不是本人」,不解决「账号有没有实名」,两者不能互相替代。
- 服务端异常时默认放行。实名、日历、人脸服务不可用时必须失败关闭,否则故障会变成漏洞。
- 弱网缓存不带到过期时间。玩家断网后可以拿旧结果跨窗口继续玩,缓存必须签名并绑定窗口结束时间。
- 规则参数在消消和方舟之间直接复制。两款产品的付费结构、联网形态和客单价不同,同一组阈值会产生完全不同的误判率。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。