平台的边界不是“公共代码”
上一节把实名与防沉迷收进平台时,争论不大,因为合规要求是外生的:不统一做,谁也过不了审。真正难的是下一批能力。它们没有法律强制,却天然像“公共设施”,于是每个团队都想把它们塞进 northstar-platform。
《泡泡工坊》先踩了坑。运营希望节日活动能改倍率,平台配置中心于是加了一张 activity_rate 表,字段按泡泡工坊的活动模型设计。两周后《裂隙战场》要做赛季首充,配置中心又加 season_id;《群岛纪元》再做跨服战,字段变成了 war_phase。半年后,配置中心同时理解三种玩法,任何一款游戏改活动结构,都要平台排期发版,另外两款陪着回归。
另一个方向的反例来自支付。《裂隙战场》要求“首充双倍只在新赛季生效”,支付团队为了少一次调用,把赛季判断写进了支付回调。结果赛季规则变更要支付团队发版,而且支付回调开始依赖战斗服的赛季状态。支付链路原本是最需要稳定的地方,却被塞进了一个每周都在变的规则。
这两个故事指向同一个问题:平台收的是能力,不是业务的最后一步判断。能力可以稳定十年,规则每个赛季都会变。
四条判据
判断一项能力是否收进平台,我们只用四个问题。它们不保证答案唯一,但能把“谁都觉得该收”变成可辩论的结论。
| 判据 | 要回答的问题 | 收进平台的信号 | 留在游戏侧的信号 |
|---|---|---|---|
| 复用度 | 三款产品是否都要用,语义是否一致 | 调用方多、语义稳定、重复实现成本高 | 只有一款用,或各游戏语义差异大 |
| 合规 | 是否涉及实名、支付、发票、留存、审计 | 监管口径统一,必须集中留证 | 只影响单一玩法的内部规则 |
| 故障影响面 | 挂掉之后是否全站不可用 | 登录、支付、配置这类入口级依赖 | 只影响一个玩法或一个活动 |
| 团队归属 | 是否有长期 owner、oncall 和路线图 | 有专职团队、能独立发布与回滚 | 临时项目组,随玩法结束而解散 |
把六项候选能力放进去,结论并不平均:
| 能力 | 复用度 | 合规 | 故障影响面 | 团队归属 | 结论 |
|---|---|---|---|---|---|
| 账号与登录 | 高 | 高(实名、防沉迷) | 全站 | 平台身份组 | 收进平台 |
| 支付与订单 | 高 | 高(对账、发票、退款) | 全站营收 | 平台交易组 | 收进平台,但不含发货规则 |
| 社交关系存储 | 高 | 中(未成年人社交限制) | 单游戏 | 平台社交组 | 收进平台,关系判定留游戏 |
| 数据管道与事件 | 高 | 高(留存、审计) | 延迟容忍 | 数据平台组 | 收进平台,指标口径留产品 |
| 配置分发 | 高 | 低 | 全站 | 平台配置组 | 收进平台,只做分发不做 schema 解释 |
| 公告触达 | 中 | 中(内容审核) | 单游戏 | 平台运营组 | 收进平台,文案与节奏留运营 |
注意“收进平台”后面的限定语。它们不是修辞,而是边界:支付收的是订单状态机,不是“首充算不算新用户”;社交收的是关系图,不是“好友能不能送体力”;数据收的是事件与管道,不是“留存第二天到底怎么算”。
平台拥有事实,游戏拥有策略
边界最容易被“谁改最后一版”弄乱。我们的规则是:平台拥有事实,游戏拥有策略;事实可以共享,策略必须留在租户内。
| 事项 | 平台拥有 | 游戏拥有 | 最终裁决 |
|---|---|---|---|
| 账号身份 | 账号、设备、登录票据 | 角色、昵称、外观 | 平台裁决身份,游戏裁决角色 |
| 支付订单 | 订单状态、金额、渠道流水 | 商品内容、发货条件、活动倍率 | 平台裁决“钱到没到”,游戏裁决“发不发” |
| 社交关系 | 好友、黑名单、会话索引 | 助力、组队、公会权限 | 平台裁决“是不是好友”,游戏裁决“好友能做什么” |
| 数据事件 | 采集、传输、schema 注册 | 指标定义、报表口径 | 平台裁决“事件是否合法”,产品裁决“指标怎么算” |
| 配置分发 | 版本、灰度、回滚 | 配置内容与生效语义 | 平台裁决“哪一版到了哪台机器”,游戏裁决“这一版什么意思” |
这条规则解决了一类反复出现的争论:平台能不能为了“体验一致”替游戏做决定。答案通常是不能。平台可以拒绝非法订单,但不能判断这个礼包该不该卖;平台可以拒绝跨租户读取,但不能判断这个活动该不该开。
反例:玩法规则进了平台
玩法规则渗入平台,通常不是因为有人想越权,而是因为“少一次调用”看起来太划算。它有三个可识别的症状:
- 发布耦合:改一次活动倍率,要平台排期、回归三款游戏。
- 回滚耦合:平台回滚配置中心,三个游戏的运营活动一起停。
- 权限耦合:运营改活动,需要平台数据库权限或后门接口。
一旦出现其中两个,基本可以确认平台收错了东西。正确的切法是:平台提供“按键值读取配置”的能力和版本、灰度、回滚机制;倍率字段的含义、默认值、生效时间由游戏自己的配置服务解释。平台不需要知道 season_id 是什么,只需要保证同一租户读到同一版本。
代码:把边界写成依赖图
口头边界撑不过三次迭代。internal/platformcore 把能力清单和依赖方向写成可校验的数据结构:
- 每个能力声明名称、类型、层级、owner 和依赖。
- 层级从基础设施到洞察共四层,依赖只能指向同层或更低层,不能反向。
Validate一次返回所有问题:未知依赖、方向错误、同层环。Remove在仍有依赖方时拒绝下线,并指出第一个阻塞者。
KindGameplay 被单独列出来,不是因为它特殊,而是为了让“玩法规则不许进平台”变成一条带名字的错误,而不是一句评审意见。
// file: internal/platformcore/roster.go
// tier: L1
// verify: go test ./internal/platformcore/
package platformcore
import (
"fmt"
"sort"
)
// Roster is the platform capability registry. Registration is cheap; the
// graph is checked by Validate so teams can register in any order.
type Roster struct {
caps map[string]Capability
}
func NewRoster() *Roster {
return &Roster{caps: map[string]Capability{}}
}
// Register adds one capability. It rejects names that are empty, duplicated,
// layered outside the known range, or that describe gameplay rules.
func (r *Roster) Register(c Capability) error {
if c.Name == "" {
return ErrEmptyName
}
if c.Kind == KindGameplay {
return ErrGameplay
}
if !knownKind(c.Kind) {
return ErrBadKind
}
if c.Layer < LayerFoundation || c.Layer >= layerMax {
return ErrBadLayer
}
if _, ok := r.caps[c.Name]; ok {
return ErrDuplicate
}
r.caps[c.Name] = c
return nil
}
// Validate returns every problem instead of the first one, because a broken
// dependency graph usually has several independent mistakes.
func (r *Roster) Validate() []Problem {
var problems []Problem
for _, name := range r.names() {
c := r.caps[name]
for _, dep := range c.Depends {
target, ok := r.caps[dep]
if !ok {
problems = append(problems, Problem{name, "unknown-dependency", dep})
continue
}
if target.Layer > c.Layer {
problems = append(problems, Problem{name, "dependency-direction", dep})
}
}
}
if cycle := r.findCycle(); cycle != "" {
problems = append(problems, Problem{cycle, "dependency-cycle", cycle})
}
return problems
}
// Remove deletes a capability only when nothing depends on it. The returned
// error names one blocker so the owner can migrate that caller first.
func (r *Roster) Remove(name string) error {
if _, ok := r.caps[name]; !ok {
return ErrUnknown
}
if deps := r.Dependents(name); len(deps) > 0 {
return fmt.Errorf("%w: %s", ErrDependents, deps[0])
}
delete(r.caps, name)
return nil
}
// Dependents lists capabilities that would break if name disappeared. It is
// the answer to "who do I have to call before shutting this down".
func (r *Roster) Dependents(name string) []string {
var out []string
for _, other := range r.names() {
for _, dep := range r.caps[other].Depends {
if dep == name {
out = append(out, other)
break
}
}
}
return out
}
func (r *Roster) names() []string {
out := make([]string, 0, len(r.caps))
for name := range r.caps {
out = append(out, name)
}
sort.Strings(out)
return out
}
// findCycle reports one capability on a dependency cycle, or the empty string.
// Depth-first colouring is enough because the graph is small and Validate is
// called during review, not per request.
func (r *Roster) findCycle() string {
const (
white = 0
grey = 1
black = 2
)
color := map[string]int{}
var visit func(string) string
visit = func(name string) string {
color[name] = grey
for _, dep := range r.caps[name].Depends {
if _, ok := r.caps[dep]; !ok {
continue
}
switch color[dep] {
case grey:
return name
case white:
if found := visit(dep); found != "" {
return found
}
}
}
color[name] = black
return ""
}
for _, name := range r.names() {
if color[name] == white {
if found := visit(name); found != "" {
return found
}
}
}
return ""
}
func knownKind(k Kind) bool {
switch k {
case KindAccount, KindPayment, KindSocial, KindData, KindConfig, KindNotice:
return true
}
return false
}
roster.go 只做静态校验,不参与请求路径。它适合放在平台仓库的 CI 里:能力清单变更时跑一次,依赖方向错了就挡住合并,而不是等到发布前才发现支付依赖了战斗服。
下面这组测试覆盖两个最容易退化的点:基础能力反向依赖洞察层,以及有依赖方的能力被直接删除。
// file: internal/platformcore/direction_test.go
// tier: L1
package platformcore
import (
"errors"
"testing"
)
func TestDependencyDirection(t *testing.T) {
tests := []struct {
name string
cap Capability
wantRule string
}{
{
name: "foundation may not depend on insight",
cap: Capability{Name: "config-v2", Kind: KindConfig, Layer: LayerFoundation, Depends: []string{"data"}},
wantRule: "dependency-direction",
},
{
name: "same layer is allowed",
cap: Capability{Name: "notice", Kind: KindNotice, Layer: LayerEngagement, Depends: []string{"social"}},
wantRule: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := baseRoster(t)
if err := r.Register(tc.cap); err != nil {
t.Fatalf("Register: %v", err)
}
problems := r.Validate()
if tc.wantRule == "" {
if len(problems) != 0 {
t.Fatalf("Validate() = %v, want none", problems)
}
return
}
for _, p := range problems {
if p.Rule == tc.wantRule {
return
}
}
t.Fatalf("Validate() = %v, missing %q", problems, tc.wantRule)
})
}
}
func TestRemovalIsGuarded(t *testing.T) {
tests := []struct {
name string
target string
want error
}{
{"foundation still in use", "account", ErrDependents},
{"leaf can be removed", "data", nil},
{"unknown capability", "missing", ErrUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := baseRoster(t)
if err := r.Remove(tc.target); !errors.Is(err, tc.want) {
t.Fatalf("Remove(%s) = %v, want %v", tc.target, err, tc.want)
}
})
}
}
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/platformcore
$ go vet ./internal/platformcore/...
$ go test -count=1 ./internal/platformcore/...
ok gsarchlab/internal/platformcore
gofmt -l 与 go vet 均无输出。platformcore 的 6 个测试函数共 27 个子用例通过,覆盖注册拒绝、依赖方向、同层环、下线保护与依赖方查询。测试规模是示例,不代表生产能力清单的规模。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 依赖成环 | 两个能力互相等待,谁也无法独立发布 | CI 跑 Validate,同层环直接失败 |
| 反向依赖 | 基础能力依赖上层,故障时一起倒 | 层级规则拒绝合并,评审必须改方向 |
| 能力无 owner | 出问题没人认领,改动靠拉群 | 注册时强制填 owner,季度复核 |
| 下线无通知 | 依赖方线上报错才发现 | Remove 拒绝有依赖方的能力,先迁移 |
| 玩法规则渗入 | 每次改玩法都要平台发版 | KindGameplay 拒绝注册;评审看字段是否含赛季、活动 |
| 平台替游戏裁决 | 同一能力出现多套“特例” | 平台只返回事实,策略留在游戏侧 |
常见错误与边界
第一,把“复用”当成“抽象”。三款游戏都调用支付,不代表支付应该理解商品目录;复用接口,不复用策略。
第二,把配置中心做成 schema 中心。配置中心只负责版本、灰度、回滚和分发;解释字段含义的是游戏。
第三,让平台承担“最后一步判断”。首充、回归、赛季这些条件每周都变,放进平台就把发布节奏绑死了。
第四,能力清单没有 owner。没有 oncall 的能力不是平台能力,只是一个没人删的公共库。
第五,依赖方向靠口头约定。没有 CI 校验的架构规则,第三次迭代就会破。
小结
平台服务化的第一步不是建多少个服务,而是明确哪些能力不该收。复用度、合规、故障影响面和团队归属四条判据,加上“平台拥有事实、游戏拥有策略”的裁决规则,能把大部分争论变成可验证的清单。代码层面,能力注册表和依赖方向校验让边界在合并请求里生效,而不是留在架构文档里。
边界划清之后,下一个问题是平台怎么同时服务三款游戏:数据放在同一张表还是分开、一个游戏的活动如何不影响另外两个、平台升级时旧版本游戏怎么办。这就是下一节的租户隔离。
阅读导航:上一节:11.3 防沉迷、实名与未成年人保护 · 下一节:12.2 多游戏复用与租户隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。