《游戏服务端架构·核心系统与契约》11.3 防沉迷、实名与未成年人保护

《泡泡工坊》一名未成年人用家长账号充值后,家长要求退款;同一时间实名服务超时,三款游戏各自判断,出现了“这个服能玩、那个服不能玩”。本节区分合规判定与执行,说明实名年龄带、可玩时段、单笔与月度限额的输入与可信度,给出数据最小化、留存与删除传导的边界,并附可运行的 Go 判定服务片段。

一个超时,三个答案

《泡泡工坊》在国庆假期做了一次联动活动。第一天晚上,实名认证服务因为上游限流超时了四十分钟。三款游戏各自处理:大厅服认为“查不到就放行”,房间服认为“查不到就禁止登录”,充值服则直接跳过了年龄判断。结果同一个账号在大厅能玩、进房间被踢、充值却成功了。家长第二天投诉时,三款游戏给出的解释互相矛盾。

这不是某一段代码的 bug,而是判定规则没有集中。防沉迷与未成年人保护涉及实名、年龄、时段、消费限额、退款和删除请求,任何一条规则复制到每个游戏,都会在版本、时区、缓存和故障处理上分叉。

合规系统要解决的不是“写一个 if”,而是回答四个问题:

  • 判断依据是什么,可信度如何;
  • 判断在哪个服务做,执行由谁负责;
  • 判断失败时是放行还是拒绝;
  • 个人信息保存多久,删除请求如何传导。

合规输入与可信度

输入来源可信度用途保存方式
实名结果/年龄带实名认证服务高(需在有效期)是否未成年、适用哪档限额只存年龄带与验证时间
游戏账号账号系统高关联多游戏消费与时长内部主键
设备与 IP客户端/网关中风控辅助,不作年龄依据短期风控日志
当前时间服务端时钟高可玩时段判断不依赖客户端
当日已玩时长会话/心跳汇总高(需防篡改)每日时长限额服务端累计
本月已消费支付/订单账本高月度限额与订单同源
玩家自报年龄客户端低仅用于引导实名不作为判定依据

第一条边界最重要:年龄只认实名服务返回的年龄带,不认玩家自报。实名材料(姓名、证件号、照片)不应进入游戏业务库;业务侧只需要一个派生结果:under8、8to16、16to18、adult,以及“是否已验证”和“验证时间”。

年龄带是数据最小化的结果。它足够执行规则,又不能在业务库被还原成具体生日或证件号。实名服务的留存期、加密和访问审计由它自己负责,游戏侧不复制原始材料。

判定与执行分离

登录/充值请求
      |
      v
+---------------------+        +------------------+
| 合规判定服务         |<------>| 实名认证服务      |
| CheckPlay/CheckPurchase |     | 返回年龄带/可信度 |
+---------------------+        +------------------+
      |
      | Decision: allow / limit / deny
      | + reason + remaining + nextAllowedAt
      v
+---------------------+        +------------------+
| 大厅、房间、充值服务 |------->| 执行:放行/限制/拒绝 |
+---------------------+        +------------------+

判定服务只输出决策,不直接踢人、不直接扣钱。执行由各游戏完成,原因是执行动作依赖具体场景:大厅拒绝登录、房间中途结束对局、充值页拦截下单,语义不同;但“能不能玩、能花多少”的规则必须来自同一个判定服务。

决策契约至少包含:

字段含义执行方如何使用
Actionallow / limit / deny决定放行、限额还是拒绝
Reason未实名、非可玩时段、时长用尽、单笔超限等展示文案、客服定位、审计
RemainingSeconds本次还可玩秒数,-1 表示不限房间服设置倒计时
RemainingCents本月还可消费金额,-1 表示不限充值页限制可选档位
NextAllowedAt下次可玩时间登录页提示,不靠客户端计算

Reason 是契约的一部分,不是日志字符串。不同游戏可以根据 reason 展示不同文案,但不能各自解释规则;否则同一个账号在三款产品里仍会出现不同结论。

