# Unity TokenSDK 使用说明

离线包 v3：导出无需设备 ID，支持限期/永久，导入后可直接离线使用。首次成功联网时根据 SDK 配置的 Project Key 认领项目；其他副本联网后吊销该包授权。未联网副本无法即时吊销，永久副本不会自然到期。需升级 Native SDK；C# 导入方法与 C ABI 不变。`offline_only=true` 禁止认领联网请求。完整规则见仓库 `docs/offline-v3.md`。本次 Windows DLL 支持 v3，其他平台旧二进制不支持该协议，需重新构建。

本目录是 Unity 原生插件发布包，包含 C# P/Invoke 绑定、UGUI 示例、C ABI 头文件和按平台/架构分离的原生库。Unity 通过同一个逻辑库名 `licsdk` 调用所有平台；Rust 导出 ABI 不因平台改变。

`release-manifest.json` 是本次构建的真实产物清单。只有 `status` 为 `available` 的目标才可以放入 Unity 工程；`not-built` 表示发布机构缺少对应 Rust target、链接器或 Android NDK，不能用其他平台的库替代。

## 1. 添加 SDK 文件和原生库

将 `LicSdkNative.cs`、`LicSdkClient.cs` 和 `LicenseDemoUI.cs` 复制到 Unity 工程的 `Assets` 目录，并把 `SDK/Plugins` 下与你的 Unity Build Target 和 CPU 架构匹配的库复制到工程的 `Assets/Plugins`。不要把不同架构的库放进同一个插件目录。

| 目标平台/架构 | 原生库 | Unity 插件路径 | 状态 |
|---|---|---|
| Windows x64 | `Plugins/Windows/x86_64/licsdk.dll` | `Assets/Plugins/x86_64/licsdk.dll` | 已提供（ABI 2） |
| Windows x86 | `Plugins/Windows/x86/licsdk.dll` | `Assets/Plugins/x86/licsdk.dll` | 已提供（ABI 2） |
| macOS Apple Silicon | `Plugins/macOS/liblicsdk.dylib` | `Assets/Plugins/macOS/liblicsdk.dylib` | 已提供（ABI 2） |
| macOS Intel | `Plugins/macOS/x86_64/liblicsdk.dylib` | `Assets/Plugins/macOS/x86_64/liblicsdk.dylib` | 已提供（ABI 2） |
| Linux x64 | `Plugins/Linux/x86_64/liblicsdk.so` | `Assets/Plugins/x86_64/liblicsdk.so` | 暂不制作| 
| Linux arm64 | `Plugins/Linux/arm64/liblicsdk.so` | `Assets/Plugins/ARM64/liblicsdk.so` | 暂不制作| 
| Android arm64-v8a | `Plugins/Android/arm64-v8a/liblicsdk.so` | `Assets/Plugins/Android/arm64-v8a/liblicsdk.so` | 暂不制作| 
| Android armeabi-v7a | `Plugins/Android/armeabi-v7a/liblicsdk.so` | `Assets/Plugins/Android/armeabi-v7a/liblicsdk.so` | 暂不制作| 
| Android x86_64 | `Plugins/Android/x86_64/liblicsdk.so` | `Assets/Plugins/Android/x86_64/liblicsdk.so` | 暂不制作|

C ABI 版本为 **2**。`LicSdkClient` 在初始化时会校验 `lic_sdk_abi_version()`，与原生库不一致会直接抛错，必须整体升级 C# 封装与原生库。当前发布包中的 Windows 与 macOS 库均为 ABI 2；打包脚本会优先从当前源码重新构建，并拒绝复用缺少 `lic_sdk_abi_version` 导出的旧库。

插件 `.meta` 使用 Unity 2022 认的平台键（`Standalone: Win64` / `Standalone: Win` / `Standalone: OSXUniversal`），并为 64 位桌面库同时启用 `Editor: Editor`。若手工复制 `.dll`/`.dylib` 到工程，务必在 Plugin Inspector 中同时勾选 **Editor** 与对应的 **Standalone** 平台，否则 Play Mode 会因找不到原生库而报 `DllNotFoundException: licsdk`。

`Web/static/assets/sdk` 下的下载文件与发布包保持一致：`licsdk-x64.dll`、`licsdk-x86.dll`、`liblicsdk.dylib`（Apple Silicon）、`liblicsdk-x64.dylib`（Intel）。
 

