# Cloud Archive Unity 开发者指南

这份文档面向 Unity 游戏开发者，说明如何在 Unity 工程中接入和调用 `IGP.UnitySDK.CloudArchive`。

本文包含：

- 安装和初始化。
- `IGPCloudArchive` 方法和 desktop session forward payload 的对应关系。
- 固定槽位、存档数据、`version` / `baseVersion` 的使用规则。
- 成功响应字段和 C# DTO 定义方式。
- 错误码、归一化错误体、冲突处理和重试规则。

## 1. 接入前提

导入包：

```text
cn.indiegp.sdk.unity-<version>.unitypackage
cn.indiegp.sdk.unity.cloud-archive-<cloud-archive-version>.unitypackage
```

场景配置：

1. 一个 `IGPRuntimeManager`。
2. `Assets/IGP/IGPConfig.asset` 已填写 `appId`。
3. 游戏启动流程里调用过 `await runtimeManager.InitializeAsync()`。
4. desktop 可用，并且用户已登录。
5. desktop attach 后能力集里包含 `cloudArchive == true`。

`IGPCloudArchive` 是静态 API，不作为 Unity 组件挂载到 GameObject。

调用链路：

```text
Unity 游戏
 -> IGPCloudArchive
 -> IGPRuntimeManager
 -> desktop session
 -> Cloud Archive API
```

Cloud Archive 调用通过 `IGPCloudArchive -> IGPRuntimeManager -> desktop session` 完成；用户认证、访问令牌和 HTTP 转发由 desktop 处理。SDK 不直接请求公开 API，也不管理用户 token。

## 2. 最小示例

第一次接入时，建议先固定使用 `IGPCloudArchive.Slot1`，确认读取、保存和冲突处理链路能跑通。

```csharp
using UnityEngine;
using IGP.UnitySDK;
using IGP.UnitySDK.CloudArchive;

public sealed class CloudArchiveBootstrap : MonoBehaviour
{
    [SerializeField] private IGPRuntimeManager runtimeManager;

    private string version;

    private async void Start()
    {
        runtimeManager ??= GetComponent<IGPRuntimeManager>();

        var initialized = await runtimeManager.InitializeAsync();
        if (!initialized)
        {
            Debug.LogError("[CloudArchive] IGP SDK 初始化失败。");
            return;
        }

        try
        {
            var loaded = await IGPCloudArchive.LoadCloudArchiveAsync(
                runtimeManager,
                IGPCloudArchive.Slot1);

            version = loaded.version;
            Debug.Log($"[CloudArchive] data={loaded.data}, version={version}");
        }
        catch (IGPCloudArchiveException ex)
        {
            Debug.LogError(
                $"[CloudArchive] code={ex.Code}, http={ex.HttpStatus}, apiCode={ex.ApiCode}, message={ex.Message}");
        }
    }
}
```

保存时传入业务已经序列化好的字符串：

```csharp
var save = await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    "{\"hp\":10}",
    new IGPCloudArchiveSaveOptions
    {
        baseVersion = version,
    });

version = save.version;
```

## 3. API 和 DTO

Cloud Archive 模块导出固定 DTO：

| 类型 | 用途 |
| --- | --- |
| `IGPCloudArchiveLoadResult` | 读取存档成功响应 |
| `IGPCloudArchiveSaveResult` | 保存存档成功响应 |
| `IGPCloudArchiveSaveOptions` | 保存时的可选参数 |
| `IGPCloudArchiveException` | desktop / API 失败时抛出的异常 |
| `IGPCloudArchiveForwardErrorBody` | desktop 归一化后的上游错误体 |
| `IGPCloudArchiveErrorBody` | 旧版 desktop / API error envelope 兼容形状 |
| `IGPCloudArchiveConflictData` | 409 冲突时的当前版本数据 |

读取结果：

```csharp
public sealed class IGPCloudArchiveLoadResult
{
    public string data;
    public string version;
}
```

保存结果：

```csharp
public sealed class IGPCloudArchiveSaveResult
{
    public string slot;
    public int size;
    public string version;
    public string updatedAt;
}
```

保存选项：

```csharp
public sealed class IGPCloudArchiveSaveOptions
{
    public string baseVersion;
}
```

字段说明：

| 字段 | 说明 |
| --- | --- |
| `data` | 游戏自己的存档字符串。SDK 不解析内容 |
| `slot` | 固定槽位，取值 `"1"` 到 `"5"` |
| `size` | 服务端记录的存档大小 |
| `version` | 服务端返回的不透明版本字符串 |
| `updatedAt` | 服务端更新时间 |
| `baseVersion` | 上一次读取或保存返回的版本，用于并发冲突检测 |

