一个超时,三个答案
《泡泡工坊》在国庆假期做了一次联动活动。第一天晚上,实名认证服务因为上游限流超时了四十分钟。三款游戏各自处理:大厅服认为“查不到就放行”,房间服认为“查不到就禁止登录”,充值服则直接跳过了年龄判断。结果同一个账号在大厅能玩、进房间被踢、充值却成功了。家长第二天投诉时,三款游戏给出的解释互相矛盾。
这不是某一段代码的 bug,而是判定规则没有集中。防沉迷与未成年人保护涉及实名、年龄、时段、消费限额、退款和删除请求,任何一条规则复制到每个游戏,都会在版本、时区、缓存和故障处理上分叉。
合规系统要解决的不是“写一个 if”,而是回答四个问题:
- 判断依据是什么,可信度如何;
- 判断在哪个服务做,执行由谁负责;
- 判断失败时是放行还是拒绝;
- 个人信息保存多久,删除请求如何传导。
合规输入与可信度
| 输入 | 来源 | 可信度 | 用途 | 保存方式 |
|---|---|---|---|---|
| 实名结果/年龄带 | 实名认证服务 | 高(需在有效期) | 是否未成年、适用哪档限额 | 只存年龄带与验证时间 |
| 游戏账号 | 账号系统 | 高 | 关联多游戏消费与时长 | 内部主键 |
| 设备与 IP | 客户端/网关 | 中 | 风控辅助,不作年龄依据 | 短期风控日志 |
| 当前时间 | 服务端时钟 | 高 | 可玩时段判断 | 不依赖客户端 |
| 当日已玩时长 | 会话/心跳汇总 | 高(需防篡改) | 每日时长限额 | 服务端累计 |
| 本月已消费 | 支付/订单账本 | 高 | 月度限额 | 与订单同源 |
| 玩家自报年龄 | 客户端 | 低 | 仅用于引导实名 | 不作为判定依据 |
第一条边界最重要:年龄只认实名服务返回的年龄带,不认玩家自报。实名材料(姓名、证件号、照片)不应进入游戏业务库;业务侧只需要一个派生结果:under8、8to16、16to18、adult,以及“是否已验证”和“验证时间”。
年龄带是数据最小化的结果。它足够执行规则,又不能在业务库被还原成具体生日或证件号。实名服务的留存期、加密和访问审计由它自己负责,游戏侧不复制原始材料。
判定与执行分离
登录/充值请求
|
v
+---------------------+ +------------------+
| 合规判定服务 |<------>| 实名认证服务 |
| CheckPlay/CheckPurchase | | 返回年龄带/可信度 |
+---------------------+ +------------------+
|
| Decision: allow / limit / deny
| + reason + remaining + nextAllowedAt
v
+---------------------+ +------------------+
| 大厅、房间、充值服务 |------->| 执行:放行/限制/拒绝 |
+---------------------+ +------------------+
判定服务只输出决策,不直接踢人、不直接扣钱。执行由各游戏完成,原因是执行动作依赖具体场景:大厅拒绝登录、房间中途结束对局、充值页拦截下单,语义不同;但“能不能玩、能花多少”的规则必须来自同一个判定服务。
决策契约至少包含:
| 字段 | 含义 | 执行方如何使用 |
|---|---|---|
Action | allow / 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:00 | 1 小时 | 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 账号、支付、社交、数据平台的职责 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。