在 Unity 的 Plugin Inspector 中确认平台和 CPU 设置。Android 原生库必须放在 ABI 子目录中，并在 Player Settings 中启用相同的 Android Target Architectures。macOS/Linux 的动态库也必须与 Unity 目标架构一致。

## 2. 生成发布包

在仓库根目录执行：

```bash
SDK/Token_rust/scripts/package-unity-release.sh
```

Windows 电脑可使用 PowerShell 版本，或使用同目录下的 `.cmd` 入口：

```powershell
& .\SDK\Token_rust\scripts\package-unity-release.ps1
# 或
.\SDK\Token_rust\scripts\package-unity-release.cmd
```

脚本会构建已安装的 Rust targets，复用当前 Web 目录中已经存在的 Windows DLL，写入 `SDK/Plugins` 和 `SDK/Token_sdk/release-manifest.json`；随后把清单、`README.md`、`licsdk.h` 与本次可用的原生库（稳定文件名：`licsdk-x64.dll`、`licsdk-x86.dll`、`liblicsdk.dylib`、`liblicsdk-x64.dylib`、`liblicsdk-x64.so`、`liblicsdk-arm64.so`）同步到 `Web/static/assets/sdk/`，最后生成该目录下的 `unity-tokensdk.zip`。完整发布默认要求所有目标可用；在发布机只暂时检查可用目标时可使用 `--allow-missing`，但生成的 manifest 必须随包交付并在发布前补齐 `not-built` 项。

发布结束后脚本还会把同一套内容镜像到本地 Unity 联调工程 `临时测试项目/Assets/Tokensdk`（C# 文件、`README.md`、`release-manifest.json`、`Plugins`、`include/licsdk.h`、`scripts/`），方便直接在 Unity 中验证；工程不存在时只告警跳过。不需要该步骤时用 `-NoTestProjectSync`（bash 为 `--no-test-project-sync`），联调工程路径可用 `TOKENSDK_TEST_PROJECT_DIR` 覆盖。同步前请关闭 Unity Editor，否则 Windows 上占用中的 DLL 会导致复制失败。

`release-manifest.json` 的 `schema_version` 为 **2**，除 `abi_version` 外还包含 `generated_at`、每个 target 的 `status` 与 `reason`，以及 `web_artifacts`——下载目录中每个产物的 `name`、`size_bytes` 与 `sha256`（以本次真正同步的文件为准）。用户中心的“SDK 下载与说明”页直接读取它，因此清单缺失或过期时界面会明确标注“校验和未记录”。`Web/static/assets/sdk/release-manifest.json` 里额外带 `unity-tokensdk.zip` 自身的校验和；zip 内嵌的那份不可能包含自身哈希，这是有意为之。

只发布部分目标时，下载目录会保留上一次的同名库文件，而清单不会声明它们；此时下载页会把这类文件标注为“可能是上一次发布残留”。需要让下载目录与本次清单严格一致时加 `--prune-web`（PowerShell 为 `-PruneWeb`）：它只删除白名单内的已知库文件名，不碰 `README.md`、`licsdk.h` 与 zip，也不做任何通配删除。

Android 需要安装 Android NDK，并设置 `ANDROID_NDK_HOME` 或 `ANDROID_NDK_ROOT`；Linux 交叉编译需要对应 Rust target 和一个能产出 ELF 的链接器。可通过 `--prebuilt-dir` 注入在专用构建机上生成的、已经过格式和导出符号检查的库。

当前发布**不提供 Linux 与 Android 产物**：清单会把 `linux-x64`、`linux-arm64`、`android-*` 记录为 `not-built` 并附具体原因（例如缺少 Rust target 或缺少 NDK linker），用户中心的下载页据此显示“未发布”并给出原因，不会给出无法下载的链接。将来需要这些目标时，在具备工具链的构建机上按 `--target` 构建，或用 `--prebuilt-dir` 注入已通过格式与导出符号检查的库即可，下载页会自动变为“已发布”。

## 3. 获取并填写 SDK 配置

登录用户中心的“SDK 下载与使用说明”，选择项目后取得接入参数。`sdk_hmac_key` 是激活密钥所属账户的专属 SDK 签名密钥（标准 Base64 编码的 32 字节密钥），不要使用其他账户的密钥，也不要默认用 Service 的全局回退密钥替代。根 CA PEM 应通过可信渠道取得并与部署的 TLS 证书链匹配。不要通过公开工单、截图或版本控制传递 HMAC 密钥。

