# 小游戏客户端云存档接入标准

适用：当前 ZYMIX 后台；核对日期：2026-09-11。本文面向小游戏客户端开发人员，示例使用 Number Colour。字段、状态码以当前部署实现为准。

## 1. 接入参数

| 参数 | Number Colour 当前值 |
|---|---|
| baseUrl | `http://192.168.1.200:18080` |
| appId | `mgrsy6xpjrothyr9` |
| platform | `wx`（本项目 TCMPP SDK 使用 wx 接口） |
| 存档 key | `bfbxc_user_data` |
| 登录方法 | `POST /api/projects/mgrsy6xpjrothyr9/login` |
| 读取方法 | `GET /api/projects/mgrsy6xpjrothyr9/data/bfbxc_user_data` |
| 保存方法 | `PUT /api/projects/mgrsy6xpjrothyr9/data/bfbxc_user_data` |

当前地址供局域网联调。正式分发使用配置好的 HTTPS 服务域名及小游戏平台合法请求域名。

客户端只保存 baseUrl、appId 和业务 token。不要把 AppSecret、后台管理员账号或平台 session_key 放进游戏。游戏请求本后台的 login，后台读取项目配置中的 OpenServer 完整地址去换码。

## 2. 必须遵循的调用顺序

1. 调用平台 `wx.login` 获取新的 code。
2. 将 code 提交给后台 login，成功后保存 token、expiresAt、user.userId。
3. 携带 token 读取存档。只有读取成功，才能确定存档是否存在及当前 version。
4. `exists:false` 时创建本地初始状态；`exists:true` 时校验并加载 `value`。
5. 保存时提交完整 `value` 和最近一次读取或保存成功得到的 `version`。
6. 保存成功后立即更新客户端 version，再允许同一 key 的下一次保存。

登录失败、读取失败与“首次没有存档”是三种不同状态。网络失败或 401 时不得假设 version=0，更不能把默认存档上传覆盖已有进度。可继续离线游玩，但必须保留本地待同步状态，恢复身份并读取云端后再处理合并。

## 3. 自动登录与身份

请求不需要已有 token：

```http
POST /api/projects/mgrsy6xpjrothyr9/login
Content-Type: application/json

{"code":"平台刚返回的code","platform":"wx"}
```

成功：HTTP 200。

```json
{
  "ok": true,
  "token": "服务器签发的业务token",
  "expiresAt": "2026-09-18T09:00:00.000Z",
  "user": {"userId": "usr_example", "userInfo": {}}
}
```

后续存档请求必须携带：

```http
Authorization: Bearer 服务器签发的业务token
```

小游戏使用业务 Bearer token，不使用管理员登录 Cookie。服务器根据 token 确定玩家，客户端不用提交 openid、userId；提交这些字段也不能改变存档归属。存档按项目、玩家、key 隔离；platform 必须保持一致，不能在 wx 和 tcmpp 之间随意切换，否则当前身份派生规则会产生不同 userId。

以返回的 expiresAt 为准安排重新登录，可提前留出时间；应用回到前台也应检查。当前没有小游戏 token refresh 接口，续期需要新的平台 code 再调用 login。并发请求只启动一个重新登录任务，其余等待。code 不可复用，即使首次请求超时也重新获取。重新登录后若 userId 改变，切换到该账号独立的本地状态，不能上传前一个玩家的存档。

## 4. 读取存档

```http
GET /api/projects/mgrsy6xpjrothyr9/data/bfbxc_user_data
Authorization: Bearer <token>
```

首次无存档仍返回 HTTP 200，不是 404：

```json
{
  "ok": true,
  "key": "bfbxc_user_data",
  "exists": false,
  "value": null,
  "version": 0,
  "updatedAt": null
}
```

已有存档返回 HTTP 200：

```json
{
  "ok": true,
  "key": "bfbxc_user_data",
  "exists": true,
  "value": {"schemaVersion": 1, "level": 12, "coins": 80},
  "version": 3,
  "updatedAt": "2026-09-11T09:00:00.000Z"
}
```

`schemaVersion` 是推荐由游戏自行管理的存档格式版本，用于数据结构迁移；顶层 `version` 是服务器维护的并发控制版本。两者不是一回事。

| 响应字段 | 说明 |
|---|---|
| ok | 成功为 true；先检查 HTTP 状态和 ok，再读取业务字段 |
| exists | 仅 GET 返回；以它判断是否存在，不能用 value 的真假判断 |
| value | 游戏保存的 JSON 值；推荐对象。接口也支持数组、字符串、数字、布尔和 null |
| version | 无存档为 0；已有存档从 1 开始，每次成功保存增加 1 |
| updatedAt | 仅 GET 返回；最后保存时间，无存档为 null |