决策:判定集中还是分散

决策点方案 A方案 B北辰选择理由
判定位置每个游戏自判独立判定服务B三款产品规则一致,故障与版本只维护一处
失败策略查询失败放行查询失败拒绝B合规系统不能在不确定时默认放行
时间来源客户端时间服务端时间B客户端时钟可改,无法作为证据
身份数据业务库存原始实名只存年龄带B数据最小化,泄露影响可控
时长/消费累计按游戏分别累计按账号跨产品累计B否则切游戏即可重置限额
节假日代码硬编码运营日历注入B调休和临时安排无法由星期推出
删除传导只删账号表账号、实名、缓存、审计全链路B单点删除会留下可关联的残留

这张表里唯一有争议的是“失败关闭”。它牺牲了部分可用性,但换来确定的合规行为;如果业务无法接受,应该通过缓存已验证结果、缩短故障窗口来解决,而不是在代码里加一个静默放行的分支。

规则示例与边界

以下数值是实验室示例值,用于说明结构与边界,不构成法律意见;上线前必须由法务和运营按当时有效的规定确认。

年龄带可玩时段每日时长单笔充值月度充值
未实名/未知禁止0禁止禁止
under8与未成年人相同1 小时禁止禁止
8to16周五/周六/周日及法定节假日 20:00–21:001 小时50 元200 元
16to18同上1 小时100 元400 元
adult不限不限不限不限

四个边界必须写进实现:

  • 时段用服务端时间。 客户端时钟可以改,不能作为判断依据;时区固定为运营区时区,不跟随设备。
  • 区间是半开区间。 [20:00, 21:00),20:00:00 允许,21:00:00 拒绝;否则边界秒会出现两个服务结论不一致。
  • 节假日是外部日历。 周五/周六/周日可以由星期推出,法定节假日和调休不能;日历必须是可注入的运营输入,不能硬编码在代码里。
  • 每日时长按账号累计。 同一账号在多设备、多游戏切换时,累计要共享;否则切一次游戏就重置一小时。

月度消费限额同理,必须跨产品累计。北辰三款产品共用一个支付账号,如果每个游戏只看自己的订单,未成年人可以在三款游戏里各充到上限,合计超过规定。

数据最小化、留存与删除

合规系统天然会接触敏感信息,架构上要做三件事:

第一,业务侧不保存实名原始材料。姓名、证件号、照片只存在于实名服务;业务侧保存年龄带、验证状态、验证时间和来源标识。即使业务库泄露,也无法直接还原证件信息。

第二,留存期分开配置。实名材料的留存由实名服务负责;游戏侧的年龄带和审计记录按各自目的设置期限。示例配置可以是身份派生信息 180 天、决策审计 365 天,但具体期限要按法规和业务需要确认,不能照抄。

第三,删除请求要能传导。玩家注销或行使删除权时,账号系统发起删除,实名服务删除映射,游戏侧删除年龄带缓存;财务和审计记录如果依法必须保留,则以游戏账号或脱敏标识保留,不保留实名材料。删除动作本身也要审计,但审计记录不应再写入被删除的实名标识。

跨境限制是另一条边界:如果实名服务在境内、游戏服务器在境外,境外服务不应复制实名材料,只接收判定结果或年龄带;数据传输路径和存储位置要在架构图上写明,不能靠“反正只读一下”带过。

可运行片段:判定服务

下面的 internal/compliance/compliance.go 实现“查身份 -> 判定可玩 -> 输出决策与审计”的主路径。policy.go 定义年龄带、策略、日历和决策契约,purchase.go 实现充值限额与删除传导;本文贴出可玩判定部分,因为时段、时长和失败关闭的逻辑都集中在这里。

// file: internal/compliance/compliance.go
// tier: L1
// verify: go test ./internal/compliance/
package compliance

import (
	"errors"
	"time"
)

var (
	ErrBadRequest     = errors.New("compliance: invalid request")
	ErrIdentitySource = errors.New("compliance: identity source unavailable")
)

