Skip to content

Cloud Sync

gxxk-dev edited this page Mar 1, 2026 · 2 revisions

云同步系统

本文档详细介绍 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
Loading

数据类型

系统支持 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
Loading

下载数据

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: 更新本地版本号
Loading

完整同步 (triggerSync)

triggerSync() 是手动触发的全量同步,执行以下步骤:

  1. 检查是否已认证且在线
  2. 处理离线队列中的待上传数据
  3. 逐类型上传本地有变更的数据
  4. 逐类型下载服务端最新数据
  5. 合并并写入本地存储

专注记录同步引擎

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。


冲突解决

当客户端版本号与服务端不匹配时触发冲突解决。各数据类型使用不同的合并策略:

focus_records — ID 去重 + LWW

// 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)
}

playlists — 目标优先合并

歌单合并以目标账号(当前登录)的数据为主,补充源账号中不存在的歌单。

focus_settings / user_settings — LWW + Deep Merge

  1. 先按版本号做 Last-Write-Wins(高版本优先)
  2. 如果版本冲突,执行 deep merge:服务端值覆盖同名字段,保留本地独有字段

share_config — 纯 LWW

仅按版本号做 Last-Write-Wins,不做深度合并。


离线队列

队列机制

当用户离线时,数据变更被缓存到 localStorage 的队列中:

// 队列条目格式
{
  id: string,          // 唯一标识
  type: string,        // 数据类型 (focus_records, playlists, ...)
  data: any,           // 变更数据
  version: number,     // 本地版本号
  timestamp: number,   // 变更时间
  operation: string    // 操作类型
}

Debounced 自动同步

数据变更后不会立即同步,而是通过 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
Loading

Protobuf 编码

为什么使用 Protobuf

Protocol Buffers (Protobuf) 是 Google 开发的高效二进制序列化格式,相比 JSON:

  • 编码体积显著减小(强类型 + varint 编码 + 字段编号替代字符串键)
  • 严格的 schema 定义,前后端通过 .proto 文件共享类型约定
  • 自动生成的编解码代码,减少手工映射出错
  • 原生支持枚举、嵌套消息、oneof 等丰富类型

Proto 定义

消息定义位于 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
Loading

编码时,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',
}

公共编解码 API

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_TYPESPROTOBUF_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
Loading

客户端/服务端版本比对

场景 客户端 version 服务端 version 结果
首次写入 创建数据,version = 1
正常更新 3 3 接受,version = 4
版本冲突 2 3 返回 409 + 服务端数据
强制写入 null 任意 接受(跳过版本检查)

客户端版本号存储在 localStorage:swm_sync_version_{dataType}


API 端点参考

所有端点需要 Bearer Token 认证,速率限制 30 次/分钟。

:type 支持以下值:focus_recordsfocus_settingsplaylistsuser_settingsshare_confighook_settings

GET /api/data/:type

下载指定类型的用户数据。

请求头:

  • Accept: application/x-protobuf

响应: Content-Type: application/x-protobuf

响应体为 Protobuf 编码的 SyncResponse 信封,包含 typedataversion 字段。

GET /api/data/:type/version

仅获取版本号(轻量级),用于检测是否需要同步。

响应:

{
  "type": "focus_records",
  "version": 3
}

PUT /api/data/:type

上传/更新数据。

请求头:

  • Content-Type: application/x-protobuf

请求体: Protobuf 编码的 SyncRequest 信封,包含 dataversion 字段。

成功响应 (application/x-protobuf):

Protobuf 编码的 SyncResponsesuccess = true,包含新的 version

冲突响应 (409, application/x-protobuf):

Protobuf 编码的 SyncResponseconflict = true,包含 serverDataserverVersion

请求体大小限制 3 MB。

DELETE /api/data/:type

删除指定类型的数据。

响应:

{
  "success": true
}

前端 Composable 参考

useDataSync

文件: 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() 清除错误状态

useSyncEngine

文件: 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) 启用/禁用同步

Clone this wiki locally