> For the complete documentation index, see [llms.txt](https://docs.convai.com/api-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/advanced-topics/custom-providers/custom-persistence-provider.md).

# 自定义持久化提供器

将默认会话存储替换为你自己的远程服务、加密文件存储或用于测试的内存实现。

Convai Unity SDK 会将会话数据——连接状态、会话 ID、恢复令牌以及编辑器最终用户 GUID——存储在 `PlayerPrefs` 默认通过 `PlayerPrefsKeyValueStore`。如果 `PlayerPrefs` 如果这对你的部署已够用，你就不需要本页。当你需要云存档、加密存储、服务端会话管理或用于自动化测试和 CI 的隔离存储时，请替换持久化提供程序。

### SDK 会写入存储的内容

| 键前缀                  | 内容                | 重要性                                                      |
| -------------------- | ----------------- | -------------------------------------------------------- |
| `convai.session.*`   | 会话 ID 和恢复令牌       | 允许 SDK 在不中断 AI 轮次的情况下恢复断开的会话                             |
| `convai.end_user_id` | 仅限编辑器的设备 GUID（后备） | 由以下使用 `DeviceEndUserIdProvider` 在 Unity 编辑器中，当硬件 ID 不可用时 |
| `convai.prefs.*`     | 用户偏好设置（例如静音状态）    | 在应用启动之间持久保存 SDK 级设置                                      |

替换持久化提供程序会替换所有这些内容的读写位置。你的实现必须处理 SDK 触及的每一个键——下面的适配器模式可确保不会遗漏任何内容。

### 持久化接口

#### IKeyValueStore — 简单存储

```csharp
namespace Convai.Domain.Abstractions
{
    public interface IKeyValueStore
    {
        string GetString(string key, string defaultValue = null);
        void   SetString(string key, string value);
        bool   HasKey(string key);
        void   DeleteKey(string key);
        void   Save();
    }
}
```

`Save()` 在写入操作后调用。对于内存存储，这是空操作；对于文件后端存储，它会刷新到磁盘。在本地存储场景中，如果不需要异步操作，请实现此接口。

#### IPersistenceProvider — 功能完整的存储

```csharp
namespace Convai.Runtime.Core.Providers
{
    public interface IPersistenceProvider
    {
        // 同步读取
        string GetString(string key, string defaultValue = null);
        int    GetInt(string key, int defaultValue = 0);
        float  GetFloat(string key, float defaultValue = 0f);
        bool   GetBool(string key, bool defaultValue = false);
        bool   HasKey(string key);

        // 同步写入
        PersistenceResult SetString(string key, string value, PersistenceOptions options = default);
        PersistenceResult SetInt(string key, int value, PersistenceOptions options = default);
        PersistenceResult SetFloat(string key, float value, PersistenceOptions options = default);
        PersistenceResult SetBool(string key, bool value, PersistenceOptions options = default);
        PersistenceResult Delete(string key);
        PersistenceResult DeleteAll(string prefix);

        void Save();

        // 异步操作
        IConvaiOperation<PersistenceResult>   SyncAsync(CancellationToken ct = default);
        IConvaiOperation<PersistenceResult>   SaveVersionedAsync<T>(VersionedKey key, T value,
                                                  ConflictResolutionStrategy strategy = ConflictResolutionStrategy.LastWriteWins,
                                                  CancellationToken ct = default);
        IConvaiOperation<VersionedValue<T>>   LoadVersionedAsync<T>(string ns, string key, CancellationToken ct = default);
        IConvaiOperation<PersistenceResult>   MigrateAsync(int fromVersion, int toVersion, CancellationToken ct = default);
    }
}
```

`builder.UsePersistence()` 接受 `IPersistenceProvider`. 如果你的实现是 `IKeyValueStore`，请将其包装在适配器中（参见 [适配器模式](#adapter-pattern-for-ikeyvaluestore-implementations) 下文）。异步操作（`SyncAsync`, `SaveVersionedAsync`）如果你的后端是同步的，可以返回占位结果。

**应实现哪个接口：**

| 场景                            | 实现                      |
| ----------------------------- | ----------------------- |
| 本地文件、加密 SQLite、内存中            | `IKeyValueStore`        |
| 云存档、服务端存储、多设备同步               | `IPersistenceProvider`  |
| 现有 `IKeyValueStore` 并在其上添加云同步 | 两者都支持——将同步/版本化操作委托给云客户端 |

### 支持类型

#### PersistenceResult

| 成员                                     | 类型         | 说明                           |
| -------------------------------------- | ---------- | ---------------------------- |
| `Success`                              | `bool`     | 操作是否成功。                      |
| `错误消息`                                 | `string`   | 错误描述（如果 `Success` 是 `false`. |
| `时间戳`                                  | `DateTime` | 操作完成时间。                      |
| `版本`                                   | `long`     | 操作后的版本号（适用于版本化操作）。           |
| `PersistenceResult.Succeeded(version)` | static     | 创建成功结果。                      |
| `PersistenceResult.Failed(error)`      | static     | 创建失败结果。                      |

#### ConflictResolutionStrategy

由以下使用 `SaveVersionedAsync` 用于在异步/云场景中解决写入冲突。

| 值                    | 行为                  |
| -------------------- | ------------------- |
| `LastWriteWins`      | 基于时间戳，最近写入的值获胜。     |
| `HighestVersionWins` | 版本号更高的值获胜。          |
| `LocalWins`          | 本地数据始终覆盖远端。         |
| `RemoteWins`         | 远端数据始终覆盖本地。         |
| `手动`                 | 将冲突信息返回给调用方，以便显式解决。 |

#### PersistenceOptions

```csharp
var options = new PersistenceOptions(
    conflictPolicy:  ConflictResolutionPolicy.LastWriteWins,
    createIfMissing: true,
    maxRetries:      3
);
```

### 实现示例

#### 内存存储（测试 / CI）

适用于自动化测试和 CI 运行，在这些场景下，运行之间的持久状态会破坏结果。

```csharp
// InMemoryKeyValueStore.cs
using System.Collections.Generic;
using Convai.Domain.Abstractions;

public class InMemoryKeyValueStore : IKeyValueStore
{
    private readonly Dictionary<string, string> _store = new();

    public string GetString(string key, string defaultValue = null)
        => _store.TryGetValue(key, out string v) ? v : defaultValue;

    public void SetString(string key, string value)
        => _store[key] = value;

    public bool HasKey(string key)
        => _store.ContainsKey(key);

    public void DeleteKey(string key)
        => _store.Remove(key);

    public void Save() { /* 内存存储无需操作 */ }
}
```

#### 加密文件存储

满足禁止明文存储的合规要求 `PlayerPrefs` 用于会话数据。

```csharp
// EncryptedFileKeyValueStore.cs
using System.Collections.Generic;
using System.IO;
using Convai.Domain.Abstractions;
using UnityEngine;

public class EncryptedFileKeyValueStore : IKeyValueStore
{
    private readonly string _filePath;
    private readonly IEncryptionService _encryption;
    private Dictionary<string, string> _cache = new();

    public EncryptedFileKeyValueStore(string fileName, IEncryptionService encryption)
    {
        _filePath   = Path.Combine(Application.persistentDataPath, fileName);
        _encryption = encryption;
        Load();
    }

    public string GetString(string key, string defaultValue = null)
        => _cache.TryGetValue(key, out string v) ? v : defaultValue;

    public void SetString(string key, string value) => _cache[key] = value;
    public bool HasKey(string key)                  => _cache.ContainsKey(key);
    public void DeleteKey(string key)               => _cache.Remove(key);

    public void Save()
    {
        string json      = JsonUtility.ToJson(new SerializableDictionary(_cache));
        string encrypted = _encryption.Encrypt(json);
        File.WriteAllText(_filePath, encrypted);
    }

    private void Load()
    {
        if (!File.Exists(_filePath)) return;
        string encrypted = File.ReadAllText(_filePath);
        string json      = _encryption.Decrypt(encrypted);
        _cache           = JsonUtility.FromJson<SerializableDictionary>(json)?.ToDictionary()
                           ?? new Dictionary<string, string>();
    }
    // SerializableDictionary 辅助类为简洁起见省略。
}
```

调用 `Save()` 在每次写入后，或定期刷新。写入会缓存在内存中——自上次 `Save()` 会在崩溃时丢失。

### 适用于 IKeyValueStore 实现的适配器模式

`builder.UsePersistence()` 需要 `IPersistenceProvider`。使用此适配器来包装任何 `IKeyValueStore`:

```csharp
// KeyValueStorePersistenceAdapter.cs
using System.Threading;
using Convai.Domain.Abstractions;
using Convai.Runtime.Core.Async;
using Convai.Runtime.Core.Providers;

public class KeyValueStorePersistenceAdapter : IPersistenceProvider
{
    private readonly IKeyValueStore _store;

    public KeyValueStorePersistenceAdapter(IKeyValueStore store) => _store = store;

    public string GetString(string key, string defaultValue = null)
        => _store.GetString(key, defaultValue);

    public int GetInt(string key, int defaultValue = 0)
    {
        string raw = _store.GetString(key);
        return raw != null && int.TryParse(raw, out int v) ? v : defaultValue;
    }

    public float GetFloat(string key, float defaultValue = 0f)
    {
        string raw = _store.GetString(key);
        return raw != null && float.TryParse(raw, out float v) ? v : defaultValue;
    }

    public bool GetBool(string key, bool defaultValue = false)
    {
        string raw = _store.GetString(key);
        return raw != null && bool.TryParse(raw, out bool v) ? v : defaultValue;
    }

    public bool HasKey(string key) => _store.HasKey(key);

    public PersistenceResult SetString(string key, string value, PersistenceOptions options = default)
    { _store.SetString(key, value); return PersistenceResult.Succeeded(); }

    public PersistenceResult SetInt(string key, int value, PersistenceOptions options = default)
    { _store.SetString(key, value.ToString()); return PersistenceResult.Succeeded(); }

    public PersistenceResult SetFloat(string key, float value, PersistenceOptions options = default)
    { _store.SetString(key, value.ToString("G")); return PersistenceResult.Succeeded(); }

    public PersistenceResult SetBool(string key, bool value, PersistenceOptions options = default)
    { _store.SetString(key, value.ToString()); return PersistenceResult.Succeeded(); }

    public PersistenceResult Delete(string key)
    { _store.DeleteKey(key); return PersistenceResult.Succeeded(); }

    public PersistenceResult DeleteAll(string prefix)
        => PersistenceResult.Failed("此后端不支持 DeleteAll。");

    public void Save() => _store.Save();

    // 异步存根——同步后端返回即时结果。
    public IConvaiOperation<PersistenceResult> SyncAsync(CancellationToken ct = default)
        => ConvaiOperation.Succeeded(PersistenceResult.Succeeded());

    public IConvaiOperation<PersistenceResult> SaveVersionedAsync<T>(VersionedKey key, T value,
        ConflictResolutionStrategy strategy = ConflictResolutionStrategy.LastWriteWins,
        CancellationToken ct = default)
        => ConvaiOperation.Succeeded(PersistenceResult.Failed("此后端不支持版本化操作。"));

    public IConvaiOperation<VersionedValue<T>> LoadVersionedAsync<T>(string ns, string key, CancellationToken ct = default)
        => ConvaiOperation.Succeeded(VersionedValue<T>.NotFound);

    public IConvaiOperation<PersistenceResult> MigrateAsync(int fromVersion, int toVersion, CancellationToken ct = default)
        => ConvaiOperation.Succeeded(PersistenceResult.Succeeded());
}
```

`DeleteAll(string prefix)` 在此适配器中会返回失败结果。SDK 会调用 `DeleteAll` 在会话重置操作期间。如果你的部署需要完整的会话重置，请实现 `DeleteAll` 通过遍历你的存储键并移除匹配该前缀的项。

### 注册提供器

```csharp
// CustomPersistenceManager.cs
using Convai.Runtime.Components;
using Convai.Runtime.Core;

public class CustomPersistenceManager : ConvaiManager
{
    [SerializeField] private bool _useEncryptedStorage = true;

    protected override ConvaiRuntimeBuilder CreateRuntimeBuilder()
    {
        ConvaiRuntimeBuilder builder = base.CreateRuntimeBuilder();

        if (_useEncryptedStorage)
        {
            IEncryptionService encryption = new AesEncryptionService();
            var store = new EncryptedFileKeyValueStore("convai_session.dat", encryption);
            builder.UsePersistence(new KeyValueStorePersistenceAdapter(store));
        }

        return builder;
    }
}
```

### 故障排除

| 症状                                                       | 可能原因                                  | 修复方法                                                     |
| -------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------- |
| 重启后会话无法恢复                                                | `GetString` 返回 `null` 在重新加载时检查会话键     | 请确保 `Save()` 会在应用退出前同步调用。订阅 `Application.quitting` 移动过去。 |
| `NullReferenceException` 在……内部 `IPersistenceProvider` 实现 | 在存储初始化之前调用异步方法                        | 在提供程序的构造函数中初始化底层存储，在 `调用 UsePersistence()` 之前调用。         |
| 崩溃时数据丢失                                                  | `SetString` 写入会缓存在内存中，并且 `Save()` 未调用 | 调用 `Save()` 在每次写入后，或者通过定期计时器刷新。                          |
| 会话重置未清除所有 SDK 数据                                         | `DeleteAll(prefix)` 在适配器中返回失败结果       | 实现 `DeleteAll` 通过遍历你的存储键集合并移除前缀匹配的条目。                    |

### 下一步

{% content-ref url="/pages/b6e1d5b82a92f7950701bd2576065db6c0349005" %}
[凭证、身份和存储](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/advanced-topics/custom-providers.md)
{% endcontent-ref %}

{% content-ref url="/pages/007b237f8ee6f8a26b307152ee716a33905cd6da" %}
[运行时模块系统](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/advanced-topics/extending-the-sdk.md)
{% endcontent-ref %}

{% content-ref url="/pages/c94f3576730a705ef8d7adec01d712a12612b542" %}
[实现自定义模块](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/advanced-topics/implement-a-custom-module.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/advanced-topics/custom-providers/custom-persistence-provider.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