type Service struct {
	policy   Policy
	source   IdentitySource
	calendar Calendar
	now      func() time.Time
	auditor  Auditor
}

func New(policy Policy, source IdentitySource, calendar Calendar, now func() time.Time, auditor Auditor) *Service {
	if policy.Location == nil {
		policy.Location = time.FixedZone("CST", 8*60*60)
	}
	if calendar == nil {
		calendar = WeekendCalendar{}
	}
	if now == nil {
		now = time.Now
	}
	return &Service{policy: policy, source: source, calendar: calendar, now: now, auditor: auditor}
}

// CheckPlay decides whether an account may play now. A source outage fails
// closed: the returned decision is "deny", not an error the caller may ignore.
func (s *Service) CheckPlay(accountID string, usedToday time.Duration) (Decision, error) {
	if accountID == "" || usedToday < 0 {
		return Decision{}, ErrBadRequest
	}
	id, err := s.lookup(accountID)
	if err != nil {
		return s.finish(accountID, "play", Decision{Action: ActionDeny, Reason: ReasonSourceError}, usedToday, 0), nil
	}
	if !id.Verified || id.Band == BandUnknown {
		return s.finish(accountID, "play", Decision{Action: ActionDeny, Reason: ReasonUnverified}, usedToday, 0), nil
	}
	if id.Band == BandAdult {
		return s.finish(accountID, "play", Decision{Action: ActionAllow, Reason: ReasonOK, RemainingSeconds: -1}, usedToday, 0), nil
	}
	now := s.now().In(s.policy.Location)
	if !s.calendar.IsPlayDay(now) || !s.inWindow(now) {
		d := Decision{Action: ActionLimit, Reason: ReasonOutsideWindow, NextAllowedAt: s.nextWindow(now)}
		return s.finish(accountID, "play", d, usedToday, 0), nil
	}
	daily := s.policy.DailyLimit - usedToday
	if daily <= 0 {
		d := Decision{Action: ActionLimit, Reason: ReasonDailyLimit, NextAllowedAt: s.nextWindow(now)}
		return s.finish(accountID, "play", d, usedToday, 0), nil
	}
	end := time.Date(now.Year(), now.Month(), now.Day(),
		s.policy.WindowEndMinute/60, s.policy.WindowEndMinute%60, 0, 0, s.policy.Location)
	window := end.Sub(now)
	if window < daily {
		daily = window
	}
	d := Decision{Action: ActionAllow, Reason: ReasonOK, RemainingSeconds: int64(daily / time.Second)}
	return s.finish(accountID, "play", d, usedToday, 0), nil
}

func (s *Service) lookup(accountID string) (Identity, error) {
	if s.source == nil {
		return Identity{}, ErrIdentitySource
	}
	return s.source.Lookup(accountID)
}

func (s *Service) inWindow(now time.Time) bool {
	minute := now.Hour()*60 + now.Minute()
	return minute >= s.policy.WindowStartMinute && minute < s.policy.WindowEndMinute
}

func (s *Service) nextWindow(from time.Time) time.Time {
	for d := 0; d <= 14; d++ {
		day := time.Date(from.Year(), from.Month(), from.Day()+d, 0, 0, 0, 0, s.policy.Location)
		if !s.calendar.IsPlayDay(day) {
			continue
		}
		start := time.Date(day.Year(), day.Month(), day.Day(),
			s.policy.WindowStartMinute/60, s.policy.WindowStartMinute%60, 0, 0, s.policy.Location)
		if start.After(from) {
			return start
		}
	}
	return time.Time{}
}

func (s *Service) finish(accountID, kind string, d Decision, used time.Duration, amountCents int64) Decision {
	s.record(AuditEntry{
		At: s.now(), Subject: accountID, Kind: kind, Action: d.Action,
		Reason: d.Reason, UsedSeconds: int64(used / time.Second), AmountCents: amountCents,
	})
	return d
}

