Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/cloud-sync-server-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 云同步服务端交接

状态:canonical(客户端合同 v1 已实现并锁定);更新:2026-09-10。读者:云同步服务端的实现与部署者。客户端实现见 [`cloud_sync.rs`](../openless-all/app/crates/openless-core/src/cloud_sync.rs),DTO 见 [`cloud_sync_types.rs`](../openless-all/app/crates/openless-core/src/cloud_sync_types.rs);数据范围与字段表见 [官方云同步](cloud-sync.md)。服务器端代码由服务端负责人编写,本文只约定客户端发出的请求与它接受的响应。

## 服务地址(客户端已内置)

- 基地址:`https://apic.openless.top:9443`,接口路径 `/me/sync`,完整地址 `https://apic.openless.top:9443/me/sync`。
- 与风格包市场后端 `https://apic.openless.top` 同一台服务器,但使用独立端口 **9443**,不经过市场后端的 443 路由。服务端需在该端口提供 TLS(可由 nginx 独立 `server` 块终止后反代到本机同步进程,同步进程本身只听 HTTP,如 `127.0.0.1:9443` 内部端口由部署自定)。
- 客户端常量 `CLOUD_SYNC_BASE_URL` 在 [`marketplace.rs`](../openless-all/app/crates/openless-core/src/marketplace.rs),主机名必须与市场地址一致,端口如需调整须同步修改该常量并重跑合同测试。
- 客户端强制:正式地址必须 HTTPS;仅回环地址(127.0.0.1、localhost)允许 HTTP,用于本机联调。请求不携带用户名/密码。

## 客户端请求行为

所有方法(GET/PUT/DELETE)行为一致:

| 项 | 值 |
| --- | --- |
| 认证 | `Authorization: Bearer <GitHub OAuth access token>`;服务端应每次经 GitHub `GET /user` 校验,只使用 numeric id 归属数据 |
| 重定向 | 客户端使用 `Policy::none`,任何 3xx 一律报错;服务端不得重定向 |
| 超时 | 30 秒 |
| Content-Type | PUT/DELETE 请求体为 `application/json` |
| 请求体上限 | 2 MiB(超过返回 413) |
| 重试 | 客户端不自动重试任何方法;GET 网络失败视为服务不可用,PUT/DELETE 网络失败视为"结果未知" |
| 缓存 | 响应应带 `Cache-Control: no-store` |

## 接口语义

合同版本 `schemaVersion: 1`。快照对象:

```json
{"schemaVersion":1,"revision":2,"updatedAt":"2026-09-10T08:00:00+00:00","payload":{…}}
```

- `revision` 从 0 开始,非负且不超过 `9007199254740991`(JavaScript 安全整数上限)。`revision=0` 当且仅当 `payload=null` 且 `updatedAt=null`(从未上传或已删除且无历史)。删除不清零版本:删除后返回递增版本和 `payload:null`。
- `updatedAt` 为服务端生成的 RFC3339 时间戳;`revision>0` 时必须存在且可解析。
- `payload` 为完整替换,不做字段级合并;四个集合/对象 `dictionary`、`corrections`、`stylePacks`、`preferences` 必须同时出现。

### GET /me/sync — 读取

首次使用返回 `200` 与空快照:`{"schemaVersion":1,"revision":0,"updatedAt":null,"payload":null}`。这不是错误,客户端据此显示"暂无云端备份"。

### PUT /me/sync — 整体上传

请求:

```json
{"schemaVersion":1,"baseRevision":2,"payload":{…完整 payload…}}
```

- `baseRevision` 是该设备最后一次读取的版本。服务端必须原子比较:`baseRevision == 当前版本` 才写入,成功后版本递增、刷新 `updatedAt`,返回 `200` 与完整新快照(含已保存 payload)。
- `baseRevision` 不匹配返回 `409`,响应体:

```json
{"error":"revision_conflict","message":"…","revision":3,"updatedAt":"…"}
```

客户端从该响应读取 `revision` 字段作为 `currentRevision` 提示用户刷新;冲突响应不得包含私有 payload。
- `baseRevision=0` 且云端确无记录时创建首份快照(版本 1)。
- `schemaVersion` 不是 1 返回 `400`(`unsupported_schema_version`)。

### DELETE /me/sync — 清空云端

请求 `{"baseRevision":2}`;成功返回递增版本、`updatedAt` 更新、`payload:null` 的快照。对不存在(`revision=0`)的快照执行删除且 `baseRevision=0` 时,按合同创建版本 1 的空记录。本机数据由客户端自行保留,服务端只管云端。

## 响应校验(服务端返回给客户端的内容同样受限)

客户端用与上传完全相同的严格 DTO 校验每个响应:camelCase、拒绝未知字段(`deny_unknown_fields`)、限额同请求。响应体读取上限为 2 MiB + 1 KiB。`payload` 各集合限额:词典 10000 条、纠正规则 2000 条、风格包 200 个;ID 1–128 个 ASCII 字母数字及 `._-:`(不允许单独 `.`/`..`,集合内唯一);图标为标准 base64 PNG,解码后 ≤ 64 KiB、宽高 1–1024 像素;完整字段与字节限制见 [官方云同步](cloud-sync.md) 与 3-backend 仓库 `docs/cloud-sync.md`(合同同源)。

