脚本不是数据
UGC 地图开放一个月后,《长风原》的运营发现两类异常:一张认证创作者的地图脚本在玩家进入时调用经济接口,试图给自己发放道具;另一张地图的脚本打开外部网络连接,把房间信息发到第三方地址。
两张地图都通过了静态校验和沙箱运行。问题不在脚本写错了,而在服务端把“玩家提交的逻辑”和“玩家提交的数据”放在了同一个信任级别:地图 JSON 被 schema 校验,脚本却被当成第一方代码执行。
这一节给出一个可以落地的边界:服务端信任数据,不信任逻辑。 数据可以校验、可以拒绝;逻辑只能隔离、观察和限权。
三级信任模型
北辰把 UGC 创作者分成三级。级别决定能力、配额和审核强度,不决定运行时权限;运行时权限永远由能力白名单单独判定。
| 级别 | 对象 | 能力 | 脚本/资源/实体 | 上传/日 | 在线地图 | 审核 |
|---|---|---|---|---|---|---|
| 官方 | 内部团队 | 全部内容能力,含全局写入 | 4 MiB / 256 MiB / 20k | 200 | 100 | 自动 |
| 认证 | 签约或历史合规创作者 | 读世界、交互、UI | 1 MiB / 64 MiB / 5k | 20 | 10 | 抽样 |
| 普通 | 所有玩家 | 读世界、交互、UI | 256 KiB / 16 MiB / 1k | 5 | 3 | 强制 |
表中的数字是示例策略值,不是实测结果;真实阈值要按沙箱成本、审核人力和内容类型调整。级别不是永久身份:违规会降级,普通创作者在满足条件后可以升到认证,但认证到官方永远是人审决定,不自动升级。
只信数据,不信逻辑
| 输入 | 例子 | 服务端做什么 | 失败时 |
|---|---|---|---|
| 数据 | 地图 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 一旦允许交易、分成或付费地图,经济状态就不能留在地图运行时里。原因不是技术偏好,而是三条硬约束:
- 审计与防作弊。 每一笔收支都要能回答“谁、何时、为什么”;脚本可以伪造余额,平台账本只接受校验过的意图。
- 合规。 分成涉及税务、结算和地区规则,不能由某个地图进程决定。
- 跨地图一致性。 玩家在 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 部署单元怎么切 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。