func (s *Service) record(e AuditEntry) {
	if s.auditor != nil {
		s.auditor.Record(e)
	}
}

几个关键点:

  • 身份源故障时失败关闭。 lookup 出错返回 deny + source_error,不返回 error 让调用方自行决定;调用方没有“忽略错误就放行”的接口。
  • 未实名和未知年龄一律拒绝。 Verified 为 false 或年龄带为 unknown 时,不放行、不充值。
  • 成年人直接放行。 RemainingSeconds 为 -1 表示不限,执行方不需要再判断年龄。
  • 剩余时间取“每日剩余”和“本时段剩余”的较小值。 20:30 进入、每日还没玩过,剩余是 30 分钟,不是 60 分钟。
  • 下一次可玩时间由日历计算。 周一请求会得到周五 20:00;当天时段用尽会得到下一个可玩日,而不是简单加 24 小时。
  • 审计只写游戏账号、决策、原因、时长和金额。 没有任何姓名字段,从结构上避免把实名材料带进日志。

充值判定在 purchase.go:先查身份,未实名拒绝;under8 直接 age_blocked;8to16 和 16to18 分别检查单笔与月度上限;成年人返回不限。删除请求调用实名源的 Erase,成功后写一条不含 subject 的删除审计。

失败模式与兜底

失败表现兜底
实名服务超时无法判断年龄失败关闭:拒绝登录/充值,记录 source_error
实名结果过期年龄带过期或来源不可信要求重新实名,不沿用旧结果
客户端改时钟非可玩时段仍请求进入只用服务端时间,客户端时间仅展示
时区/日切错误20:00 判断成 19:00固定运营时区,服务端统一换算
边界秒不一致21:00:00 仍可玩半开区间,判定与执行共用同一契约
节假日日历缺失调休日误判保守按非可玩日处理,并告警补日历
多游戏各自累计每款游戏都能玩满 1 小时账号级会话与消费汇总,跨产品共享
多游戏分别限额每款游戏都能充到月上限支付账号级月度累计,统一判定
删除与审计冲突删除后审计仍能关联到人删除实名映射,财务记录用脱敏标识保留
退款与防沉迷脱节未成年人退款后仍可继续充退款触发风控复核,不自动恢复额度

“失败关闭”会带来可用性代价:实名服务抖动时,正常成年人也可能暂时进不去。这个代价必须由业务显式接受,并通过缓存已验证结果、降级到最近一次可信结果(仍在有效期内)来降低,而不是在故障时悄悄放行。

常见错误

第一,每个游戏各写一套规则。版本、时区、缓存和故障处理会分叉,同一个账号得到不同结论。

第二,用客户端时间判断时段。改系统时间是最简单的绕过方式。

第三,把实名材料复制到业务库。一旦泄露,影响远超游戏数据;业务侧只需要年龄带。

第四,故障时默认放行。合规系统必须在不确定时选择拒绝或限制,并把故障暴露出来。

第五,每日时长按设备或按游戏累计。玩家切换设备或游戏就能重置,限额形同虚设。

第六,删除请求只删账号表。实名映射、年龄带缓存、审计日志里的关联标识都要有处理策略。

第七,把示例限额当成法律结论。规则会变,数值必须由法务和运营确认,并在配置层可调整。

小结

防沉迷与未成年人保护的核心不是一张规则表,而是“判定集中、执行分散、失败关闭、数据最小化”。判定服务输出 allow/limit/deny 与原因,游戏只负责执行;实名材料留在实名服务,业务侧只保存年龄带;删除请求沿数据链路传导,审计记录不含实名标识。

到这里,支付、退款和合规三条链路都落在了平台侧。下一章把视角拉高:账号、支付、社交和数据平台各自负责什么,边界在哪里,以及什么时候应该合并、什么时候必须拆开。

阅读导航:上一节:11.2 退款、对账与差异处理 · 下一节:12.1 账号、支付、社交、数据平台的职责 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查