## 状态码 → 客户端行为

| 服务端返回 | 客户端表现 |
| --- | --- |
| 200 | 正常解析快照 |
| 401 / 403 | 判定 GitHub 登录失效,要求重新登录 |
| 404 / 405 / 501 | "官方同步服务暂不可用"(服务未部署时 UI 的默认表现,不得被当成空备份) |
| 409 | 版本冲突,提示刷新云端状态后重新选择 |
| 400 / 413 / 415 及其他 4xx/5xx | 通用失败提示,附 HTTP 状态码,本机数据不变 |
| 网络失败/超时 | GET:服务不可用;PUT/DELETE:结果未知,要求先刷新状态 |

400 应使用 JSON 错误体(`{"error":"invalid_payload"}` 等),客户端不解析其内容,仅按状态码处理。

## 服务端参考实现

3-backend 仓库已含一份与本文同合同的参考实现(`backend/src/routes/sync.rs`、迁移 `0006_user_cloud_sync.sql`、文档 `docs/cloud-sync.md`),尚未部署到生产;可部署、改造或重写,但对外行为须满足本文全部条款。

## 联调与验收清单

1. 无服务时访问 `GET https://apic.openless.top:9443/me/sync` 应 404,客户端显示"官方同步服务暂不可用"(回归基线)。
2. 有效 GitHub token:GET 返回空快照 → PUT 保存 → GET 读回一致 → 再 PUT 带旧 `baseRevision` 收到 409 → DELETE 清空后快照 `payload:null` 且版本递增。
3. TLS 证书对 `apic.openless.top` 有效;HTTP 明文访问 9443 不得 200。
4. 超过 2 MiB 的 PUT 收到 413;未知字段收到 400。
5. 应用内验证:设置 → 权限与数据 → 云同步,完成备份/恢复/删除各一次,恢复后词典、纠正规则、风格包与偏好生效,API 密钥与设备设置不变。

客户端合同测试(本机假服务)与生产地址固定测试随 `cargo test -p openless-core --locked` 运行;服务端部署验收需另行按平台执行。
4 changes: 2 additions & 2 deletions docs/cloud-sync.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# 官方云同步

状态:手动快照同步,HTTP 合同版本 1。客户端需要官方服务同时提供 `/me/sync`;旧版服务返回 404/405/501 时显示暂不支持,不能将其当成空备份。
状态:手动快照同步,HTTP 合同版本 1。客户端需要官方服务同时提供 `/me/sync`;旧版服务返回 404/405/501 时显示暂不支持,不能将其当成空备份。生产同步地址为 `https://apic.openless.top:9443`(与市场后端同一服务器、独立端口),服务端连接细节见 [云同步服务端交接](cloud-sync-server-handoff.md)。

## 入口与数据边界

[`cloud_sync.rs`](../openless-all/app/crates/openless-core/src/cloud_sync.rs) 从共享 Core 仓储生成快照,使用现有 `MarketplaceConfig` 的官方地址和同一个 Marketplace 登录实例。GitHub OAuth token 只从 `CredentialStore` 读取,不经过 React,不包含在状态、事件或错误中;注销标记同样对云同步生效。带凭据的请求不跟随重定向,正式地址要求 HTTPS,本机 HTTP 仅用于回环测试。
[`cloud_sync.rs`](../openless-all/app/crates/openless-core/src/cloud_sync.rs) 从共享 Core 仓储生成快照,使用 `MarketplaceConfig` 的专用云同步地址(生产为 `https://apic.openless.top:9443`,测试与 `new()` 构造默认同源)和同一个 Marketplace 登录实例。GitHub OAuth token 只从 `CredentialStore` 读取,不经过 React,不包含在状态、事件或错误中;注销标记同样对云同步生效。带凭据的请求不跟随重定向,正式地址要求 HTTPS,本机 HTTP 仅用于回环测试。

| 同步内容 | 保持在本机的内容 |
| --- | --- |
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
## 接口契约