**HTTP 响应 JSON 的业务字段直接在顶层。** 使用 wx.request 时通常是 `res.data.value`、`res.data.version`；不要读取 `res.data.data.value`。如果你的网络封装已经返回了响应体，则读取 `body.value`。

## 5. 写入存档

```http
PUT /api/projects/mgrsy6xpjrothyr9/data/bfbxc_user_data
Content-Type: application/json
Authorization: Bearer <token>

{"version":3,"value":{"schemaVersion":1,"level":13,"coins":90}}
```

成功：HTTP 200。

```json
{
  "ok": true,
  "key": "bfbxc_user_data",
  "value": {"schemaVersion": 1, "level": 13, "coins": 90},
  "version": 4
}
```

客户端把 version 更新为 **响应中的 4**，下一次保存提交 4。不要自己预先递增，也不要每次固定传 0。PUT 成功响应没有 exists、updatedAt，不要依赖这两个字段。

| 约束 | 标准 |
|---|---|
| version | 必填 JSON 数字，非负安全整数；`"3"` 字符串、null、负数、小数、缺失均不合法 |
| value | 必须有这个字段；undefined 会在 JSON 序列化时被丢弃；显式 null 合法，但会保存一个存在且值为 null 的记录 |
| key | 非空，最长 128 字符；推荐只用字母、数字、下划线、连字符，例如 bfbxc_user_data |
| 请求体 | JSON 对象，整体最多 1 MiB；推荐显著小于此值 |
| 写入语义 | 整个 value 替换；不是字段级 PATCH，不会自动合并 |
| 删除 | 当前没有存档 DELETE 接口；写入 null 不等于删除，也不会把 version 重置为 0 |
| 持久化 | MySQL 事务提交成功后才返回成功；Redis 负责登录态和配置缓存，云存档本体存于 MySQL |

金币等内容只是玩家提交的存档，不应当作服务端可信余额或发奖凭据。

## 6. 428、409 与重试

截图中的 HTTP 428 对应响应：

```json
{"ok":false,"code":"data_version_required","message":"data_version_required"}
```

这表示 PUT 缺少合法的顶层 version。**它不是登录失败。** 应改成 `{version: 已知版本, value: 存档对象}`。首次必须先 GET；确认 exists=false 后才用 version=0 创建。

| HTTP | code / 情况 | 客户端处理 |
|---|---|---|
| 400 | data_value_required | 请求缺少 value，修复序列化或请求结构 |
| 400 | data_key_invalid / 非法 JSON | 修复 key 或 JSON；非法 JSON 当前 code 为 request_error |
| 401 | request_error / session_expired | token 缺失、错误、过期或会话不存在；保留本地存档，重新登录后再读取 |
| 401 | project_disabled | 重新核对项目状态，不无限循环登录 |
| 404 | project_not_found / not_found | 检查 AppID、项目启用状态、路径；不当作无存档 |
| 409 | data_version_conflict | 当前版本已变化；重新 GET，再按游戏业务规则处理差异 |
| 413 | request_too_large | 请求体过大，缩减存档 |
| 428 | data_version_required | 修复 version 缺失或类型；不盲目重试 |
| 429 | 限流 | 合并保存请求，退避并加入随机抖动；不要紧密循环 |
| 502 / 504 | 登录换码上游错误 / 超时 | 暂停云同步，记录响应 code 和数字 providerCode；重试登录需新平台 code |
| 503 / 网络超时 | 服务不可用 / 请求结果未知 | 保留待同步状态，恢复后先读取云端，确认是否已经提交 |

同一玩家同一 key 的 PUT 必须串行。建议合并频繁变化、在关卡完成等关键点保存，避免每帧上传。切后台请求可能来不及完成，应先保存本地副本并保留未同步标记。

两个设备都读到 version=3 时，只有一个 PUT 能成功并得到 4；另一个返回 409。冲突不会返回最新 value，必须重新 GET。不要仅换成新 version 就重发旧 value，否则仍会覆盖另一设备的进度。关卡解锁、消耗品、奖励等字段需要各自的合并规则；无法自动合并时让玩家选择存档。

PUT 超时也可能已经写入成功。不要把“没收到成功响应”等同于“没保存”；先 GET，结合待提交快照判断。当前没有存档写入幂等请求 ID；必要时由游戏在 value 中保存自己的操作标识辅助核对，但服务器不会替它执行幂等检查。

## 7. JSON.parse 与客户端参考代码

