《移动游戏产品的架构·客户端与移动运行时》5.3 策划工作流:从表格到线上

配置错误是移动游戏线上事故的主要来源之一,而多数事故并非算错数值,而是绕过了流水线。本节把导入、校验、评审、灰度、发布与回滚写成带准入门槛的状态机,用前向迁移处理结构变化、用上一版产物处理回滚,让差异报告决定评审人数,并把审计记录绑定到版本与差异。

事故:周五下午改了一行配置

案例设定里,方舟有一次周五下午的紧急修复:策划在表格里改了一行活动时间,跳过评审直接让配置平台发布。表格里的时间列是本地时区文本,导出器按服务器时区解析,活动比预期提前结束。更麻烦的是,回滚时有人试图把新表「反向迁移」回旧结构,结果卡牌枚举被改回旧值,反而让新客户端读不了。

这次事故暴露的不是某个字段写错,而是流程缺口:导入没有固定入口,校验只是发布按钮旁边的一个提示,评审没有差异可看,回滚没有可恢复的上一版产物。配置错误之所以危险,是因为它不需要发版就能触达全量玩家,而代码缺陷至少还有构建和商店审核两道缓冲。

流水线:六个状态与准入门槛

把配置发布写成状态机,关键不是状态名称,而是每个状态的准入条件和拒绝后的动作。状态只能前进,回滚是唯一的向后动作,并且必须留下原因。

状态准入条件拒绝后的动作
imported表格已导出为带 schema 版本与数据版本的产物保留原始文件,修正导出器
validated5.2 的校验报告零错误退回导入,不允许「先发再修」
reviewed差异报告已生成,评审人数满足风险等级留在 validated,补齐评审
canary评审通过,灰度样本与指标门禁已配置留在 reviewed,不得直接全量
live灰度判据为 promote回到 canary 或执行回滚
rolled-back有上一版 live 产物与回滚原因人工介入,不允许自动猜版本

状态机还要记录审计:谁在什么时间、因为什么原因推动了状态。没有审计的流水线只能证明「系统允许发布」,不能证明「这次发布被谁批准」。审计记录同时也是复盘材料,能区分「校验漏了」和「校验报了但被人忽略」。

前向迁移,反向回滚

配置结构变化用迁移解决,配置内容回退用产物解决,这是两条不同的路。

动作方向输入结果失败处理
迁移旧 schema → 新 schema旧结构产物新结构产物缺步骤就失败,不猜默认值
回滚新产物 → 上一版产物上一版 live 产物恢复旧内容没有上一版就拒绝,不反向迁移

迁移必须是前向的、确定性的、不修改输入的纯函数。每个步骤只负责一个版本跨度,v1 → v3 由 v1 → v2 和 v2 → v3 串起来;缺少任何一步就报错,而不是让调用方「大概补一下」。这样同一份旧配置在任何机器上都能得到同一份新配置,评审和复现才有意义。

回滚不执行反向迁移。反向迁移要处理信息丢失、枚举收回和时间字段还原,任何一处猜错都会制造第二次事故。正确做法是保留上一版 live 产物,回滚时直接切回;如果上一版不存在,就明确失败,让人工决定。兼容窗口的意义也在这里:旧产物在窗口内仍然能被旧客户端读取,回滚才有退路。

评审看差异,不看表格

评审最常见的失效方式是「打开表格看一眼」。几百行的表,人眼只能看到被高亮的那几行,看不到删除了什么、哪些引用因此悬空。流水线应该先算差异,再让人评审差异。

差异按表拆成新增、删除、修改三类行 id。删除任何一行都算高风险,因为可能有别的表正在引用它;卡牌表和活动表的修改也算高风险,因为它们直接影响战斗与奖励。高风险变更要求两位不同的评审者,低风险变更一位即可。同一个人重复点击「通过」不算第二票。

评审记录要和差异绑定:评审的是哪一版差异,就必须在审计里留下对应的变更行数。否则事后无法回答「当时看的是不是这一版」。

