《游戏服务端架构·核心系统与契约》12.1 账号、支付、社交、数据平台的职责

北辰三款产品共用 northstar-platform 后,最先吵起来的不是技术选型,而是边界:哪些能力该收进平台、平台交付的是能力还是数据、玩法规则能不能顺手放进去。本文用复用度、合规、故障影响面和团队归属四条判据划边界,并给出能力注册表与依赖方向校验的可运行实现,解释平台为什么会退化成第二个游戏服。

平台的边界不是“公共代码”

上一节把实名与防沉迷收进平台时,争论不大,因为合规要求是外生的:不统一做,谁也过不了审。真正难的是下一批能力。它们没有法律强制,却天然像“公共设施”,于是每个团队都想把它们塞进 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 多游戏复用与租户隔离 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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