《游戏服务端架构·规模化、全球化与成本》10.1 版号、实名、防沉迷的架构落点

《裂隙战场》《群岛纪元》《泡泡工坊》的登录、进服、支付和跨区四个入口,需要的不是同一套实名与时长校验。本文把版号、实名、年龄与防沉迷拆成可配置的规则表,讨论自有与渠道实名服务的缓存和失败策略,说明按账号累计、跨天切分与断线重连的计时边界,并附 internal/compliancerules 的 Go 实现与六组表驱动测试。

三个产品,四种“已经实名”

《泡泡工坊》在渠道包上架时,渠道 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 隐私、数据出境与删除权 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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