Ed25519 公钥可从门户参数表或部署对外提供的 `GET /api/sdk/public-key` 响应中取得；也可由 Service 运维方提供并核对。Service URL 应填写门户参数表中的 `base_url`（HTTPS origin，不附加 `/api`），由 Web 代理转发 SDK 请求，不要将内部 Service 地址暴露给 Unity 客户端。客户端 HMAC 密钥可能被提取，不能替代服务端授权。

在 `LicenseDemoUI` 的 Unity Inspector 中按以下对照填写。既有字段名保持不变，新增的项目 Key 和间隔字段会传入 Native SDK 初始化 JSON：

| 门户参数 | Inspector 字段（C# 字段） | SDK 初始化字段 / 用途 |
|---|---|---|
| `base_url` | Service Url（`serviceUrl`） | `base_url` |
| `sdk_hmac_key` | Sdk Hmac Key（`sdkHmacKey`） | `sdk_hmac_key`，账户专属签名密钥 |
| `ed25519_public_key` | Ed25519 Public Key（`ed25519PublicKey`） | `ed25519_public_key` |
| `pinned_root_certificate_pem` | Pinned Root Certificate Pem（`pinnedRootCertificatePem`） | 自动转换为 `pinned_root_certificates_pem` 数组 |
| `project_key` | Project Key（`projectKey`） | `project_key`，登记设备项目归属 |
| `heartbeat_interval_secs` | Heartbeat Interval Secs（`heartbeatIntervalSecs`） | `heartbeat_interval_secs`，默认 300 秒 |
| `verify_interval_secs` | Verify Interval Secs（`verifyIntervalSecs`） | `verify_interval_secs`，默认 14400 秒 |
| `license_signing_key_id` | 无需填写 | 仅供核对签名密钥标识，不是 `product_id` |

`Project Name` 只是可选展示名称，不能代替 `Project Key`。`Product Id` 是保留的可选兼容字段，不要填入账户 ID、项目 Key 或签名密钥 ID。激活密钥在运行后的输入框中填写，不是项目 Key 或 HMAC 密钥。`cache_path` 由示例使用应用持久化目录自动生成；设备标识默认自动获取，无需填写 `Device Id Override`。

在线接入保持 `Offline Only` 关闭；纯离线部署才启用它。HTTPS 必须配置有效根 CA；`Allow Insecure Http` 仅用于隔离的本地开发。间隔单位为秒，请使用门户给出的值。修改 Inspector 配置后重新运行以重新初始化 SDK。

### `Sdk Hmac Key`

将门户提供的本账户 `sdk_hmac_key` 复制到 `Sdk Hmac Key`，并确认激活密钥属于同一账户。

该密钥用于 SDK 请求签名，不是许可证签名密钥。任何分发到 Unity 客户端的 HMAC 密钥都可能被提取，因此它不是客户端身份凭证，也不能替代服务端授权。不要将密钥写入日志、截图、公开工单或版本控制，也不要将 `LICENSE_SIGNING_KEY` 放入 Unity。

### `Ed25519 Public Key`

将 `GET /api/sdk/public-key` 返回的 Ed25519 公钥复制到 `Ed25519 Public Key`。这是公开的授权票据签名验证材料，必须与签发票据的 Service 相匹配。对应的 `LICENSE_SIGNING_KEY` 私钥只保存在 Service。

### `Pinned Root Certificate Pem`

将运维方通过可信渠道提供的根 CA PEM 复制到 `Pinned Root Certificate Pem`。不要将普通服务器叶证书误当作根证书。

SDK 不使用操作系统默认根证书库，而只信任此字段中的证书列表；信任锚错误或不完整会导致 TLS 连接失败。这是 CA 信任锚，不是叶证书指纹固定。CA 轮换期间，应先配置并验证新旧根证书，再切换服务器证书。

## 3.1 设备名称与“所属项目”

SDK 在激活和心跳时会上报两项仅用于展示的信息（不参与许可证身份计算）：

| 字段 | 来源 | 说明 |
|---|---|---|
| `project` | Inspector 的 `Project Name` → 配置 `project` | 所属项目名称；用户中心优先用它显示设备名称 |
| `machine_name` | 自动读取本机名（`COMPUTERNAME` / `HOSTNAME` / `hostname`），可用 `Machine Name Override` 覆盖 | 用户中心显示为本机名称 |

用户中心显示优先级：**自定义名称（备注） > 所属项目 > 本机名称 > device_id 前 12 位**。用户中心和管理端都可以单独改名或填写备注，这些内容只在门户展示，不影响设备标识与授权。

