三个产品,四种“已经实名”
《泡泡工坊》在渠道包上架时,渠道 SDK 已经完成过一次实名。玩家从渠道包切到官网包,平台账号体系里却只有一条空记录,系统要求他再填一次。客服解释“渠道那边验过了”,后台没有凭证,只能按未实名处理。
《裂隙战场》的赛事服在另一个区域。参赛账号的主区在亚太,比赛房间在北美;登录、进房、支付报名费、跨区参赛四个动作,各自踩到不同的规则。支付系统只检查余额,赛事系统只检查段位,没有任何一处检查“这个账号在这个区域是否允许被处理”。
《群岛纪元》是全球同服。一个玩家在设备上把时区从东八区改到西五区,客户端显示“今天还没玩”,服务端的当日累计却已经接近上限。运营查日志时发现,两个系统用了不同的“今天”。
这三个问题看起来分别是渠道对接、跨区部署和时区处理,根子上是同一件事:团队把合规当成登录页上的一个勾,而它实际上是一组发生在不同入口、依赖不同事实、失败后动作也不同的判断。
需要先说明边界:本文讨论的是这些判断在服务端架构里的落点,不解读任何具体法律条文,也不构成法律意见。年龄门槛、每日时长、留存期限和区域名单,全部由法务与运营确认后作为参数注入;代码里只出现参数名,不出现拍脑袋的数字。
校验点不是越多越好,而是各管一段
北辰最初的做法是在登录网关里塞一个巨大的 complianceCheck:不通过就不发会话。上线两周后问题集中爆发:实名服务抖动时,所有玩家连同客服后台一起被挡在门外;渠道包的回调晚到三秒,玩家已经被踢回登录页;跨区观战因为主区与观赛区不同,被误判成异地登录。
把校验拆成四个入口之后,每个入口只回答自己那一段的问题。
| 入口 | 必须同步完成 | 可以异步复核 | 不通过时 |
|---|---|---|---|
| 登录前 | 账号状态、拒绝名单、实名拒绝态 | 实名待定、设备风险 | 拒绝建立会话 |
| 进入游戏前 | 实名已验证、年龄档、当日剩余时长 | 渠道实名对账、异常登录画像 | 允许登录但拒绝进服 |
| 支付前 | 实名已验证、年龄档、版号可用、支付风控 | 大额订单人工复核 | 拒绝创建订单 |
| 跨区时 | 账号归属、区域规则命中 | 数据驻留复核、客服授权 | 进入复核队列,不直接放行 |
这张表有三条设计意图。
第一,登录与进服必须分开。登录成功但进服被拒,玩家还能看公告、联系客服、完成实名补录;如果登录网关一刀切,补录入口本身也被挡掉,客服只能手工改库。
第二,支付前的判断必须同步且完整。扣款之后再退款,涉及渠道对账、发票和风控,代价远高于在下单前多查一次。支付入口适合“全部满足才放行”,不适合“先放行再补材料”。
第三,跨区默认进入复核而不是直接拒绝。跨区可能是正常观赛、合服或客服协助,也可能是账号被盗后的异地登录;把它做成一个可观察的队列,比在网关里硬编码规则更容易运营。
实名服务的三种边界
实名结果可能来自三个地方,它们的信任依据不同,失败后的动作也不能一样。
| 来源 | 信任依据 | 缓存键 | 典型失败 |
|---|---|---|---|
| 平台自有 | 平台直连身份服务,签名响应 | 平台账号 ID | 服务超时、证件库不可用 |
| 渠道 SDK | 渠道回调签名、订单号去重 | 渠道 + 渠道账号 | 回调延迟、签名不匹配 |
| 海外第三方 | 本地供应商合同与回调凭证 | 区域 + 本地账号 | 区域故障、结果格式差异 |
平台不缓存证件号码,也不缓存出生日期。缓存里只存三样东西:实名状态、年龄档、过期时间。证件号一旦落进业务库,删除请求的级联范围会立刻扩大一圈;年龄档只需要回答“是否适用未成年人预算”,精度到日就够了。
缓存命中是优化,不是事实来源。一个过期的“已实名”比“未知”更危险,因为它会让进服和支付同时放行。因此缓存条目必须带绝对过期时间,读取时先比较时间再返回;过期条目删除后重新查询,而不是原地续期。
失败策略按入口配置,不做全局开关。登录入口在实名服务不可用时可进入“待复核”状态,先进游戏看公告;进服与支付入口则必须失败关闭。把“服务挂了就全部放行”写成全局配置,等于给了一次持续时间未知的绕过窗口。
渠道回调还要额外处理三件事:签名验证、回调幂等、重放窗口。回调可能重复投递,也可能迟到;用渠道订单号做幂等键,超出重放窗口的旧回调进入人工对账,不直接改写当前状态。
防沉迷计时:账号、时区与重连
时长账本比实名更容易做错,因为它跨会话、跨天、跨游戏,而且每个环节都可能重试。
第一条规则是按账号累计,不按角色。玩家在《群岛纪元》里换一个角色,或者在《裂隙战场》和《泡泡工坊》之间切换,消耗的都是同一个账号的预算。角色维度的统计仍然保留,用于运营分析,但不参与准入判断。
第二条规则是跨游戏累计、按产品可拆。平台维护账号级账本,同时记录每个产品贡献的时长;对账时既能回答“这个账号今天还能玩多久”,也能回答“这半小时花在哪个产品上”。
第三条规则是跨天按策略时区切分。服务端不使用设备时区,也不使用玩家自报的时区;它使用账号所属运营区域对应的策略时区,在该时区的本地午夜切分。一个从 22:00 持续到次日 02:00 的会话,会被切成两段分别记账,而不是整段落在开始那天。
第四条规则是断线重连不重置。会话以 sessionID 为幂等键上报,网关重试同一个 sessionID 不会重复扣减;重连产生新的 sessionID,但继续累加到同一个账号当天的桶里。离线时间不计入游戏时长,重连后的宽限期是独立参数,不参与预算计算。
| 事件 | 记账动作 | 幂等键 |
|---|---|---|
| 会话结束上报 | 按本地午夜切分后累加 | sessionID |
| 网关重试同一上报 | 直接返回成功,不重复累加 | sessionID |
| 重连建立新会话 | 新 sessionID,累加同一账号当天桶 | sessionID |
| 跨天在线 | 午夜切分,两段分别计入两天 | sessionID + 日期 |
| 迟到上报 | 在重放窗口内接受,超出则进入对账 | sessionID |
这里有一个容易被忽略的边界:会话上报可能晚于切天时刻到达。如果服务端用“收到上报的时间”决定记在哪一天,就会把昨天的时长记到今天。正确做法是用会话自身的开始与结束时间切分,收到时间只用于判断是否超出重放窗口。
把规则做成表:compliancerules
下面的实现把“入口、严重级别、原因码”做成数据表,评估器只负责遍历匹配;实名缓存和时长账本放在同包的另一文件里,避免一个函数同时处理身份、时间和网络重试。
// file: internal/compliancerules/rules.go
// tier: L1
// verify: go test ./internal/compliancerules/
package compliancerules
import (
"sort"
"strings"
"time"
)
// Gate is a point where the platform must make an allow/deny decision. The
// same account can pass one gate and fail the next; treating them as a single
// "login check" hides where a rule is actually enforced.
type Gate string
const (
GateBeforeLogin Gate = "before_login"
GateBeforeEnter Gate = "before_enter"
GateBeforePayment Gate = "before_payment"
GateCrossRegion Gate = "cross_region"
)
// Severity separates a synchronous block from a review that may complete
// after the player is already online. A review is not a substitute for a
// block at a gate whose answer must be exact before the action proceeds.
type Severity string
const (
SeverityBlock Severity = "block"
SeverityReview Severity = "review"
)
// Reason is a stable machine-readable code. Keeping it separate from the
// player-facing message lets support and analytics count denials without
// parsing prose, and lets one reason serve several gates.
type Reason string
const (
ReasonRealNameMissing Reason = "real_name_missing"
ReasonRealNamePending Reason = "real_name_pending"
ReasonRealNameRejected Reason = "real_name_rejected"
ReasonAgeUnknown Reason = "age_band_unknown"
ReasonMinorBudgetUsedUp Reason = "minor_budget_used_up"
ReasonLicenseUnavailable Reason = "license_unavailable"
ReasonResidencyReview Reason = "residency_review"
)
func (r Reason) String() string { return string(r) }
// RealNameState is the cached result of an identity provider call. Unknown
// is not Pending: Unknown means no answer is cached, Pending means a call is
// in flight or the provider asked us to retry later.
type RealNameState string
const (
RealNameVerified RealNameState = "verified"
RealNamePending RealNameState = "pending"
RealNameRejected RealNameState = "rejected"
RealNameUnknown RealNameState = "unknown"
)
// AgeBand is deliberately coarse. The server needs to know whether minor
// limits apply and which budget to read, not a birth date.
type AgeBand string
const (
AgeAdult AgeBand = "adult"
AgeMinor AgeBand = "minor"
AgeUnknown AgeBand = "unknown"
)
// AccountState is the subset of account facts a gate needs. Keeping it a
// value makes the rule table testable without a database, and makes it
// obvious which facts a new rule depends on.
type AccountState struct {
RealName RealNameState
Age AgeBand
MinorLeft time.Duration
LicenseOK bool
HomeRegion string
}
// Request is one gate evaluation. RoleID is carried for logs only; limits
// are accounted per account, never per role, so a player cannot reset a
// budget by switching characters.
type Request struct {
AccountID string
RoleID string
Gate Gate
Region string
State AccountState
Now time.Time
}
// Decision is the result of one pass over the rule table. Reviews are still
// allowed; blocks are not. A block always wins even if a review also fired,
// because an asynchronous follow-up cannot undo an action already taken.
type Decision struct {
Allow bool
Blocks []Reason
Reviews []Reason
}
func (d Decision) Explain() string {
if d.Allow && len(d.Reviews) == 0 {
return "allow"
}
parts := make([]string, 0, 2)
if len(d.Blocks) > 0 {
parts = append(parts, "block:"+joinReasons(d.Blocks))
}
if len(d.Reviews) > 0 {
parts = append(parts, "review:"+joinReasons(d.Reviews))
}
return strings.Join(parts, " ")
}
func joinReasons(rs []Reason) string {
out := make([]string, len(rs))
for i, r := range rs {
out[i] = string(r)
}
sort.Strings(out)
return strings.Join(out, ",")
}
// Rule is one condition at one gate. Rules are data so they can be reviewed,
// diffed and unit-tested; the evaluator itself only knows block/review.
type Rule struct {
Gate Gate
Severity Severity
Reason Reason
Match func(Request) bool
}
// DefaultRules is the policy table used by the platform. Order is stable so
// that Explain output and tests do not depend on map iteration.
func DefaultRules() []Rule {
return []Rule{
{GateBeforeLogin, SeverityBlock, ReasonRealNameRejected, func(r Request) bool {
return r.State.RealName == RealNameRejected
}},
{GateBeforeLogin, SeverityReview, ReasonRealNamePending, func(r Request) bool {
return r.State.RealName == RealNamePending || r.State.RealName == RealNameUnknown
}},
{GateBeforeEnter, SeverityBlock, ReasonRealNameMissing, func(r Request) bool {
return r.State.RealName != RealNameVerified
}},
{GateBeforeEnter, SeverityBlock, ReasonMinorBudgetUsedUp, func(r Request) bool {
return r.State.Age == AgeMinor && r.State.MinorLeft <= 0
}},
{GateBeforeEnter, SeverityReview, ReasonAgeUnknown, func(r Request) bool {
return r.State.Age == AgeUnknown
}},
{GateBeforePayment, SeverityBlock, ReasonRealNameMissing, func(r Request) bool {
return r.State.RealName != RealNameVerified
}},
{GateBeforePayment, SeverityBlock, ReasonAgeUnknown, func(r Request) bool {
return r.State.Age == AgeUnknown
}},
{GateBeforePayment, SeverityBlock, ReasonLicenseUnavailable, func(r Request) bool {
return !r.State.LicenseOK
}},
{GateBeforePayment, SeverityBlock, ReasonMinorBudgetUsedUp, func(r Request) bool {
return r.State.Age == AgeMinor && r.State.MinorLeft <= 0
}},
{GateCrossRegion, SeverityReview, ReasonResidencyReview, func(r Request) bool {
return r.State.HomeRegion != "" && r.Region != r.State.HomeRegion
}},
}
}
// Evaluate applies every matching rule once. It never short-circuits: support
// needs the full reason list to explain a denial, and a second rule must not
// disappear just because the first one already blocked.
func Evaluate(req Request, rules []Rule) Decision {
d := Decision{Allow: true}
for _, rule := range rules {
if rule.Gate != req.Gate || !rule.Match(req) {
continue
}
if rule.Severity == SeverityBlock {
d.Allow = false
d.Blocks = append(d.Blocks, rule.Reason)
continue
}
d.Reviews = append(d.Reviews, rule.Reason)
}
return d
}
// EvaluateDefault is the path used by request handlers. Tests that exercise
// a narrower policy can call Evaluate directly.
func EvaluateDefault(req Request) Decision {
return Evaluate(req, DefaultRules())
}
这段代码有三个可以单独测试的决策点。
第一,Evaluate 不做短路。支付入口同时缺实名、年龄未知、版号不可用时,三个原因都会返回;客服看到的是完整清单,而不是“修完第一个再看第二个”。
第二,阻断优先于复核。同一入口既命中复核规则又命中阻断规则时,Allow 为 false。异步复核不能撤销一个已经发生的扣款或进服动作。
第三,时长按账号和策略时区的日键累计,sessionID 保证重试幂等。AddSession 在本地午夜切分区间,所以跨天在线不会把整段时长塞进开始那天。
失败模式与兜底
| 失败 | 直接后果 | 兜底 |
|---|---|---|
| 实名服务超时 | 登录可进、进服与支付被拒 | 待复核队列 + 缓存旧结果(未过期) |
| 渠道回调重复 | 状态被覆盖、时长重复扣 | 订单号/ sessionID 幂等 |
| 策略时区配置错误 | 跨天切分偏移 | 配置校验 + 灰度区域 |
| 时长上报丢失 | 预算偏松 | 心跳快照 + 日终对账 |
| 版号状态缓存过期 | 支付被误拒 | 支付前强制刷新,不读长缓存 |
| 跨区规则未命中 | 误放行或误拦截 | 默认复核 + 人工工单 |
兜底的原则是“宁可让一个人工队列变长,也不要让一个未知状态静默放行”。但兜底不等于全局失败关闭:登录入口需要保留补录和客服通道,否则一次第三方故障会演变成全量玩家无法自救。
常见错误
第一,把实名、年龄、时长塞进一个登录检查。结果是补录入口也被挡掉,故障时没有降级路径。
第二,按角色计时。玩家换角色就能重置预算,运营数据还会显示“人均时长正常”。
第三,用客户端时区判断跨天。改系统时间是最简单的绕过方式,服务端只认策略时区。
第四,缓存证件号。删除请求的级联范围立刻扩大,备份和日志里也会留下副本。
第五,渠道回调不做幂等。重复投递会把“待定”覆盖成“通过”,或者把同一段时长扣两次。
第六,把异步复核当成阻断。复核队列积压时,支付和进服仍然放行,等于规则没有落点。
小结
版号、实名和防沉迷的架构落点,不是登录页上的一个开关,而是四个入口、三类实名来源和一本按账号累计的时间账本。入口决定哪些判断必须同步,来源决定缓存什么、失败时怎么办,账本决定跨游戏、跨天和重连时如何不重不漏。把这些做成表而不是散落在网关里的 if,才能在上线后解释每一次拒绝,也才能在参数变化时只改配置、不改调用链。
下一节把视角从“能不能玩”移到“数据能不能留、能不能走、能不能删”:当实名信息、支付流水和战斗日志落在不同区域,删除一个账号到底要扇出到哪些系统,又怎样证明它真的删干净了。
阅读导航:上一节:9.3 成本与体验的取舍 · 下一节:10.2 隐私、数据出境与删除权 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。