可运行模型:version-migrate.ts

下面的 version-migrate.ts 实现三件事:按版本链做前向迁移、按行 id 计算差异并判定风险、用状态机约束发布与回滚。时钟通过 Clock 注入,测试可以精确检查审计时间;模型不接触真实配置平台。

// file: src/config/version-migrate.ts
// tier: L1
// verify: node --test test/config/version-migrate.test.ts

import type { Clock } from "../core/clock.ts";
import { fail, ok, type Result } from "../core/result.ts";
import type { ConfigTableName } from "./schema.ts";
import { CONFIG_TABLES, type ConfigRow, type ConfigSet, type ValidationReport } from "./table.ts";

export interface Migration {
  readonly from: number;
  readonly to: number;
  readonly migrate: (table: ConfigTableName, row: ConfigRow) => ConfigRow;
}

function renamed(row: ConfigRow, from: string, to: string): ConfigRow {
  const next: Record<string, unknown> = { ...row };
  if (!(from in next)) return next;
  next[to] = next[from];
  delete next[from];
  return next;
}

export const ARK_MIGRATIONS: readonly Migration[] = [
  {
    from: 1,
    to: 2,
    migrate(table, row) {
      if (table === "level") return renamed(row, "enemies", "enemyCardIds");
      if (table === "card") return renamed(row, "power", "attack");
      return { ...row };
    },
  },
  {
    from: 2,
    to: 3,
    migrate(table, row) {
      const next: Record<string, unknown> = { ...row };
      if (table === "card" && next.rarity === "normal") next.rarity = "common";
      if (table === "activity" && typeof next.enabled === "boolean") {
        next.status = next.enabled ? "live" : "draft";
        delete next.enabled;
      }
      return next;
    },
  },
];

export function migrationPath(from: number, to: number, migrations: readonly Migration[] = ARK_MIGRATIONS): Result<readonly Migration[], string> {
  if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < 1) {
    return fail("invalid-version", `versions must be positive integers: v${from} -> v${to}`);
  }
  if (to < from) {
    return fail("no-downgrade", `cannot migrate v${from} down to v${to}; restore the previous artifact instead`);
  }
  const path: Migration[] = [];
  let current = from;
  while (current < to) {
    const step = migrations.find((candidate) => candidate.from === current && candidate.to <= to);
    if (step === undefined) return fail("missing-migration", `no migration step from v${current} toward v${to}`);
    path.push(step);
    current = step.to;
    if (path.length > migrations.length) return fail("migration-loop", "migration graph does not terminate");
  }
  return ok(path);
}

export function migrateSet(set: ConfigSet, targetVersion: number, migrations: readonly Migration[] = ARK_MIGRATIONS): Result<ConfigSet, string> {
  const path = migrationPath(set.schemaVersion, targetVersion, migrations);
  if (!path.ok) return fail(path.error, path.reason);
  let tables: Readonly<Record<ConfigTableName, readonly ConfigRow[]>> = set.tables;
  for (const step of path.value) {
    const next: Record<ConfigTableName, readonly ConfigRow[]> = { level: [], card: [], activity: [] };
    for (const table of CONFIG_TABLES) next[table] = tables[table].map((row) => step.migrate(table, row));
    tables = next;
  }
  return ok({ ...set, schemaVersion: targetVersion, tables });
}

export type DiffRisk = "low" | "high";
export interface TableDiff { readonly table: ConfigTableName; readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[]; }
export interface BundleDiff { readonly tables: readonly TableDiff[]; readonly risk: DiffRisk; readonly changedRows: number; }
export interface DiffPolicy { readonly highRiskTables: readonly ConfigTableName[]; }

export const ARK_DIFF_POLICY: DiffPolicy = { highRiskTables: ["card", "activity"] };

function rowKey(row: ConfigRow): string {
  return typeof row.id === "string" ? row.id : "<missing-id>";
}

