《游戏服务端架构·品类、状态与拓扑》10.3 玩家创造内容的服务端信任模型

UGC 上线后,认证创作者的脚本开始调用经济接口,另一张地图试图直接发放道具。问题不在脚本写错,而在服务端把逻辑也当成了可信输入。本节给出官方、认证创作者、普通玩家三级信任模型,能力白名单、配额与审核强度,以及违规降级、升级条件和平台账本的分工与代价。

脚本不是数据

UGC 地图开放一个月后,《长风原》的运营发现两类异常:一张认证创作者的地图脚本在玩家进入时调用经济接口,试图给自己发放道具;另一张地图的脚本打开外部网络连接,把房间信息发到第三方地址。

两张地图都通过了静态校验和沙箱运行。问题不在脚本写错了,而在服务端把“玩家提交的逻辑”和“玩家提交的数据”放在了同一个信任级别:地图 JSON 被 schema 校验,脚本却被当成第一方代码执行。

这一节给出一个可以落地的边界:服务端信任数据,不信任逻辑。 数据可以校验、可以拒绝;逻辑只能隔离、观察和限权。

三级信任模型

北辰把 UGC 创作者分成三级。级别决定能力、配额和审核强度,不决定运行时权限;运行时权限永远由能力白名单单独判定。

级别对象能力脚本/资源/实体上传/日在线地图审核
官方内部团队全部内容能力,含全局写入4 MiB / 256 MiB / 20k200100自动
认证签约或历史合规创作者读世界、交互、UI1 MiB / 64 MiB / 5k2010抽样
普通所有玩家读世界、交互、UI256 KiB / 16 MiB / 1k53强制

表中的数字是示例策略值,不是实测结果;真实阈值要按沙箱成本、审核人力和内容类型调整。级别不是永久身份:违规会降级,普通创作者在满足条件后可以升到认证,但认证到官方永远是人审决定,不自动升级。

只信数据,不信逻辑

输入例子服务端做什么失败时
数据地图 JSON、实体坐标、道具 ID、计时器schema 校验、范围检查、所有权检查拒绝请求
逻辑脚本、触发器、表达式放进沙箱,只给白名单能力终止运行、记录违规
意图“生成一个宝箱”“扣 10 金币”宿主校验后执行,带幂等键拒绝意图,不回滚已提交事务

关键在第三行:脚本不能直接写数据库,也不能直接调用经济服务。它只能返回“意图”,由宿主进程按当前玩家的权限、地图规则和配额做二次校验,再调用平台服务。这样即使脚本被篡改,它能造成的副作用也受限于宿主愿意执行的意图集合。

意图还必须带幂等键。脚本重试、沙箱重启、消息重复都会让同一个意图到达两次;没有幂等键,玩家会收到两份奖励,或者被扣两次费用。

能力白名单

能力不是一张全局开关,而是按级别配置的白名单:

能力官方认证普通说明
world.read是是是只读查询世界状态
event.interact是是是触发地图内交互
ui.overlay是是是展示地图自定义 UI
world.write.global是否否改全服状态,仅官方
fs.read否否否读宿主文件
net.outbound否否否访问外部网络
os.exec否否否执行进程
economy.grant否否否直接发放货币或道具

后四项在任何级别都不允许。它们描述的是宿主访问,不是内容行为;如果某个玩法确实需要,正确做法是平台新增一个受控意图,而不是给脚本更大权限。白名单要在策略构造时校验:把 fs.read 配进普通玩家策略的配置错误,应该让进程启动失败,而不是等第一个恶意脚本触发。

运行观测与降级

沙箱能阻止越权,但不能阻止“合法但有害”的内容:刷屏、诱导付费、极端难度、资源占用。运行观测负责这一类问题。

违规按严重度累计:内容策略 20、资源炸弹 30、脚本逃逸 40、经济滥用 50。累计到 50 时降一级,分数归零,并暂停 7 天;普通玩家已经在最低级,只暂停不降级。官方账号不自动降级,直接升级给人审,因为自动降级会掩盖内部流程问题。

违规分数典型处理
内容策略20记录,要求整改
资源炸弹30下架当前版本
脚本逃逸40累计到阈值后降级 + 暂停
经济滥用50降级 + 暂停,另行核对账本

升级认证的门槛同样要写死:至少 3 张已发布地图、当前分数为 0、连续 30 天无违规。没有这三个条件,“认证创作者”会变成注册即得,审核强度也就名存实亡。

经济与社交外溢必须走平台账本

UGC 一旦允许交易、分成或付费地图,经济状态就不能留在地图运行时里。原因不是技术偏好,而是三条硬约束:

  1. 审计与防作弊。 每一笔收支都要能回答“谁、何时、为什么”;脚本可以伪造余额,平台账本只接受校验过的意图。
  2. 合规。 分成涉及税务、结算和地区规则,不能由某个地图进程决定。
  3. 跨地图一致性。 玩家在 A 地图赚的钱要能在 B 地图花,余额必须只有一个权威来源。

