-
Notifications
You must be signed in to change notification settings - Fork 0
Cloud Sync
本文档详细介绍 StudyWithMiku 的云端数据同步系统,包括同步架构、Protobuf 编码协议、冲突解决策略和离线队列机制。
云同步系统允许已认证用户在多设备间同步番茄钟记录、设置、歌单等数据。系统采用 乐观并发控制 + Protobuf 二进制编码 设计,在保证数据一致性的同时最小化传输体积。
flowchart TB
subgraph Client["浏览器"]
useDataSync["useDataSync()"]
useSyncEngine["useSyncEngine()"]
protobufClient["protobufClient"]
conflictResolver["syncConflictResolver"]
offlineQueue["离线队列 (localStorage)"]
end
subgraph Server["Cloudflare Workers"]
DataRoutes["/api/data/:type"]
UserDataService["userData Service"]
protobufServer["protobufServer"]
D1["D1 Database (user_data)"]
end
useDataSync --> protobufClient
useDataSync --> conflictResolver
useDataSync --> offlineQueue
useSyncEngine --> useDataSync
protobufClient -->|"Protobuf"| DataRoutes
DataRoutes --> UserDataService
UserDataService --> protobufServer
UserDataService --> D1
系统支持 6 种数据类型,各有独立的大小限制和验证规则:
| 数据类型 | 说明 | 大小限制 | 条目限制 |
|---|---|---|---|
focus_records |
番茄钟专注记录 | 2 MB | 10,000 条 |
focus_settings |
番茄钟设置 | 1 KB | — |
playlists |
歌单数据(含歌曲元信息) | 200 KB | 50 个歌单,每个 500 首歌 |
user_settings |
用户偏好设置 | 1 KB | — |
share_config |
分享卡片配置 | 10 KB | — |
hook_settings |
钩子设置 | 1 KB | — |
用户总配额: 5 MB(所有数据类型合计)
对应的 localStorage 键名映射:
| 数据类型 | localStorage 键 |
|---|---|
focus_records |
STORAGE_KEYS.FOCUS_RECORDS (swm_focus_records) |
focus_settings |
STORAGE_KEYS.FOCUS_SETTINGS (swm_focus_settings) |
playlists |
STORAGE_KEYS.PLAYLISTS (swm_playlists) |
user_settings |
STORAGE_KEYS.USER_SETTINGS (swm_settings) |
share_config |
STORAGE_KEYS.SHARE_CARD_CONFIG (swm_share_card_config) |
hook_settings |
swm_hooks |
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>C: 读取本地数据和版本号
C->>C: Protobuf 编码
C->>S: PUT /api/data/:type {data, version}
alt 版本匹配
S->>S: 验证数据格式和大小
S->>S: Protobuf 编码存储到 D1
S->>S: version++
S-->>C: {success: true, version}
C->>C: 更新本地版本号
else 版本冲突 (409)
S-->>C: {conflict: true, serverData, serverVersion}
C->>C: 执行冲突解决
C->>S: PUT /api/data/:type {data, version: null} (force)
S-->>C: {success: true, version}
end
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: GET /api/data/:type
S->>S: 从 D1 读取数据
S->>S: Protobuf 解码
S-->>C: {type, data, version}
C->>C: Protobuf 解码
C->>C: 与本地数据合并
C->>C: 写入 localStorage
C->>C: 更新本地版本号
triggerSync() 是手动触发的全量同步,执行以下步骤:
- 检查是否已认证且在线
- 处理离线队列中的待上传数据
- 逐类型上传本地有变更的数据
- 逐类型下载服务端最新数据
- 合并并写入本地存储
useSyncEngine 是专门为专注记录设计的同步引擎,提供更精细的控制:
同步引擎使用 PROTOBUF_PROTOCOL_VERSION 常量(值为 1)标识当前协议版本。该常量在 shared/proto/index.js 中定义,前后端共享,确保编解码兼容性。
const sync = async () => {
// 1. 获取服务端版本
const serverVer = await fetchServerVersion()
// 2. 如果版本不同或有本地变更
if (serverVer !== localVersion || queue.length > 0) {
// 3. 下载服务端数据
const serverData = await downloadFullData()
// 4. 与本地记录合并
const merged = mergeFocusRecords(localRecords, serverData)
// 5. 上传合并后的数据
await uploadFullData(merged)
}
// 6. 清空变更队列
clearQueue()
}每次专注记录变化时,useSyncEngine 将变更加入队列:
queueChange('add', record) // 新增记录
queueChange('update', record) // 更新记录
queueChange('delete', record) // 删除记录队列按记录 ID 去重:相同 ID 的新变更会覆盖旧变更,delete 操作优先级最高。队列持久化到 localStorage。
当客户端版本号与服务端不匹配时触发冲突解决。各数据类型使用不同的合并策略:
// src/utils/syncConflictResolver.js
const mergeFocusRecords = (local, server) => {
const map = new Map()
// 先放入服务端记录
server.forEach(r => map.set(r.id, r))
// 本地记录按 updatedAt 做 Last-Write-Wins
local.forEach(r => {
const existing = map.get(r.id)
if (!existing || (r.updatedAt || 0) > (existing.updatedAt || 0)) {
map.set(r.id, r)
}
})
return [...map.values()].sort((a, b) => b.startTime - a.startTime)
}歌单合并以目标账号(当前登录)的数据为主,补充源账号中不存在的歌单。
- 先按版本号做 Last-Write-Wins(高版本优先)
- 如果版本冲突,执行 deep merge:服务端值覆盖同名字段,保留本地独有字段
仅按版本号做 Last-Write-Wins,不做深度合并。
当用户离线时,数据变更被缓存到 localStorage 的队列中:
// 队列条目格式
{
id: string, // 唯一标识
type: string, // 数据类型 (focus_records, playlists, ...)
data: any, // 变更数据
version: number, // 本地版本号
timestamp: number, // 变更时间
operation: string // 操作类型
}数据变更后不会立即同步,而是通过 debounce 机制延迟处理:
// AUTH_CONFIG.SYNC_DEBOUNCE_DELAY = 2000ms
const debouncedSync = debounce(() => processQueue(), 2000)这避免了频繁的网络请求,例如用户快速修改多个设置时只触发一次同步。
flowchart TB
Change["数据变更"]
Queue["加入离线队列"]
Debounce["Debounce 2s"]
Check["检查: 已认证 && 在线?"]
Process["逐条处理队列"]
Upload["上传数据"]
Success["移除已处理条目"]
Fail["保留在队列中"]
Retry["下次触发时重试"]
Change --> Queue --> Debounce --> Check
Check -->|"是"| Process --> Upload
Check -->|"否"| Retry
Upload -->|"成功"| Success
Upload -->|"失败"| Fail --> Retry
Protocol Buffers (Protobuf) 是 Google 开发的高效二进制序列化格式,相比 JSON:
- 编码体积显著减小(强类型 + varint 编码 + 字段编号替代字符串键)
- 严格的 schema 定义,前后端通过
.proto文件共享类型约定 - 自动生成的编解码代码,减少手工映射出错
- 原生支持枚举、嵌套消息、
oneof等丰富类型
消息定义位于 shared/proto/studymiku.proto,使用 Proto3 语法。主要的信封消息:
message SyncRequest {
optional int32 version = 1;
oneof data {
FocusRecords focus_records = 10;
FocusSettings focus_settings = 11;
Playlists playlists = 12;
UserSettings user_settings = 13;
ShareConfig share_config = 14;
HookSettings hook_settings = 15;
}
}
message SyncResponse {
string type = 1;
int32 version = 2;
bool success = 3;
optional string error = 4;
optional string code = 5;
bool conflict = 6;
optional int32 serverVersion = 7;
optional bool merged = 8;
oneof data {
FocusRecords focus_records = 10;
FocusSettings focus_settings = 11;
Playlists playlists = 12;
UserSettings user_settings = 13;
ShareConfig share_config = 14;
HookSettings hook_settings = 15;
}
oneof serverData {
FocusRecords server_focus_records = 20;
FocusSettings server_focus_settings = 21;
Playlists server_playlists = 22;
UserSettings server_user_settings = 23;
ShareConfig server_share_config = 24;
HookSettings server_hook_settings = 25;
}
}自动生成的 JS 绑定位于 shared/proto/gen/studymiku_pb.js,由构建工具根据 .proto 文件生成。
编解码层在 JS 语义值与 Proto 枚举值之间自动转换。映射定义在 shared/proto/index.js:
| JS 字段 / 类型 | JS 值 | Proto 枚举值 |
|---|---|---|
| FocusMode | focus |
FOCUS |
shortBreak |
SHORT_BREAK |
|
longBreak |
LONG_BREAK |
|
| CompletionType | completed |
COMPLETED |
cancelled |
CANCELLED |
|
| ... | ... | |
| PlaylistMode | playlist |
PLAYLIST |
collection |
COLLECTION |
|
| PlaylistSource | netease |
NETEASE |
tencent |
TENCENT |
|
spotify |
SPOTIFY |
|
local |
LOCAL |
|
| HookTrigger | focus_start |
HOOK_FOCUS_START |
| ... | ... | |
| HookProvider | notification |
PROVIDER_NOTIFICATION |
sound |
PROVIDER_SOUND |
|
push |
PROVIDER_PUSH |
|
estim |
PROVIDER_ESTIM |
flowchart LR
subgraph Encode["编码 (上传)"]
Original["原始 JS 对象"]
Convert["JS_TO_PROTO 转换"]
Create["create(Schema, data)"]
ToBinary["toBinary(Schema, msg)"]
Binary["Uint8Array"]
Original --> Convert --> Create --> ToBinary --> Binary
end
subgraph Decode["解码 (下载)"]
BinaryIn["Uint8Array"]
FromBinary["fromBinary(Schema, buffer)"]
Reconvert["PROTO_TO_JS 转换"]
Result["原始 JS 对象"]
BinaryIn --> FromBinary --> Reconvert --> Result
end
编码时,JS 对象先经过 JS_TO_PROTO 转换(枚举值映射、字段名适配),然后通过生成的 create() 构造 Proto 消息实例,最后用 toBinary() 序列化为 Uint8Array。解码时执行反向操作:fromBinary() 反序列化,再经 PROTO_TO_JS 转换还原为应用层 JS 对象。
shared/proto/index.js 导出 DATA_TYPES 常量,统一管理所有数据类型标识:
const DATA_TYPES = {
FOCUS_RECORDS: 'focus_records',
FOCUS_SETTINGS: 'focus_settings',
PLAYLISTS: 'playlists',
USER_SETTINGS: 'user_settings',
SHARE_CONFIG: 'share_config',
HOOK_SETTINGS: 'hook_settings',
}shared/proto/index.js 提供以下公共函数,前后端共享使用:
| 函数 | 说明 |
|---|---|
encodeData(dataType, jsObject) |
将 JS 对象编码为 Protobuf 二进制 (Uint8Array) |
decodeData(dataType, buffer) |
将 Protobuf 二进制解码为 JS 对象 |
encodeSyncRequest(dataType, data, version) |
构造并编码 SyncRequest 信封 |
decodeSyncRequest(buffer, dataType) |
解码 SyncRequest 信封,返回 { version, data }
|
encodeSyncResponse(responseObj, dataType) |
构造并编码 SyncResponse 信封 |
decodeSyncResponse(buffer, dataType) |
解码 SyncResponse 信封为 JS 对象 |
protobufClient.js 在公共 API 之上封装了 HTTP 相关逻辑:
| 导出 | 说明 |
|---|---|
PROTOBUF_CONTENT_TYPE |
'application/x-protobuf' |
encodeToProtobuf(dataType, data) |
编码数据为 Uint8Array
|
decodeFromProtobuf(dataType, buffer) |
解码 Uint8Array 为 JS 对象 |
createProtobufRequestInit(dataType, body) |
生成包含正确 Content-Type 和编码后 body 的请求配置 |
parseProtobufResponse(response, dataType) |
从 Response 对象中读取并解码 Protobuf 数据 |
Protobuf 协议版本通过 PROTOBUF_PROTOCOL_VERSION 常量(当前值为 1)管理,定义在 shared/proto/index.js 中,前后端共享。版本号会包含在 SyncRequest 信封的 version 字段中,服务端据此判断客户端兼容性。
编解码代码位于 shared/proto/ 目录,前后端共享:
| 文件 | 说明 |
|---|---|
shared/proto/studymiku.proto |
Proto3 消息定义(所有数据类型的 schema) |
shared/proto/gen/studymiku_pb.js |
自动生成的 JS 绑定(由构建工具生成) |
shared/proto/index.js |
编解码 API、枚举映射、DATA_TYPES、PROTOBUF_PROTOCOL_VERSION
|
user_data 表的 data 列存储 Protobuf 二进制数据,dataFormat 列默认值为 'protobuf'。
每个数据类型独立维护版本号。服务端在 user_data 表中存储 version 字段,每次成功写入递增。
flowchart TB
ClientRead["客户端读取: version=3"]
LocalEdit["本地修改数据"]
Upload["上传: {data, version: 3}"]
ServerCheck{"服务端: 当前版本 == 3?"}
Accept["接受写入, version=4"]
Reject["拒绝: 409 Conflict"]
ClientRead --> LocalEdit --> Upload --> ServerCheck
ServerCheck -->|"是"| Accept
ServerCheck -->|"否"| Reject
| 场景 | 客户端 version | 服务端 version | 结果 |
|---|---|---|---|
| 首次写入 | 无 | 无 | 创建数据,version = 1 |
| 正常更新 | 3 | 3 | 接受,version = 4 |
| 版本冲突 | 2 | 3 | 返回 409 + 服务端数据 |
| 强制写入 | null | 任意 | 接受(跳过版本检查) |
客户端版本号存储在 localStorage:swm_sync_version_{dataType}
所有端点需要 Bearer Token 认证,速率限制 30 次/分钟。
:type 支持以下值:focus_records、focus_settings、playlists、user_settings、share_config、hook_settings。
下载指定类型的用户数据。
请求头:
Accept: application/x-protobuf
响应: Content-Type: application/x-protobuf
响应体为 Protobuf 编码的 SyncResponse 信封,包含 type、data、version 字段。
仅获取版本号(轻量级),用于检测是否需要同步。
响应:
{
"type": "focus_records",
"version": 3
}上传/更新数据。
请求头:
Content-Type: application/x-protobuf
请求体: Protobuf 编码的 SyncRequest 信封,包含 data 和 version 字段。
成功响应 (application/x-protobuf):
Protobuf 编码的 SyncResponse,success = true,包含新的 version。
冲突响应 (409, application/x-protobuf):
Protobuf 编码的 SyncResponse,conflict = true,包含 serverData 和 serverVersion。
请求体大小限制 3 MB。
删除指定类型的数据。
响应:
{
"success": true
}文件: src/composables/useDataSync.js
State (Readonly Refs):
| 名称 | 类型 | 说明 |
|---|---|---|
syncStatus |
Object | 各数据类型的同步状态 |
lastSyncTime |
Number | 最后一次成功同步时间戳 |
isSyncing |
Boolean | 是否正在同步 |
pendingChanges |
Array | 离线队列中的待处理变更 |
error |
Object | 当前错误 {code, message, type}
|
Computed:
| 名称 | 类型 | 说明 |
|---|---|---|
hasPendingChanges |
Boolean | 是否有待处理变更 |
isOnline |
Boolean | 网络是否在线 |
canSync |
Boolean | 是否可以同步(已认证且在线) |
Methods:
| 方法 | 说明 |
|---|---|
initialize() |
从 localStorage 恢复队列和同步状态 |
uploadData(type, data, force?) |
上传指定类型数据 |
downloadData(type) |
下载并合并指定类型数据 |
triggerSync() |
全量同步(上传待处理 + 下载全部类型) |
queueChange(type, data) |
将变更加入离线队列 |
processQueue() |
处理离线队列 |
cancelPendingSync() |
取消 debounced 自动同步 |
clearError() |
清除错误状态 |
文件: src/composables/focus/useSyncEngine.js
State (Readonly Refs):
| 名称 | 类型 | 说明 |
|---|---|---|
syncEnabled |
Boolean | 云同步是否启用 |
isSyncing |
Boolean | 是否正在同步 |
lastSyncTime |
Number | 最后同步时间戳 |
serverVersion |
Number | 服务端版本号 |
Computed:
| 名称 | 类型 | 说明 |
|---|---|---|
queueLength |
Number | 变更队列长度 |
Methods:
| 方法 | 说明 |
|---|---|
sync() |
执行完整同步周期 |
queueChange(action, record) |
加入变更队列 (add/update/delete) |
processQueue() |
处理变更队列 |
clearQueue() |
清空变更队列 |
setSyncEnabled(enabled) |
启用/禁用同步 |