截图里的 `Unexpected token u in JSON at position 0` 通常是对 undefined 或字符串 `"undefined"` 调用了 JSON.parse。它是客户端异常，不能用取消鉴权或固定 version 解决。

规则：只在收到的内容是 JSON 字符串时解析。对象不要重复 JSON.parse。响应失败不进入存档解析；exists=false 时使用默认状态。历史存档如果保存为字符串，可以做一次明确的兼容解析；解析失败应保留原始本地副本并报告，不能默认清档后立即上传。

下面示例展示请求层、登录、读取和单次保存。调用方必须按顺序 await，同一 key 不可并发 save；冲突/离线合并及本地备份由游戏业务层处理。示例中的 level、coins、schemaVersion 仅为说明，应替换成游戏实际结构。

```js
const BASE_URL = 'http://192.168.1.200:18080';
const APP_ID = 'mgrsy6xpjrothyr9';
const KEY = 'bfbxc_user_data';
let token = '';
let version = null; // null = 尚未成功读取；不能默认为 0

function request(method, path, data, authenticated = true) {
  return new Promise((resolve, reject) => {
    wx.request({
      url: BASE_URL + path,
      method,
      header: {
        'content-type': 'application/json',
        ...(authenticated ? { Authorization: 'Bearer ' + token } : {})
      },
      ...(data === undefined ? {} : { data }),
      success(res) {
        let body;
        try {
          body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
        } catch (_) {
          reject(new Error('invalid_response_json'));
          return;
        }
        if (res.statusCode < 200 || res.statusCode >= 300 || !body || body.ok !== true) {
          const error = new Error(body?.message || 'request_failed');
          error.status = res.statusCode;
          error.code = body?.code;
          error.details = body?.details;
          reject(error);
          return;
        }
        resolve(body); // 返回体本身，不再套一层 data
      },
      fail: reject
    });
  });
}

async function login() {
  token = '';
  version = null;
  const platformResult = await new Promise((resolve, reject) => {
    wx.login({ success: resolve, fail: reject });
  });
  if (!platformResult.code) throw new Error('platform_code_missing');
  const body = await request('POST', '/api/projects/' + APP_ID + '/login', {
    code: platformResult.code, platform: 'wx'
  }, false);
  token = body.token;
  return { userId: body.user.userId, expiresAt: body.expiresAt };
}

const savePath = '/api/projects/' + APP_ID + '/data/' + encodeURIComponent(KEY);

async function load() {
  version = null;
  const body = await request('GET', savePath);
  // 仅示例：游戏应按自己的 schema 完整校验及迁移。
  let value = body.exists ? body.value : { schemaVersion: 1, level: 1, coins: 0 };
  if (body.exists && typeof value === 'string') value = JSON.parse(value);
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('invalid_save_schema');
  }
  version = body.version; // 数据校验通过后才启用保存
  return value;
}

async function save(value) {
  if (version === null) throw new Error('load_required_before_save');
  try {
    const body = await request('PUT', savePath, { version, value });
    version = body.version;
    return body.value;
  } catch (error) {
    version = null; // 冲突或结果未知后，禁止继续使用旧版本写入
    throw error; // 调用方保留待同步快照，读取后按业务规则合并
  }
}

// 游戏启动时由调用方捕获异常：
// await login();
// const state = await load();
// state.level += 1;
// await save(state);
// 下一次 save 必须等待上一次结束。
```

## 8. 客户端联调验收清单

| 场景 | 应观察到的结果 |
|---|---|
| 新玩家首次进入 | login 200 → GET 200 exists=false/version=0 → PUT 200/version=1 |
| 已有玩家再次进入 | GET 取得之前 value 和 version，不用默认状态覆盖 |
| 连续两次保存 | 按响应版本依次提交，版本递增，无 428 |
| 缺失 version | PUT 428，保留本地进度并明确记录错误 |
| 另一设备修改 | PUT 409，重新 GET 并执行已定义的冲突策略 |
| token 失效 | 暂停云写入、单次重新登录、读取后恢复同步 |
| 断网或 PUT 超时 | 本地进度保留；恢复后先核对云端，不直接盲写 |
| 无存档 / JSON 对象 | 不触发 JSON.parse(undefined) 或重复解析 |
| 换玩家 | 本地副本按 appId/platform/userId/key 隔离，不串档 |

排障只记录方法、路径、HTTP 状态、响应 code、providerCode、存档 version 和脱敏上下文；不打印 token、平台 code、AppSecret 或完整玩家存档。

相关文档：[接口总览](/docs)、[TCMPP 登录配置](/docs/tcmpp)、[部署运维](/docs/operations)。