- [官方云同步](cloud-sync.md):可同步字段、GitHub 身份边界、版本冲突、本地恢复与验证。
- [云同步服务端交接](cloud-sync-server-handoff.md):同步服务地址与端口(apic.openless.top:9443)、客户端请求行为、状态码映射与服务端验收清单。
- [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。

## 平台与运营
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/crates/openless-core/src/cloud_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ impl CloudSyncService {
body: Option<Vec<u8>>,
token: &SecretValue,
) -> Result<CloudSyncSnapshot, BackendError> {
let url = self.marketplace.public_url("me/sync")?;
let url = self.marketplace.cloud_sync_url("me/sync")?;
let loopback = url.host_str().is_some_and(|host| {
host == "localhost"
|| host
Expand Down
4 changes: 3 additions & 1 deletion openless-all/app/crates/openless-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@ pub use local_asr_service::{
LocalAsrRuntimeLease, ModelPrepareProgressSink, ModelRuntimeAdapter, NativeModelState,
StorageRebind,
};
pub use marketplace::{MarketplaceConfig, MARKETPLACE_BASE_URL, MARKETPLACE_GITHUB_TOKEN_ACCOUNT};
pub use marketplace::{
MarketplaceConfig, CLOUD_SYNC_BASE_URL, MARKETPLACE_BASE_URL, MARKETPLACE_GITHUB_TOKEN_ACCOUNT,
};
pub use model_store::{
extract_archive_safely, merge_hf_tree_pages, merge_hf_tree_pages_with_base, model_mirror_base,
parse_hf_tree_page, validate_model_path, validate_model_url, DownloadProgressSink,
Expand Down
30 changes: 29 additions & 1 deletion openless-all/app/crates/openless-core/src/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,32 @@ use crate::{PreferencesStore, StylePack, StylePackStore};

pub const MARKETPLACE_GITHUB_TOKEN_ACCOUNT: &str = "github.oauth_token";
pub const MARKETPLACE_BASE_URL: &str = "https://apic.openless.top";
/// Cloud sync shares the marketplace host but runs on its own listener port;
/// the sync service owner deploys TLS on this port.
pub const CLOUD_SYNC_BASE_URL: &str = "https://apic.openless.top:9443";

#[derive(Debug, Clone)]
pub struct MarketplaceConfig {
pub base_url: reqwest::Url,
pub cloud_sync_base_url: reqwest::Url,
pub github_client_id: String,
pub github_device_code_url: reqwest::Url,
pub github_access_token_url: reqwest::Url,
pub github_user_url: reqwest::Url,
}

impl MarketplaceConfig {
/// The cloud sync endpoint defaults to the given base URL so contract tests
/// can point both services at one local mock; production pairs the
/// marketplace host with the dedicated [`CLOUD_SYNC_BASE_URL`].
pub fn new(base_url: impl AsRef<str>) -> Result<Self, BackendError> {
Self::with_cloud_sync_base_url(base_url, None)
}

pub fn with_cloud_sync_base_url(
base_url: impl AsRef<str>,
cloud_sync_base_url: Option<&str>,
) -> Result<Self, BackendError> {
let parse = |value: &str, name: &str| {
reqwest::Url::parse(value).map_err(|_| {
BackendError::new(
Expand All @@ -46,6 +60,10 @@ impl MarketplaceConfig {
.unwrap_or_else(|| "Ov23liyv3nEucG7oMHNE".into());
Ok(Self {
base_url: parse(base_url.as_ref(), "marketplace")?,
cloud_sync_base_url: match cloud_sync_base_url {
Some(value) => parse(value, "cloud sync")?,
None => parse(base_url.as_ref(), "marketplace")?,
},
github_client_id,
github_device_code_url: parse(
"https://github.com/login/device/code",
Expand All @@ -60,7 +78,8 @@ impl MarketplaceConfig {
}

pub fn production() -> Self {
Self::new(MARKETPLACE_BASE_URL).expect("built-in Marketplace URLs are valid")
Self::with_cloud_sync_base_url(MARKETPLACE_BASE_URL, Some(CLOUD_SYNC_BASE_URL))
.expect("built-in Marketplace URLs are valid")
}
}

Expand Down Expand Up @@ -264,6 +283,15 @@ impl MarketplaceService {
})
}

pub(crate) fn cloud_sync_url(&self, path: &str) -> Result<reqwest::Url, BackendError> {
self.config.cloud_sync_base_url.join(path).map_err(|_| {
BackendError::new(
BackendErrorCode::InvalidArgument,
"invalid cloud sync request URL",
)
})
}

async fn send_with_retry<F>(&self, make: F) -> Result<reqwest::Response, BackendError>
where
F: Fn() -> reqwest::RequestBuilder,
Expand Down
23 changes: 23 additions & 0 deletions openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,26 @@ async fn unsupported_server_auth_failures_and_logout_do_not_invent_success() {
);
assert_eq!(server.state.lock().unwrap().requests.len(), count);
}

#[test]
fn production_cloud_sync_targets_the_dedicated_port_on_the_marketplace_host() {
let config = MarketplaceConfig::production();
assert_eq!(
config.cloud_sync_base_url,
reqwest::Url::parse(openless_core::CLOUD_SYNC_BASE_URL).unwrap()
);
assert_eq!(
config.cloud_sync_base_url.host_str(),
config.base_url.host_str(),
"cloud sync must stay on the marketplace host"
);
assert_eq!(config.cloud_sync_base_url.port(), Some(9443));
assert_eq!(
config.cloud_sync_base_url.scheme(),
"https",
"the production sync endpoint must be HTTPS"
);
// Contract tests point both services at one local mock through `new`.
let local = MarketplaceConfig::new("http://127.0.0.1:8080/").unwrap();
assert_eq!(local.cloud_sync_base_url.as_str(), "http://127.0.0.1:8080/");
}
2 changes: 1 addition & 1 deletion openless-all/app/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -1549,7 +1549,7 @@ html:lang(de) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: 12px;
}
.ol-cloud-sync .ol-cloud-sync-error {
color: var(--ol-err);
Expand Down
Loading