设备标识 `device_id` 由本机指纹散列得出，与上述展示字段无关。

SDK 启动时会在有项目 key 或已保存激活密钥时登记设备：项目 key 将设备归入对应项目；仅有激活密钥时按密钥所属账户登记为未归属项目设备。首次运行且既无项目 key、也无已录入激活密钥时，SDK 没有可安全确定的账户身份；设备会在用户首次在线激活时登记，不接受客户端自行指定账户 ID。

这两个字段会在**激活、每次启动自动校验、每次心跳、每次计量**时上报，因此改动 Inspector 后重新 Play 会立即同步（启动自动校验先于首次心跳执行）。SDK 也提供了运行期更新接口：

```csharp
await LicSdkClient.Instance.SetDeviceMetadataAsync(projectName, machineNameOverride);
// 下一个校验/心跳请求即生效；传空字符串表示清空该字段
```

注意：SDK 在 `InitAsync` 时读取一次配置，直接修改 Inspector 值而不重新 Play 不会自动生效，需要调用上面的接口或重新运行。

## 4. 创建并连接示例场景

1. 如果工程尚未包含 `UnityEngine.UI`，请先安装 Unity UGUI 包。
2. 创建并保存示例场景，例如 `LicenseDemoScene`，并在 **File > Build Settings > Scenes In Build** 中加入该场景。
3. 添加 `Canvas`，并配置 `Canvas Scaler`、`Graphic Raycaster` 和 `EventSystem`。
4. 在 Canvas 下创建以下 UGUI 控件：
   - 用于输入许可证密钥的 `InputField`；
   - 三个 `Button`：激活、导入离线许可证、进入游戏；
   - 用于显示状态和错误信息的 `Text`。
5. 创建名为 `TokenSDK` 的激活 GameObject 并添加 `LicSdkClient`。场景中只保留一个启用状态的 `LicSdkClient`，该对象会在场景切换时持续存在。
6. 将 `LicenseDemoUI` 添加到示例 UI GameObject，并把 InputField、三个 Button 和状态 Text 拖到对应字段。若引用缺失，示例会在 Unity Console 中报告并停用自身。
7. 设置 Service URL 和上文所述的三个值。生产环境必须关闭 `Allow Insecure Http`。将 `Game Scene Name` 设置为受保护场景的准确名称，并将该场景加入 **Scenes In Build**。
8. 进入 Play 模式。SDK 初始化成功前按钮会保持禁用。粘贴有效许可证密钥并点击 **Activate**；激活成功后点击 **Enter Game**，示例会执行本地 challenge/scene 检查并加载配置的场景。

示例使用 300 秒心跳间隔和 4 小时在线校验间隔。SDK 初始化且许可证激活后，由 Rust 后台任务管理这些定时操作；Unity 示例不会再启动第二个心跳定时器。Challenge/response 和场景门禁只是应用层演示，并非服务端强制的安全边界。重要操作必须由 Service 执行授权判断，不要只依赖客户端场景检查来保护数据或功能。

## 5. 导入离线激活包

1. 请管理员或客户本人从管理端/客户门户导出目标激活密钥和设备的离线激活包。导出时密钥即被占用并绑定设备。
2. 将完整的离线激活包 JSON 响应保存为 `license.licbin`。
3. 将文件复制到目标运行环境的 `Application.persistentDataPath`，文件名必须保持不变。Unity Editor 下该路径因平台而异；如果文件不存在，示例会显示预期的完整路径。
4. 启动示例并选择 **Import Offline License**。SDK 会验证签名、设备、权限和有效期，然后保存加密的本地缓存。

离线模式允许对已导入激活包执行本地检查，但仅在线功能（例如用量计量）仍需要连接 Service。导入操作不会创建离线激活包。

## 6. 启动自动恢复、事件回调与令牌管理

SDK 会把成功激活（或导入离线激活包）后的授权写入加密缓存 `cache_path`，并在下次 `lic_sdk_init` 时自动恢复，因此**不需要用户重新输入激活密钥**。

初始化后按以下顺序自动执行，并通过回调把结果通知宿主：