function canonical(row: ConfigRow): string {
  const keys = Object.keys(row).sort();
  return JSON.stringify(keys.map((key) => [key, row[key]]));
}

export function diffSets(before: ConfigSet, after: ConfigSet, policy: DiffPolicy = ARK_DIFF_POLICY): BundleDiff {
  const tables = CONFIG_TABLES.map((table) => {
    const beforeRows = new Map(before.tables[table].map((row) => [rowKey(row), row]));
    const afterRows = new Map(after.tables[table].map((row) => [rowKey(row), row]));
    const added: string[] = [];
    const removed: string[] = [];
    const changed: string[] = [];
    for (const [id, row] of afterRows) {
      const previous = beforeRows.get(id);
      if (previous === undefined) added.push(id);
      else if (canonical(previous) !== canonical(row)) changed.push(id);
    }
    for (const id of beforeRows.keys()) if (!afterRows.has(id)) removed.push(id);
    return { table, added: added.sort(), removed: removed.sort(), changed: changed.sort() };
  });
  const changedRows = tables.reduce((sum, table) => sum + table.added.length + table.removed.length + table.changed.length, 0);
  const highRisk = tables.some(
    (table) => table.removed.length > 0 || (policy.highRiskTables.includes(table.table) && table.added.length + table.changed.length > 0),
  );
  return { tables, risk: highRisk ? "high" : "low", changedRows };
}

export type ReleaseState = "imported" | "validated" | "reviewed" | "canary" | "live" | "rolled-back";
export interface AuditEntry { readonly state: ReleaseState; readonly atMs: number; readonly actor: string; readonly note: string; }

export class ReleasePipeline {
  readonly #clock: Clock;
  readonly #version: number;
  readonly #previousLiveVersion: number | null;
  readonly #history: AuditEntry[] = [];
  readonly #reviewers = new Set<string>();
  #state: ReleaseState = "imported";

  constructor(clock: Clock, version: number, previousLiveVersion: number | null = null) {
    if (!Number.isInteger(version) || version < 1) throw new RangeError(`config version must be a positive integer: ${version}`);
    this.#clock = clock;
    this.#version = version;
    this.#previousLiveVersion = previousLiveVersion;
    this.#history.push({ state: "imported", atMs: clock.nowMs(), actor: "import", note: `config v${version} imported` });
  }