## 4. SDK 如何构造请求

`IGPCloudArchive` 会把你的调用转成 desktop named command：

```ts
command: 'cloudArchiveForward'
contentJson: JSON.stringify(payload)
```

读取 payload：

```json
{
  "method": "GET",
  "slot": "1"
}
```

保存 payload：

```json
{
  "method": "PUT",
  "slot": "1",
  "body": {
    "data": "{\"hp\":10}",
    "baseVersion": "etag-v1"
  }
}
```

字段含义：

| 字段 | 说明 |
| --- | --- |
| `method` | `GET` 或 `PUT` |
| `slot` | 固定存档槽位，不是文件名 |
| `appId` | 可选；通常不需要传，desktop 使用已 attach 的 appId |
| `body.data` | 保存内容，必须是 string |
| `body.baseVersion` | 可选；首次创建可省略，后续保存建议传入 |

常规接入不需要手动传 `appId`。如果传入 `appIdOverride`，desktop 会要求它与当前 attach 的 appId 匹配。

## 5. 固定槽位规则

Cloud Archive 只有 5 个固定槽位：

| C# 常量 | 实际值 | 说明 |
| --- | --- | --- |
| `IGPCloudArchive.Slot1` | `"1"` | 存档槽 1 |
| `IGPCloudArchive.Slot2` | `"2"` | 存档槽 2 |
| `IGPCloudArchive.Slot3` | `"3"` | 存档槽 3 |
| `IGPCloudArchive.Slot4` | `"4"` | 存档槽 4 |
| `IGPCloudArchive.Slot5` | `"5"` | 存档槽 5 |

注意：

- `slot` 不是文件名。
- 不支持 `"main"`、`"auto-save"`、`"save/main"` 这类自定义名字。
- 不会 trim 输入；`"1"` 合法，`" 1"` 和 `"1 "` 都非法。
- SDK 会在发送 desktop command 之前先校验槽位。

## 6. 读取存档

```csharp
var result = await IGPCloudArchive.LoadCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1);

var data = result.data;
var version = result.version;
```

成功响应没有统一外层 envelope。`IGPCloudArchiveLoadResult` 对应的是 API 成功 body：

```json
{
  "data": "{\"hp\":10}",
  "version": "etag-v1"
}
```

如果槽位不存在，desktop 会返回 API 非 2xx 结果，SDK 抛出 `IGPCloudArchiveException`。常见情况是 API code `10004`。

## 7. 保存存档

首次创建槽位时可以省略 `baseVersion`：

```csharp
var save = await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    "{\"hp\":10}");
```

更新已有槽位时，建议传入上一次读取或保存返回的 `version`：

```csharp
var save = await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    "{\"hp\":11}",
    new IGPCloudArchiveSaveOptions
    {
        baseVersion = version,
    });

version = save.version;
```

成功响应：

```json
{
  "slot": "1",
  "size": 9,
  "version": "etag-v2",
  "updatedAt": "2026-06-10T00:00:00.000Z"
}
```

`version` 是不透明字符串。不要解析它，也不要假设它是递增数字、时间戳或 etag 格式。

## 8. JSON 存档写法

`data` 是字符串。SDK 不帮你把对象转 JSON，也不检查 JSON 内容。

推荐做法：

```csharp
var saveData = new PlayerSaveData
{
    hp = 10,
    level = 3,
};

var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(saveData);

await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    serialized,
    new IGPCloudArchiveSaveOptions
    {
        baseVersion = version,
    });
```

读取后再由游戏自己反序列化：

```csharp
var loaded = await IGPCloudArchive.LoadCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1);

var saveData = Newtonsoft.Json.JsonConvert.DeserializeObject<PlayerSaveData>(loaded.data);
```

如果你的游戏存档不是 JSON，也可以传任意字符串，例如压缩后再 base64 的内容。服务端只把它当作不透明字符串。

## 9. 冲突处理

当保存时传入的 `baseVersion` 过旧，或者首次创建时槽位已经存在，API 可能返回 409 / `10005`。

典型处理：