| 事件 | 数值 | 触发时机 | `code` | `state` |
|---|---|---|---|---|
| `LIC_EVENT_STARTUP` | 1 | 缓存读取与本地校验完成（立即触发） | 0 表示缓存可用；否则为错误码（如 `-7` 无许可证、`-8` 过期、`-9` 已吊销、`-17` 设备已停止、`-6` 票据签名无效） | 当前许可证状态 |
| `LIC_EVENT_ONLINE_CHECK` | 2 | 缓存可用且非 `offline_only` 时，后台自动向门户校验 | 0 表示在线校验通过；`-4` 表示网络不可达（此时按离线宽限继续） | 校验后的状态 |
| `LIC_EVENT_STATE_CHANGED` | 3 | 之后的任何状态变化（心跳失败降级、后台校验发现吊销/过期、导入离线激活包等） | 0 或触发本次变化的错误码 | 新状态 |

C# 封装已经把事件切回 Unity 主线程，直接订阅即可：

```csharp
LicSdkClient.Instance.LicenseEventReceived += info =>
{
    Debug.Log($"{info.Type} code={info.Code} state={info.State} {info.Message}");
};
```

必须在 `InitAsync` **之前**订阅；错过的事件仍可从 `LastStartupResult`、`LastOnlineCheckResult` 读取。

`InitAsync` 等待期间启动/在线校验事件就可能已经到达，因此**不要**在初始化返回后无条件把状态栏改回“正在自动校验”。如果激活密钥被拒（例如已暂停），之后不会再加发事件，占位文案会一直停住。应先读取 `LastStartupResult` / `LastOnlineCheckResult`；两者都为空时先短暂等待事件，超时后调用 `GetStateAsync()` 用当前状态兜底（示例见 `LicenseDemoUI.ResolveWithoutEventAsync`），否则界面会永远停在“已保存激活密钥，正在自动校验...”。

### 停止某台设备（-17）

管理员或客户本人都可以在用户中心/后台按设备执行“停止”，或对整张许可证执行“停止全部设备”：

- 该设备之后的校验、心跳、计量扣减都会返回 `-17 device has been blocked`，本地门禁同时关闭（状态码 `9`）；
- 被停止的设备**仍占用**设备名额，若需要释放在名额请使用“解绑”；
- 激活一个已被停止的设备同样返回 `-17`，不会自动解除停止；
- **缓存与令牌保留**：停止只会让本地门禁关闭，不会删除本地缓存，`HasSavedLicense` 仍为 `true`，`VerifySavedAsync()` 等接口继续可用；
- 恢复（取消停止）后无需用户重新输入令牌：SDK 用独立的恢复重试定时器（`clamp(heartbeat_interval_secs, 5s, 30s)`，默认约 30 秒一次）自动重试，成功后自动清除拒签标记并回到可用状态；也可由宿主主动调用 `VerifySavedAsync()` 立即恢复；
- 只有**吊销**（`-9`）或许可证不存在才会删除缓存并清除令牌，此时才需要重新输入许可证密钥。

SDK 侧可用 `LicenseSdkError.DeviceBlocked`（`-17`）判断该情况，并提示用户联系发行方。

### 连不上服务端时的本地可用性

本地缓存中的票据由 Service 用 Ed25519 签名，因此其中的 `expires_at` 是可信的本地截止时间：

- **有到期日的许可证**：在校验服务不可达时，本地门禁持续放行到 `expires_at`，不再受 24 小时宽限限制；
- **永久授权**（`expires_at` 为空）：离线永久可用；
- 计量扣减（`ConsumeSavedAsync`）等在线能力仍需连通服务端，离线时返回 `-13`；
- 系统时钟被回拨到上次成功校验之前会返回 `-16`（clock rollback），不会延长授权；
- 若需要保留“定期上线才能继续用”的严格策略，可在 SDK 配置 JSON 中加入 `"offline_policy": "grace"`，此时离线窗口为最后一次成功校验后的 `offline_grace_secs`（默认 86400 秒）。

### 导入离线激活包与在线授权共存

`ImportOfflineLicenseAsync(package)`（原生 `lic_import_offline_license`）把离线激活包写入**第二槽位**，不会清除已激活的在线授权：

- **在线优先**：门禁先用在线票据；在线票据不可用（无票据 / 断网超过窗口 / 已到期）时才使用离线激活包（状态 `7` 限期、`8` 永久）；
- 持有离线激活包时 `VerifyAsync` / `HeartbeatAsync` / `ConsumeAsync` 仍然正常可用（不再返回 `-13`）；
- **服务端明确拒绝优先**：收到吊销 `-9`、暂停 `-7`、设备停止 `-17` 时本地立即失效（状态 `5/9/1`），永久证书也不放行；恢复后自动重试并回到可用状态；
- **吊销**：用户中心/后台可吊销某条导出记录，SDK 在下次在线校验（`verify`/`heartbeat`/`activate`）读到 `offline_revoked_at` 后删除本地离线包槽；重新签发的新包（`issued_at` 晚于吊销时间）不受影响。设备一直离线时无法实时失效，这是离线授权的固有限制。