  get state(): ReleaseState { return this.#state; }
  get version(): number { return this.#version; }
  get previousLiveVersion(): number | null { return this.#previousLiveVersion; }
  get history(): readonly AuditEntry[] { return [...this.#history]; }

  #record(state: ReleaseState, actor: string, note: string): void {
    this.#state = state;
    this.#history.push({ state, atMs: this.#clock.nowMs(), actor, note });
  }

  #note(actor: string, note: string): void {
    this.#history.push({ state: this.#state, atMs: this.#clock.nowMs(), actor, note });
  }

  validate(report: ValidationReport, actor = "ci"): Result<ReleaseState, string> {
    if (this.#state !== "imported") return fail("illegal-transition", `validate requires imported, got ${this.#state}`);
    if (!report.ok) return fail("validation-failed", `refusing a report with ${report.errorCount} errors`);
    this.#record("validated", actor, `${report.errorCount} errors`);
    return ok(this.#state);
  }

  review(reviewer: string, diff: BundleDiff): Result<ReleaseState, string> {
    if (this.#state !== "validated") return fail("illegal-transition", `review requires validated, got ${this.#state}`);
    const name = reviewer.trim();
    if (name.length === 0) return fail("reviewer-required", "reviewer must not be empty");
    if (this.#reviewers.has(name)) return fail("duplicate-reviewer", `${name} has already reviewed this release`);
    this.#reviewers.add(name);
    const required = diff.risk === "high" ? 2 : 1;
    if (this.#reviewers.size < required) {
      this.#note(name, `review ${this.#reviewers.size}/${required}; risk=${diff.risk}`);
      return ok(this.#state);
    }
    this.#record("reviewed", name, `${this.#reviewers.size} reviewers; risk=${diff.risk}; ${diff.changedRows} changed rows`);
    return ok(this.#state);
  }

  startCanary(actor = "release"): Result<ReleaseState, string> {
    if (this.#state !== "reviewed") return fail("illegal-transition", `canary requires reviewed, got ${this.#state}`);
    this.#record("canary", actor, "canary started");
    return ok(this.#state);
  }

  promote(actor = "release"): Result<ReleaseState, string> {
    if (this.#state !== "canary") return fail("illegal-transition", `promote requires canary, got ${this.#state}`);
    this.#record("live", actor, `config v${this.#version} is live`);
    return ok(this.#state);
  }

  rollback(actor: string, reason: string): Result<number, string> {
    if (this.#state !== "canary" && this.#state !== "live") {
      return fail("illegal-transition", `rollback requires canary or live, got ${this.#state}`);
    }
    if (reason.trim().length === 0) return fail("reason-required", "rollback needs a recorded reason");
    if (this.#previousLiveVersion === null) return fail("no-previous-release", "no previous live version to roll back to");
    this.#record("rolled-back", actor, `rollback to v${this.#previousLiveVersion}: ${reason}`);
    return ok(this.#previousLiveVersion);
  }
}

配套测试 test/config/version-migrate.test.ts 覆盖 13 条路径:迁移链、缺步骤、禁止降级、输入不可变、差异风险、双人评审、非法状态跳转和无上一版时的回滚拒绝。运行命令是 node --test test/config/version-migrate.test.ts。

双案例:周更活动与双周版本

消消以周活动为主,配置结构稳定,流水线可以更轻:一位评审、当天灰度、失败就切回上周产物。它的关键是把「改完就发」变成「校验通过才发」。

方舟每两周一个版本、每周还有活动,卡牌与关卡的引用关系更深,高风险变更需要两位评审。它的回滚目标不是「把表改回去」,而是「切回上一版已验收的配置产物」。

与相邻章节的关系

5.1 定义 schema 与兼容窗口,5.2 提供校验报告与灰度判据,本节把两者串成可审计的发布流程。存档结构的迁移可以参考存档格式与迁移 ,区域化配置的发布可以参考区域化配置与内容分级 ;配置驱动数据表的分工可以参考配置驱动的数据表 。

小结

  • 配置发布是状态机:每个状态有准入门槛,回滚是唯一向后动作且必须记录原因。
  • 迁移只向前,回滚靠上一版产物;反向迁移会把信息丢失变成第二次事故。
  • 迁移函数必须是确定性纯函数,缺步骤就失败,不猜默认值。
  • 评审要基于差异报告:删除行、卡牌与活动变更属于高风险,需要两位不同评审者。
  • 审计记录要绑定版本、差异与原因,才能区分「系统允许」和「有人批准」。

阅读导航:上一节:5.2 校验、引用完整性与灰度 · 下一节:6.1 资源句柄、引用计数与生命周期 。

常见错误与边界

  • 跳过评审直接发布。配置不需要发版就能触达全量玩家,跳过评审等于把线上当测试环境;流水线应把评审设为硬门槛。
  • 用反向迁移做回滚。反向迁移要还原已丢失的信息,猜错会制造第二次事故;回滚应切回上一版产物。
  • 迁移函数修改输入对象。同一份旧配置被迁移两次会得到不同结果,评审和复现都失去意义;迁移必须是纯函数。
  • 把「同一个账号点了两次通过」当成两位评审。高风险变更需要不同的人独立评审;重复评审者应被拒绝。
  • 回滚时临时挑一个版本号。没有上一版 live 产物就应失败并转人工,临时猜版本会把未知配置推给玩家。
  • 边界:本节不实现配置平台 UI、权限系统与真实审批流。状态机是模型,企业内的审批人、权限与留痕需要与平台账号体系对接。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查