客户端接入文档 v0.1
目标:首次登录 → 保存存档 → 退出并重新登录 → 读回同一存档;能处理会话过期和版本冲突。
本文覆盖 14 个核心操作。接口存在、契约通过、本地测试通过、development 联调通过是四个不同状态。当前尚未交付已验收的公网测试环境;不要把后台可登录当成玩家 API 已就绪。
1. 从后台取得公开参数
管理员进入「游戏配置」,选择游戏,再选择同一 environment 下的 iOS/Android App,打开应用配置。交给客户端的是 gameSlug、gameId、gameAppId、platform、appIdentifier,以及发布负责人提供的已验收 apiBaseUrl。客户端不需要后台 Access 凭据。
| 参数 | 来源与用途 | 不能替代它的字段 |
|---|---|---|
| apiBaseUrl | 玩家 API 的 HTTPS origin,不带 /v1;SDK 自己添加路径 | 管理后台地址、文档域名、content 域名 |
| gameSlug | 后台游戏 slug;bootstrap 和平台登录使用 | 显示名称 |
| gameId | 游戏 UUID;bootstrap.gameId、session.gameId 和后台游戏 ID 应一致 | gameSlug、gameAppId |
| gameAppId | 后台所选应用 UUID,即 catalog.game_apps.id;设备挑战中的 clientAppId | gameId、Bundle ID、Apple App ID |
| Bundle ID/包名 | iOS 的反向域名字符串/Android applicationId;登录字段分别为 bundleId/packageName | 内部 UUID |
| Apple App ID | App Store Connect 的数字应用 ID;后台 apple_app_id | Apple Team ID、Bundle ID、gameAppId |
| Apple Team ID | Apple 开发者团队标识;由后端核验配置 | App Store 数字应用 ID |
| installationId | 每个安装持久保存的随机 UUID,与设备私钥一起使用 | 玩家 ID、每请求随机 ID、广告 ID |
| deviceKeyId | 首次登录响应的设备注册 UUID,后续刷新使用 | gameAppId、installationId |
| gameAccountId | 登录响应的该游戏玩家 ID;同平台账号再次登录应恢复它 | gameId、平台原始玩家标识 |
iOS 与 Android App 分开登记;development 和 production 不混用会话、密钥或本地缓存。公开参数不是 Secret,也不是登录凭据。只有这些参数仍不足以完成平台登录:游戏必须配置 Game Center 或 Play Games,并接好平台证明、P-256 签名和安全存储适配器。
不要在客户端、文档、日志或 Git 中放 JWT 私钥、数据库连接串、OAuth client secret、Apple 私钥或后台凭据。
2. 中文快速开始
先由发布负责人交付真实 development 参数和就绪记录。文档已发布于 docs.apemotion.app;建议的玩家 API 域名 api-dev.apemotion.app 仍是候选地址,不是已上线承诺。OpenAPI 的 /v1 是相对于玩家 API origin,不是相对于文档站。
安装 Unity SDK 后从 Package Manager 导入「First Game」示例。初始化 FirstGameExample 时传入公开参数、ISecureStorage、IDeviceProofProvider 和 IPlatformIdentityAdapter。设备私钥应保存在 Keychain/Keystore,签名适配器输出 P-256 ES256 的 64 字节 r||s 签名的无填充 base64url,而不是 ASN.1 DER。
- GET /v1/public/games/{gameSlug}/bootstrap;检查维护状态、最低版本、environment、gameId。维护中停止登录/写入;低于 minimumClientVersion 先升级。使用 configVersion 获取公开游戏配置。
- 恢复持久 session。同一安装必须仍能使用原设备私钥。GET /v1/me 验证会话;过期时 SDK 单次串行刷新;刷新被拒绝则清理 session 并重新平台登录,不能无限重试。
- 首次登录先取得 Game Center 签名材料或 Play Games serverAuthCode,再申请 game_login 设备挑战、签名,发送平台登录。成功后持久保存整个 session,核对 gameId。
- GET /v1/games/{gameId}/saves/main。404 NOT_FOUND 只表示该槽不存在,此时新建本地进度并用 baseRevision=0 保存;不能把所有 404 都当成空存档。
- PUT 同一路径,携带 Idempotency-Key、当前 baseRevision、document 和 sha256。成功后保存响应 revision,不要继续用旧版本。
- POST /v1/auth/logout,成功后清理本地 token。同一平台账号重新登录,再 GET 同一槽,核对 gameAccountId、document 和 revision。
资料响应使用 display_fields,存档使用 inline_document、content_sha256、updated_at。请求使用 displayFields、baseRevision。不要自行把所有 JSON 字段统一转换成 camelCase。
3. 平台登录与设备签名
Game Center 需要 publicKeyUrl、signature、salt、timestamp、teamPlayerId、bundleId,可选 gamePlayerId。timestamp 是平台原始整数时间戳,不是 ISO 字符串。这里不是 Sign in with Apple。Android 需要 serverAuthCode、packageName,可选 recallSessionId;serverAuthCode 必须对应后端登记的 server/Web OAuth client。
登录请求还需要 gameSlug、installationId、devicePublicJwk、authRequestId 和三个设备证明字段。完整字段定义和约束见接口检索,字段均由共享 Zod 契约生成。
设备签名步骤:
- 按请求 schema 得到语义请求(补齐 JWK 的 alg=ES256、ext=true;去掉未知字段)。移除 deviceChallengeId、deviceChallengeNonce、deviceProof 三项;其余字段不得变动。
- 对语义对象递归按当前协议排序键并紧凑序列化,SHA-256 得到小写十六进制 semanticRequestHash。认证字段为 ASCII,数字 timestamp 用整数。以仓库 canonicalJson 和 SDK 实现为准;不得把整段原始 HTTP JSON 当作签名输入。
- POST /v1/auth/device-challenges,传 operation=game_login、installationId、platform、devicePublicJwk、clientAppId=gameAppId。刷新时改 game_refresh,并加 session.deviceKeyId。
- 签名以下 UTF-8 文本,行间是 LF,末尾没有换行;追加三个证明字段后发送登录/刷新。
ape-device-proof-v1
{operation}
{challengeId}
{nonce}
{semanticRequestHash}
挑战响应为 HTTP 201,nonce 在登录请求中叫 deviceChallengeNonce。挑战一次性使用,有效期以 expiresAt 为准。网络超时可能已消费挑战,每次重发都要申请新挑战,但不能变更同一次尝试的语义字段。
4. 幂等、过期和重试
| 场景 | 保留的内容 | 下一步 |
|---|---|---|
| 普通写入超时/503 | 原 Idempotency-Key、请求体、baseRevision、originAccountLifecycleId | 有限指数退避并加抖动,原样重发;跨重启需安全持久化待提交操作 |
| 登录超时 | authRequestId、幂等键、平台证明及其余语义字段 | 只更新设备挑战和签名后重发;不能把已兑换 auth code 用在新尝试中 |
| 平台证明需重签 | 不再复用旧兑换尝试 | EXTERNAL_IDENTITY_PROOF_REISSUE_REQUIRED 后向平台取新证明、新 authRequestId 和新键 |
| refresh 超时 | 旧 refreshToken、refreshRequestId、installationId、deviceKeyId、JWK | 只换挑战和签名;未知结果时持久保留该尝试,不能生成新 ID 再用旧 token |
| 401 AUTHENTICATION_REQUIRED | 待提交写入保持原键和请求体 | single-flight 刷新一次并重发;刷新 401 或重发仍 401 时要求重新登录 |
| 409 IDEMPOTENCY_IN_PROGRESS | 原键和请求体 | 有限退避后重试,不新建写入 |
| 409 REVISION_CONFLICT | 保留未同步本地 document | 重新读取远端,由游戏合并或让用户选择,然后用最新 revision、新键提交 |
| 409 IDEMPOTENCY_CONFLICT | 保留诊断 requestId | 停止:同一键对应了不同请求;检查客户端逻辑 |
| 429 RATE_LIMITED | 当前账号、安装身份保持不变 | 指数退避+抖动,不能轮换安装标识;当前 API 不保证 Retry-After |
| 403/422 | 保留诊断 requestId | 修正 scope、平台配置或校验和;不盲目重试 |
Idempotency-Key 为 8–128 个可打印 ASCII 字符,推荐 UUID。设备挑战和 refresh 不要求此 header;refresh 的去重标识在请求体。GET 不要求幂等键。
accessToken 和 refreshToken 只能保存在安全存储;不要放 PlayerPrefs。会话过期依据 expiresAt,并参考 Date 响应头矫正设备时钟。刷新成功后原子替换 session,旧 refreshToken 不可用于另一 refreshRequestId,否则会撤销整个会话家族。
5. 存档和版本冲突
首版使用 JSON inline 存档。document 必须是对象,服务端以 JSON.stringify(document) 的 UTF-8 字节计算 SHA-256;上限是 128 KiB,hash 必须 64 位小写十六进制。这里不是认证语义对象的排序哈希。
跨语言序列化不是天然一致的。首版示例将游戏状态编码为 document.payload 字符串,避免浮点、指数、数值键排序、日期或自定义 Newtonsoft converter 导致 .NET 与 JavaScript 输出不同。复杂对象直接存入 document 前必须增加跨语言校验测试;不要修改服务端已有哈希语义来绕过不一致。
更新用读到的 revision 作为 baseRevision。并发两个写入只允许一个成功,另一个返回 REVISION_CONFLICT;客户端不得吞掉冲突、递增猜测 revision 或自动覆盖。PATCH profile 的 displayFields 合并行为由服务端实现决定,客户端也必须使用最新 revision。
超时不等于失败,已提交的存档可通过同一键重放获得结果。切换账号时冻结旧账号待同步操作,绝不把旧账号文档写入新账号。不要将 object_key 当作可直接下载的 URL;大文件上传协议不在本版接入范围。
6. 错误结构
失败返回 application/problem+json,包括 type、title、status、code、requestId,可选 detail、details。依据 code 和 HTTP status 分支,不匹配英文 detail。记录 requestId、操作名、HTTP status 和 code;不记录 token、签名、平台证明或存档正文。所有错误码和状态映射由现有 ApiProblem 生成在接口页面。
7. 可用范围
| 领域 | v0.1 文档状态 | 运行验收状态 |
|---|---|---|
| 配置、设备挑战、登录/刷新/退出、玩家资料、inline 存档 | 核心契约、示例与 CI 覆盖 | 本地验证与公网验收分开记录 |
| 运动、排行榜、邮件 | 仅接口目录,待逐项补齐 | 不承诺可直接接入 |
| 购买、账号合并 | 复杂证明与跨服务依赖未纳入首版 | 不承诺可直接接入 |
| 导出、删除 | 部分契约已存在,完整客户端教程待补齐 | 不承诺可直接接入 |
| 大文件存档、资源更新 | 已有 SDK 能力,非首版登录/存档路径 | 需专项验收 |
8. 首个游戏验收清单
- 文档构建、OpenAPI 一致性、请求/响应 fixture、真实路由响应测试通过;后台能打开客户端文档,下载 OpenAPI 和示例。
- 发布负责人提供已确认参数和真实 development API。先验证 DNS/TLS、health、bootstrap;连续与并发玩家请求不得出现跨请求 I/O 错误。
- 在真机使用真实平台测试账号登录,记录脱敏 requestId、gameAccountId、gameId;保存测试存档并记录 revision。
- 退出、重新平台登录,gameAccountId 不变,读取相同存档;重新启动应用也能恢复会话。
- 等待/测试控制使 access token 过期,确认只刷新一次;撤销会话后应提示重新登录而不是死循环。
- 两个客户端读同一 revision 后分别写入,验证一方 409,未同步本地数据仍保留;用户确认合并后才提交。
- 模拟响应丢失,重试同一写入不增加额外 revision;刷新响应丢失后仍沿用同一个 refreshRequestId。
仅本地 mock、静态 SDK 检查或 Worker dry-run 不能勾选真机/公网验收。真实环境的剩余输入和发布门禁由 development-launch-inputs.md 管理;本文不授权放宽门禁。