对应事件表现：启动时 `LIC_EVENT_STARTUP` 报告 `code=0, state=3`（缓存可用），随后 `LIC_EVENT_ONLINE_CHECK` 在断网时报告 `code=-4, state=3`——`state=3` 表示授权仍然可用，宿主不应因为该错误码就阻止玩家进入。

### 使用已保存的令牌

- `LicSdkClient.HasSavedLicense` / `HasSavedLicenseAsync()`：本地是否存在可直接使用的许可证密钥（缓存可用即返回 `true`；被停止、暂停或过期时仍为 `true`）。许可证密钥本身不会暴露给 C#。
- 被停止 / 暂停 / 过期后令牌仍然保留；恢复或续期后校验成功即自动回到可用状态，不需要用户重新输入令牌。
- `VerifySavedAsync()`、`HeartbeatSavedAsync()`、`ConsumeSavedAsync(metric, amount, idempotencyKey)`：使用缓存令牌执行，无需再传入 `license_key`。

### 更新令牌

`UpdateLicenseKeyAsync(newKey)` 用新令牌在线激活；**只有激活成功才会替换本地缓存**，失败时旧许可证仍然可用，调用方可提示用户重试。示例 UI 的 **Activate** 按钮据此自动分流：输入框为空且有已保存令牌时改为校验缓存，输入新令牌时执行更新，没有已保存令牌时执行首次激活。

## 7. 运行与构建说明

- 原生接口使用 UTF-8 字节数组和显式 `UIntPtr` 长度。C# 封装在工作任务中执行阻塞 FFI 操作，并在 Unity 同步上下文中更新 UI。
- `LicSdkClient` 也封装了心跳、计量扣减、功能门禁和核心逻辑门禁；失败时可通过 `GetLastErrorAsync` 读取 Rust SDK 返回的错误文本。初始化前调用这些操作会明确抛出 `InvalidOperationException`。
- `ConsumeAsync(licenseKey, metric, amount, idempotencyKey)` 中的幂等键可选；重试同一计量请求时应传入相同幂等键，避免重复扣减。
- 在应用生命周期内保持 `LicSdkClient` GameObject 存活。它负责 SDK 初始化、原生后台运行时、本地缓存、心跳和关闭。
- 不要记录 `sdkHmacKey`、许可证密钥或本地缓存文件。分发到客户端的 HMAC 密钥可以被提取，最终授权仍由 Service 决定。
- HTTPS 连接需要 `pinnedRootCertificatePem` 中的有效 PEM 信任锚。`Allow Insecure Http` 仅可用于隔离的本地开发环境，发布版本绝不能开启。
- 仓库中的 `临时测试项目` 仅用于 Windows 联调，不代表其他平台已经运行验证。正式发布前，必须在实际 Unity Editor、Mono/IL2CPP、Windows、macOS、Linux 和 Android 设备/架构上验证库加载、TLS 证书链、缓存目录、离线导入和场景加载行为。

### 初始化返回 `-2`

`-2` 表示 SDK 配置无效。请查看示例状态文本中冒号后的详细原因，并确认：`Service URL` 是门户显示的 HTTPS origin（不要附加 `/api` 路径）；HMAC Key 与 Ed25519 公钥原样粘贴、Base64 解码后各为 32 字节；根证书字段粘贴完整的 PEM（包括 `BEGIN/END CERTIFICATE` 行）。SDK 会修正常见的证书头/尾换行格式，但不会接受缺失或无效证书。HTTP 仅在明确启用开发选项时允许使用。

### 激活返回 `-5`（request signature was rejected）

Service 对该请求返回了 HTTP 401。按顺序检查：

1. `Service URL` 必须是门户 origin（本地开发为 `http://127.0.0.1:8080`）；生产环境必须使用 HTTPS。
2. Web 代理与 Service 必须按部署配置正确连接；客户端不应直接访问仅供内网使用的 Service 地址。
3. `Sdk Hmac Key` 必须与激活密钥所属账户在门户显示的 `sdk_hmac_key` 相匹配。
4. 客户端与 Service 的系统时钟偏差不能超过 30 秒，否则请求会被判为过期。
