小游戏客户端云存档接入标准
适用:当前 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:
POST /api/projects/mgrsy6xpjrothyr9/login
Content-Type: application/json
{"code":"平台刚返回的code","platform":"wx"}
成功:HTTP 200。
{
"ok": true,
"token": "服务器签发的业务token",
"expiresAt": "2026-09-18T09:00:00.000Z",
"user": {"userId": "usr_example", "userInfo": {}}
}
后续存档请求必须携带:
Authorization: Bearer 服务器签发的业务token
小游戏使用业务 Bearer token,不使用管理员登录 Cookie。服务器根据 token 确定玩家,客户端不用提交 openid、userId;提交这些字段也不能改变存档归属。存档按项目、玩家、key 隔离;platform 必须保持一致,不能在 wx 和 tcmpp 之间随意切换,否则当前身份派生规则会产生不同 userId。
以返回的 expiresAt 为准安排重新登录,可提前留出时间;应用回到前台也应检查。当前没有小游戏 token refresh 接口,续期需要新的平台 code 再调用 login。并发请求只启动一个重新登录任务,其余等待。code 不可复用,即使首次请求超时也重新获取。重新登录后若 userId 改变,切换到该账号独立的本地状态,不能上传前一个玩家的存档。
4. 读取存档
GET /api/projects/mgrsy6xpjrothyr9/data/bfbxc_user_data
Authorization: Bearer <token>
首次无存档仍返回 HTTP 200,不是 404:
{
"ok": true,
"key": "bfbxc_user_data",
"exists": false,
"value": null,
"version": 0,
"updatedAt": null
}
已有存档返回 HTTP 200:
{
"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. 写入存档
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。
{
"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 对应响应:
{"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 仅为说明,应替换成游戏实际结构。
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 或完整玩家存档。
相关文档:接口总览、TCMPP 登录配置、部署运维。