```csharp
try
{
    var save = await IGPCloudArchive.SaveCloudArchiveAsync(
        runtimeManager,
        IGPCloudArchive.Slot1,
        serializedSave,
        new IGPCloudArchiveSaveOptions
        {
            baseVersion = version,
        });

    version = save.version;
}
catch (IGPCloudArchiveException ex) when (ex.HttpStatus == 409 || ex.ApiCode == 10005)
{
    var currentVersion = ex.Conflict?.currentVersion;

    // 1. 重新读取最新云端存档。
    var latest = await IGPCloudArchive.LoadCloudArchiveAsync(
        runtimeManager,
        IGPCloudArchive.Slot1);

    // 2. 游戏自行合并本地和云端数据。
    var merged = MergeSave(latest.data, serializedSave);

    // 3. 用最新 version 再保存。
    var retry = await IGPCloudArchive.SaveCloudArchiveAsync(
        runtimeManager,
        IGPCloudArchive.Slot1,
        merged,
        new IGPCloudArchiveSaveOptions
        {
            baseVersion = latest.version,
        });

    version = retry.version;
}
```

合并策略由游戏决定。SDK 只暴露足够的错误数据，不会自动覆盖云端或本地存档。

## 10. 错误处理

`IGPCloudArchiveException` 会保留 desktop 或 API 返回的错误码、错误消息和上游错误体。

常用属性：

| 属性 | 说明 |
| --- | --- |
| `Code` | desktop command result 的错误码，例如 `CLOUD_ARCHIVE_HTTP_409` |
| `Message` | desktop 或 API 错误消息 |
| `BodyJson` | desktop 返回的原始 `contentJson` |
| `ForwardBody` | desktop 归一化后的上游错误体 |
| `HttpStatus` | HTTP 状态码；本地校验错误可能为 null |
| `ApiCode` | API code 的 int 形式；如果 API 返回字符串且不能转 int，可能为 null |
| `ApiCodeValue` | API code 原始值，可能是 number 或 string |
| `ApiMessage` | API 错误消息 |
| `ApiData` | API 错误 data；409 时通常包含 `currentVersion` |
| `RawBody` | 原始 API error body，用于诊断 |
| `Body` | 旧版 API error envelope 兼容字段 |
| `Conflict` | 解析后的 `currentVersion` |

示例：

```csharp
try
{
    await IGPCloudArchive.SaveCloudArchiveAsync(
        runtimeManager,
        IGPCloudArchive.Slot1,
        serializedSave,
        new IGPCloudArchiveSaveOptions { baseVersion = version });
}
catch (IGPCloudArchiveException ex)
{
    if (ex.HttpStatus == 409 || ex.ApiCode == 10005)
    {
        Debug.LogWarning($"云存档冲突，currentVersion={ex.Conflict?.currentVersion}");
    }
    else if (ex.HttpStatus.HasValue && ex.HttpStatus.Value >= 500)
    {
        // 可按业务策略稍后重试。
    }
    else if (ex.Code == "CLOUD_ARCHIVE_INVALID_SLOT" ||
             ex.Code == "CLOUD_ARCHIVE_INVALID_BODY" ||
             ex.Code == "CLOUD_ARCHIVE_BODY_TOO_LARGE")
    {
        // desktop 本地校验失败，通常应修正调用或减小 payload。
    }

    Debug.LogError(
        $"code={ex.Code}, http={ex.HttpStatus}, apiCode={ex.ApiCodeValue}, message={ex.Message}, body={ex.BodyJson}");
}
```

## 11. 错误体形状

desktop 转发 API 非 2xx 时，新的错误体是归一化形状：

```json
{
  "httpStatus": 409,
  "apiCode": 10005,
  "apiMessage": "stale version",
  "apiData": {
    "currentVersion": "etag-v3"
  },
  "rawBody": {
    "success": false,
    "code": 10005,
    "message": "stale version",
    "data": {
      "currentVersion": "etag-v3"
    }
  }
}
```

SDK 优先使用：

- `httpStatus`
- `apiCode`
- `apiMessage`
- `apiData`
- `rawBody`

为了兼容旧 desktop，SDK 也能解析旧 API envelope：

```json
{
  "success": false,
  "code": 10005,
  "message": "stale version",
  "data": {
    "currentVersion": "etag-v3"
  }
}
```

desktop 本地校验错误可能没有 `contentJson`：

```text
Code = CLOUD_ARCHIVE_INVALID_BODY
Message = invalid body
BodyJson = null 或空字符串
```

这种情况下 `HttpStatus`、`ApiCode`、`ApiData` 通常为空，游戏应按调用错误处理。

## 12. 常见错误码

API 错误：