因此 Authorize 对交易和分成返回的不是“允许执行”,而是“路由到平台账本”。UGC 运行时只负责产生交易意图,账本负责余额、流水、退款和分成。分成提现还要求创作者至少是认证级别;普通玩家可以交易,但不能直接提现。

代码:信任等级与策略

internal/contenttrust 把级别、能力白名单、策略校验和注册表写成可测试的模型。下面这段是策略与注册;授权、违规降级和升级在同一个包的 runtime.go 里。

// file: internal/contenttrust/contenttrust.go
// tier: L1
// verify: go test ./internal/contenttrust/
package contenttrust

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrBadPolicy        = errors.New("contenttrust: incomplete or invalid policy")
	ErrPolicyUnsafe     = errors.New("contenttrust: policy grants an unsafe capability")
	ErrEmptyCreator     = errors.New("contenttrust: empty creator id")
	ErrUnknownCreator   = errors.New("contenttrust: unknown creator")
	ErrDuplicateCreator = errors.New("contenttrust: creator already registered")
)

// Tier is ordered from most to least trusted. A numeric comparison is only
// used for "at least verified"; capability checks always use the policy map.
type Tier int

const (
	TierOfficial Tier = iota
	TierVerified
	TierPlayer
)

func (t Tier) String() string {
	switch t {
	case TierOfficial:
		return "official"
	case TierVerified:
		return "verified"
	case TierPlayer:
		return "player"
	default:
		return "unknown"
	}
}

func (t Tier) valid() bool { return t >= TierOfficial && t <= TierPlayer }

type Capability string

const (
	CapWorldRead        Capability = "world.read"
	CapEventInteract    Capability = "event.interact"
	CapUIOverlay        Capability = "ui.overlay"
	CapWorldWriteGlobal Capability = "world.write.global"
	CapFileRead         Capability = "fs.read"
	CapNetOutbound      Capability = "net.outbound"
	CapProcessExec      Capability = "os.exec"
	CapEconomyGrant     Capability = "economy.grant"
)

// unsafeCapabilities can never appear in any tier. They describe host access,
// not content behaviour; a UGC script that needs them needs a platform change
// and a human review, not a bigger quota.
func unsafeCapability(c Capability) bool {
	switch c {
	case CapFileRead, CapNetOutbound, CapProcessExec, CapEconomyGrant:
		return true
	}
	return false
}

type ReviewMode int

const (
	ReviewAuto ReviewMode = iota
	ReviewSampled
	ReviewMandatory
)

type Policy struct {
	Tier             Tier
	MaxScriptBytes   int64
	MaxAssetBytes    int64
	MaxEntities      int
	MaxUploadsPerDay int
	MaxLiveMaps      int
	Review           ReviewMode
	Caps             map[Capability]bool
	AllowEconomy     bool
}

func (p Policy) validate() error {
	if !p.Tier.valid() {
		return fmt.Errorf("%w: tier %d", ErrBadPolicy, p.Tier)
	}
	if p.MaxScriptBytes <= 0 || p.MaxAssetBytes <= 0 || p.MaxEntities <= 0 ||
		p.MaxUploadsPerDay <= 0 || p.MaxLiveMaps <= 0 {
		return fmt.Errorf("%w: %s has non-positive quota", ErrBadPolicy, p.Tier)
	}
	if p.Review < ReviewAuto || p.Review > ReviewMandatory {
		return fmt.Errorf("%w: review mode %d", ErrBadPolicy, p.Review)
	}
	for cap := range p.Caps {
		if unsafeCapability(cap) {
			return fmt.Errorf("%w: %s grants %s", ErrPolicyUnsafe, p.Tier, cap)
		}
		if cap == CapWorldWriteGlobal && p.Tier != TierOfficial {
			return fmt.Errorf("%w: %s grants %s", ErrPolicyUnsafe, p.Tier, cap)
		}
	}
	return nil
}

type Action int

const (
	ActionUpload Action = iota
	ActionPublish
	ActionUseCapability
	ActionTrade
	ActionPayout
)

type Request struct {
	Action       Action
	ScriptBytes  int64
	AssetBytes   int64
	Entities     int
	UploadsToday int
	LiveMaps     int
	Cap          Capability
}

type Route int

const (
	RouteRuntime Route = iota
	RouteReview
	RouteLedger
	RouteDenied
)

type DenyCode int

const (
	DenyNone DenyCode = iota
	DenySuspended
	DenyQuota
	DenyCapability
	DenyEconomy
	DenyPayout
)

type Decision struct {
	Allowed bool
	Route   Route
	Review  ReviewMode
	Code    DenyCode
}

