diff --git a/docs/cloud-sync-server-handoff.md b/docs/cloud-sync-server-handoff.md new file mode 100644 index 000000000..7a3815db7 --- /dev/null +++ b/docs/cloud-sync-server-handoff.md @@ -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 `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` 运行;服务端部署验收需另行按平台执行。 diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 84705b6c8..ab172a02d 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -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 仅用于回环测试。 | 同步内容 | 保持在本机的内容 | | --- | --- | diff --git a/docs/index.md b/docs/index.md index 7d0738a24..4423a1da9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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`、启动快照、事件面与公开签名。 ## 平台与运营 diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 9f3c25dbf..83569a9be 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -154,7 +154,7 @@ impl CloudSyncService { body: Option>, token: &SecretValue, ) -> Result { - 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 diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 94f66b7c9..3dc2d6c42 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -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, diff --git a/openless-all/app/crates/openless-core/src/marketplace.rs b/openless-all/app/crates/openless-core/src/marketplace.rs index 6763bddeb..0b25e9f07 100644 --- a/openless-all/app/crates/openless-core/src/marketplace.rs +++ b/openless-all/app/crates/openless-core/src/marketplace.rs @@ -20,10 +20,14 @@ 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, @@ -31,7 +35,17 @@ pub struct MarketplaceConfig { } 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) -> Result { + Self::with_cloud_sync_base_url(base_url, None) + } + + pub fn with_cloud_sync_base_url( + base_url: impl AsRef, + cloud_sync_base_url: Option<&str>, + ) -> Result { let parse = |value: &str, name: &str| { reqwest::Url::parse(value).map_err(|_| { BackendError::new( @@ -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", @@ -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") } } @@ -264,6 +283,15 @@ impl MarketplaceService { }) } + pub(crate) fn cloud_sync_url(&self, path: &str) -> Result { + self.config.cloud_sync_base_url.join(path).map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "invalid cloud sync request URL", + ) + }) + } + async fn send_with_retry(&self, make: F) -> Result where F: Fn() -> reqwest::RequestBuilder, diff --git a/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs index a5b90f7a7..7b5f9bd5d 100644 --- a/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs +++ b/openless-all/app/crates/openless-core/tests/cloud_sync_contract.rs @@ -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/"); +} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 53207e3e1..64855cc04 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -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);