| API code | 常见原因 | 处理建议 |
| --- | --- | --- |
| `10001` | 参数错误 | 检查 slot、data、baseVersion |
| `10002` | 用户未授权或登录状态失效 | 等待用户登录或重新 attach |
| `10004` | 槽位不存在 | 首次存档时改走保存；读取时提示无云端存档 |
| `10005` | 版本冲突或槽位已存在 | 重新读取、合并、用最新 version 保存 |
| `40002` | payload 过大 | 压缩、裁剪或减少存档内容 |

desktop / SDK 侧错误：

| 错误码 | 来源 | 处理建议 |
| --- | --- | --- |
| `DESKTOP_SESSION_CAPABILITY_MISSING` | desktop session | 当前 desktop 不支持 Cloud Archive 或能力未开启 |
| `DESKTOP_SESSION_REQUIRED` | SDK / desktop session | 初始化或 attach 未完成，确认 `InitializeAsync()` |
| `CLOUD_ARCHIVE_INVALID_SLOT` | desktop 本地校验 | 只使用 `"1"` 到 `"5"` |
| `CLOUD_ARCHIVE_INVALID_BODY` | desktop 本地校验 | 确认 PUT body 里有 string 类型 `data` |
| `CLOUD_ARCHIVE_BODY_TOO_LARGE` | desktop 本地校验 | 减小存档字符串大小 |
| `CLOUD_ARCHIVE_HTTP_<status>` | API | API 返回非 2xx，查看 `ApiCode` / `ApiData` |

## 13. 保存版本和重试规则

`baseVersion` 的规则：

- 第一次创建槽位时可以省略。
- 后续更新建议传入最近一次 load/save 得到的 `version`。
- 发生 409 后，不要继续拿旧 `baseVersion` 盲重试。
- 409 后应重新读取、合并，再用最新 `version` 保存。

网络或 5xx 重试：

- 如果 save 请求超时或返回 5xx，游戏可以按业务策略重试。
- 重试同一份存档时，保持 `data` 和 `baseVersion` 不变。
- 如果重试后得到 409，说明云端已经变化，切换到冲突处理流程。

不要这样做：

```csharp
// 不建议：忽略冲突，直接不带 baseVersion 覆盖。
await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    serializedSave);
```

推荐这样做：

```csharp
// 推荐：更新已有槽位时带上最近的 version。
await IGPCloudArchive.SaveCloudArchiveAsync(
    runtimeManager,
    IGPCloudArchive.Slot1,
    serializedSave,
    new IGPCloudArchiveSaveOptions
    {
        baseVersion = version,
    });
```

## 14. 使用规则

初始化顺序：

```csharp
await runtimeManager.InitializeAsync();
await IGPCloudArchive.LoadCloudArchiveAsync(runtimeManager, IGPCloudArchive.Slot1);
```

槽位：

```csharp
// 使用 SDK 常量，不使用自定义文件名。
IGPCloudArchive.Slot1
```

存档内容：

```csharp
// data 是 string；对象先由游戏序列化。
string data = Newtonsoft.Json.JsonConvert.SerializeObject(saveObject);
```

更新已有存档：

```csharp
new IGPCloudArchiveSaveOptions
{
    baseVersion = version,
}
```

冲突：

```csharp
catch (IGPCloudArchiveException ex) when (ex.HttpStatus == 409 || ex.ApiCode == 10005)
```

错误日志：

```csharp
Debug.LogError($"code={ex.Code}, http={ex.HttpStatus}, apiCode={ex.ApiCodeValue}, body={ex.BodyJson}");
```

## 15. 接入检查清单

- 已导入 `cn.indiegp.sdk.unity`。
- 已导入 `cn.indiegp.sdk.unity.cloud-archive`。
- 场景里有 `IGPRuntimeManager`。
- `IGPConfig.appId` 已填写。
- 游戏启动流程已调用 `InitializeAsync()`。
- desktop 可用且用户已登录。
- desktop attach 后 `cloudArchive` capability 为 true。
- Cloud Archive 调用发生在初始化成功之后。
- 只使用 `IGPCloudArchive.Slot1` 到 `Slot5`。
- `data` 已由游戏序列化成 string。
- 更新已有槽位时传入最近一次返回的 `version` 作为 `baseVersion`。
- 409 冲突时重新读取、合并、用最新 version 保存。
- 错误日志保留 `IGPCloudArchiveException.Code`、`HttpStatus`、`ApiCodeValue` 和 `BodyJson`。