func deny(code DenyCode) Decision {
	return Decision{Route: RouteDenied, Code: code}
}

type Violation int

const (
	ViolationContentPolicy Violation = iota
	ViolationResourceBomb
	ViolationScriptEscape
	ViolationEconomyAbuse
)

func (v Violation) severity() int {
	switch v {
	case ViolationContentPolicy:
		return 20
	case ViolationResourceBomb:
		return 30
	case ViolationScriptEscape:
		return 40
	case ViolationEconomyAbuse:
		return 50
	}
	return 0
}

type Profile struct {
	Creator        string
	Tier           Tier
	Violations     int
	Score          int
	CleanSince     time.Time
	SuspendedUntil time.Time
	Published      int
}

type Outcome struct {
	Tier           Tier
	Demoted        bool
	SuspendedUntil time.Time
	Escalated      bool
}

type Registry struct {
	mu       sync.Mutex
	policies map[Tier]Policy
	now      func() time.Time
	profiles map[string]*Profile
}

func NewRegistry(policies []Policy, now func() time.Time) (*Registry, error) {
	if len(policies) != 3 {
		return nil, fmt.Errorf("%w: need all three tiers", ErrBadPolicy)
	}
	r := &Registry{policies: make(map[Tier]Policy, 3),
		profiles: make(map[string]*Profile)}
	for _, p := range policies {
		if err := p.validate(); err != nil {
			return nil, err
		}
		if _, ok := r.policies[p.Tier]; ok {
			return nil, fmt.Errorf("%w: duplicate tier %s", ErrBadPolicy, p.Tier)
		}
		r.policies[p.Tier] = p
	}
	if len(r.policies) != 3 {
		return nil, fmt.Errorf("%w: missing tier", ErrBadPolicy)
	}
	if now == nil {
		now = time.Now
	}
	r.now = now
	return r, nil
}

func (r *Registry) Register(creator string, tier Tier) (Profile, error) {
	if creator == "" {
		return Profile{}, ErrEmptyCreator
	}
	if !tier.valid() {
		return Profile{}, fmt.Errorf("%w: %d", ErrBadPolicy, tier)
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.profiles[creator]; ok {
		return Profile{}, ErrDuplicateCreator
	}
	p := &Profile{Creator: creator, Tier: tier, CleanSince: r.now()}
	r.profiles[creator] = p
	return *p, nil
}

几个设计点:

  • Policy.validate 在构造注册表时执行。危险能力出现在任何级别都会让启动失败,而不是留到运行时。
  • world.write.global 只允许官方。它是最容易被“临时放开”的能力,也是影响面最大的一个。
  • 策略里的配额全部要求为正数。配额为 0 在代码里容易被解释成“无限”,而运营通常理解成“禁止”,两种语义不能混用。
  • 注册是设置级别的唯一入口;升级只发生在 runtime.go 的 Promote,而且只允许普通到认证。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/contenttrust
$ go test -count=1 ./internal/contenttrust/
ok  	gsarchlab/internal/contenttrust	0.419s

6 个测试函数覆盖策略安全、配额与审核、账本路由、违规降级和升级条件;耗时是本机一次运行结果,不应当作性能指标。

失败模式与兜底

失败表现兜底
重新注册绕过降级新账号重新获得认证实名/设备/支付多维关联
新账号重置配额换号继续上传配额按主体而非账号
能力通过数据夹带脚本借地图参数越权意图二次校验
违规分数被清零反复小额违规不受罚分数按时间窗口累计
暂停期间换号继续发布内容主体级暂停

最后两项都指向同一个设计原则:信任的主体是人或团队,不是账号。 账号只是登录凭证;配额、违规和暂停如果只挂在账号上,换号就能重置。

常见错误

第一,把地图 JSON 校验通过当成“内容安全”。数据合法不代表脚本行为合法。

第二,给脚本数据库连接或经济服务客户端。只要脚本能直接调用,白名单就只是装饰。

第三,交易和分成留在地图运行时。余额会出现多个版本,退款和税务无法处理。

第四,升级条件只看上传数量。没有干净记录和观察期,认证会迅速贬值。

小结

UGC 的信任模型可以压缩成一句话:服务端信任经过校验的数据,不信任玩家提交的逻辑;逻辑只能在沙箱里产生意图,意图再由宿主按级别、能力和配额执行;经济与社交的外溢一律走平台账本。级别、配额和审核强度是策略,不是硬编码在脚本运行时里的 if。

本节的可运行产物是 internal/contenttrust:contenttrust.go 定义级别、能力和策略校验,runtime.go 负责授权、违规降级与升级,共 6 个测试函数。下一节离开品类,进入部署:当这些服务真的要被切成部署单元时,边界应该按进程、按故障域,还是按团队来切。

阅读导航:上一节:10.2 UGC 地图的隔离、审核与发布 · 下一节:11.1 部署单元怎么切 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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