《移动游戏产品的架构·商业化与发行工程》12.2 未成年人保护与防沉迷

防沉迷不是弹一个提示框,而是把实名、时段、时长和充值限额做成服务端可执行的判定。本节以《潮汐消消》实名、时长与充值限额、《潮汐方舟》另加人脸抽检为背景,给出周五至周日及法定节假日 20:00–21:00 的时段判断、8–16 与 16–18 两档充值上限、失败关闭策略与可测试产物。

防沉迷的核心不是提示,是拒绝

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,单笔低、频次高,抽检的打扰成本相对更明显。

人脸抽检不能替代实名、时段和限额。它解决的是「实名信息是真的,但操作的人不一定是本人」,不是「这个账号有没有实名」。正确的顺序是:

  1. 实名信息先判定年龄档。
  2. 时段与消费限制照常执行。
  3. 在接近月累计上限、或出现异常设备切换时,再触发人脸抽检。
  4. 抽检失败按失败关闭处理,不进入支付流程。

案例里把触发点放在月累计消费达到上限的 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 商店审核材料与驳回应对 。

常见错误与边界

  1. 用设备本地时间判断可玩时段。改时区或改系统时间就能绕过,必须用服务端时钟和固定东八区。
  2. 把周末简单等同于周五到周日。调休上班的周六仍然开放,会直接违反口径;日历要能排除调休工作日。
  3. 21:00 用小于等于判断。边界上会多放一分钟,应该用半开区间,21:00 整即拒绝。
  4. 只做单笔限额,不做月累计。玩家可以拆成多笔小额绕过,两层限额必须同时存在。
  5. 年龄分档边界按虚岁或四舍五入。8、16、18 三个边界会整体偏移,必须与实名口径统一为周岁左闭右开。
  6. 未成年人充值失败后仍调用支付 SDK。限额判定必须在拉起支付之前,失败就不应该产生订单。
  7. 人脸抽检失败时允许重试到成功。无限重试等于没有抽检,应该限制次数并转人工或拒绝。
  8. 把抽检当成实名替代。抽检解决「操作者是不是本人」,不解决「账号有没有实名」,两者不能互相替代。
  9. 服务端异常时默认放行。实名、日历、人脸服务不可用时必须失败关闭,否则故障会变成漏洞。
  10. 弱网缓存不带到过期时间。玩家断网后可以拿旧结果跨窗口继续玩,缓存必须签名并绑定窗口结束时间。
  11. 规则参数在消消和方舟之间直接复制。两款产品的付费结构、联网形态和客单价不同,同一组阈值会产生完全不同的误判率。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查