Skip to content

Architecture

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

架构概览

本文档介绍 StudyWithMiku 项目的整体架构设计、目录结构和核心设计模式。


目录结构

StudyWithMiku/
├── src/                        # 前端源码
│   ├── components/             # Vue 组件
│   │   ├── common/             # 通用组件
│   │   │   └── UserAvatar.vue  # 多源头像组件
│   │   ├── settings/           # 设置相关组件
│   │   │   └── tabs/           # 设置面板选项卡
│   │   │       ├── stats/      # 统计相关组件
│   │   │       ├── hooks/      # 钩子系统设置组件
│   │   │       └── account/    # 账号相关组件
│   │   │           ├── AccountLoginPanel.vue
│   │   │           ├── AccountProfilePanel.vue
│   │   │           ├── AccountDeviceList.vue
│   │   │           ├── AccountSyncPanel.vue
│   │   │           ├── OAuthButton.vue
│   │   │           └── AccountDeleteConfirm.vue
│   │   ├── Toast.vue           # 通知组件
│   │   ├── StatusPill.vue      # 状态胶囊
│   │   └── ...
│   │
│   ├── composables/            # Vue Composables(组合式函数)
│   │   ├── focus/              # 番茄钟子模块
│   │   │   ├── constants.js    # 常量定义
│   │   │   ├── useTimer.js     # 计时器实现
│   │   │   ├── useSession.js   # 状态机管理
│   │   │   ├── useRecords.js   # 记录管理
│   │   │   ├── useStats.js     # 统计计算
│   │   │   ├── useSyncEngine.js # 专注记录同步引擎
│   │   │   └── eventBus.js     # 专注事件总线
│   │   │
│   │   ├── hooks/              # 钩子系统
│   │   │   ├── constants.js    # 枚举和存储键
│   │   │   ├── hookEngine.js   # 匹配与分发
│   │   │   ├── providerRegistry.js # Provider 注册表
│   │   │   ├── useHooks.js     # 主 composable
│   │   │   ├── presets.js      # 默认钩子和预设
│   │   │   └── providers/      # Provider 实现(4 个)
│   │   │
│   │   ├── useFocus.js         # 番茄钟统一入口(外观模式)
│   │   ├── usePlayer.js        # 播放器状态管理
│   │   ├── useMusic.js         # 音乐源管理
│   │   ├── usePlaylistManager.js # 歌单管理
│   │   ├── useToast.js         # 通知系统
│   │   ├── useAuth.js          # 认证状态管理
│   │   ├── useDataSync.js      # 云端数据同步
│   │   ├── useAppBootstrap.js  # 应用启动引导
│   │   ├── usePWA.js           # PWA 功能管理
│   │   └── ...
│   │
│   ├── player/                 # 播放器抽象层
│   │   ├── PlayerAdapter.js    # 抽象基类
│   │   ├── adapters/           # 具体适配器
│   │   │   ├── APlayerAdapter.js
│   │   │   └── SpotifyAdapter.js
│   │   ├── mediaSessionBridge.js # Media Session 桥接
│   │   └── constants.js        # 播放器常量
│   │
│   ├── services/               # 服务层
│   │   ├── meting.js           # Meting API 集成
│   │   ├── spotify.js          # Spotify API
│   │   ├── localAudioStorage.js # 本地音频存储
│   │   ├── playlistImportExport.js # 导入导出
│   │   ├── onlineServer.js     # 在线计数服务
│   │   ├── runtimeConfig.js    # 运行时配置
│   │   ├── auth.js             # 认证 API 服务
│   │   ├── dataSync.js         # 数据同步 API 服务
│   │   └── migration.js        # 数据迁移服务
│   │
│   ├── config/                 # 配置
│   │   ├── constants.js        # 全局常量
│   │   └── oauthProviders.js   # OAuth 提供商配置
│   │
│   ├── dev/                    # 开发者控制台
│   │   ├── index.js            # swm_dev 入口
│   │   └── help/               # 帮助系统
│   │       ├── index.js
│   │       ├── introspection.js
│   │       └── formatter.js
│   │
│   ├── utils/                  # 工具函数
│   │   ├── storage.js          # localStorage 封装
│   │   ├── cache.js            # 缓存工具
│   │   ├── exportUtils.js      # 导出工具
│   │   ├── authStorage.js      # Token/认证信息存储
│   │   ├── protobufClient.js   # Protobuf 编解码客户端
│   │   ├── syncConflictResolver.js # 同步冲突解决
│   │   └── webauthnHelper.js   # WebAuthn 浏览器 API 封装
│   │
│   ├── types/                  # 类型定义(JSDoc)
│   │   ├── playlist.js
│   │   └── music.js
│   │
│   ├── styles/                 # 全局样式
│   └── main.js                 # 应用入口
│
├── shared/                     # 前后端共享代码
│   └── proto/                  # Protobuf 编解码(前后端共享)
│       ├── studymiku.proto     # Proto3 消息定义
│       ├── gen/studymiku_pb.js # 自动生成的 JS 绑定
│       └── index.js            # 编解码 API、枚举映射
│
├── workers/                    # Cloudflare Workers 后端
│   ├── index.js                # Worker 入口(Hono 应用)
│   ├── constants.js            # 后端常量
│   ├── auth-challenge.js       # Durable Object: WebAuthn 挑战
│   ├── rate-limiter.js         # Durable Object: 速率限制
│   ├── online-counter.js       # Durable Object: 在线计数
│   ├── focus-notifier.js       # Durable Object: 番茄钟推送通知
│   ├── db/                     # 数据库
│   │   ├── index.js            # Drizzle 客户端工厂
│   │   └── schema.js           # 表定义(6 张表)
│   ├── middleware/             # 中间件(5 个)
│   │   ├── envDefaults.js
│   │   ├── securityHeaders.js
│   │   ├── cors.js
│   │   ├── auth.js
│   │   └── rateLimit.js
│   ├── routes/                 # 路由
│   │   ├── auth/               # 认证路由组(7 个文件)
│   │   ├── oauth.js            # OAuth 路由
│   │   ├── data.js             # 数据同步路由
│   │   └── push.js             # Web Push 推送路由
│   ├── services/               # 业务逻辑(9+1 个服务)
│   ├── schemas/                # Zod 验证模式
│   │   └── auth.js
│   └── utils/                  # 工具函数
│       ├── authHelpers.js
│       ├── cookie.js
│       ├── avatar.js
│       └── protobufServer.js   # Protobuf 编解码封装
│
├── migrations/                 # D1 数据库迁移
│   ├── 0000_strong_lily_hollister.sql
│   └── meta/
│
├── tests/                      # 测试
│   ├── unit/                   # 单元测试
│   ├── integration/            # 集成测试
│   ├── e2e/                    # E2E 测试
│   └── setup/                  # 测试配置
│
├── public/                     # 静态资源
└── scripts/                    # 构建脚本

整体架构图

前端架构

flowchart TB
    subgraph VueComponents["Vue Components"]
        Settings["Settings"]
        StatusPill["StatusPill"]
        Toast["Toast"]
        Player["Player"]
        AccountPanels["Account Panels"]
        Others["..."]
    end

    subgraph ComposablesLayer["Composables Layer"]
        useFocus["useFocus (Facade)"]
        usePlayer["usePlayer (State Mgmt)"]
        useMusic["useMusic (Cache Layer)"]
        useAppBootstrap["useAppBootstrap"]

        subgraph FocusSub["Focus 子模块"]
            useSession["useSession"]
            useTimer["useTimer"]
            useRecords["useRecords"]
            useStats["useStats"]
        end

        PlayerAdapter["PlayerAdapter (Adapter)"]
        MetingService["Meting Service"]

        subgraph AuthLayer["Auth Layer"]
            useAuth["useAuth (Singleton)"]
            authStorage["authStorage"]
        end

        subgraph SyncLayer["Sync Layer"]
            useDataSync["useDataSync"]
            useSyncEngine["useSyncEngine"]
            protobufClient["protobufClient"]
        end

        subgraph HooksLayer["Hooks Layer"]
            useHooks["useHooks (Singleton)"]
            hookEngine["hookEngine"]
            providerRegistry["providerRegistry"]
            focusEventBus["focusEventBus"]
        end
    end

    subgraph ServicesLayer["Services Layer"]
        localStorage["localStorage (Settings)"]
        IndexedDB["IndexedDB (Handles)"]
        OPFS["OPFS (Audio)"]
        CacheAPI["Cache API (SW Cache)"]
        D1Cloud["D1 Cloud (Sync)"]
    end

    VueComponents --> ComposablesLayer
    useFocus --> FocusSub
    usePlayer --> PlayerAdapter
    useMusic --> MetingService
    AccountPanels --> AuthLayer
    AccountPanels --> SyncLayer
    useSyncEngine --> useDataSync
    useDataSync --> protobufClient
    useFocus --> focusEventBus
    focusEventBus --> useHooks
    useHooks --> hookEngine
    hookEngine --> providerRegistry
    ComposablesLayer --> ServicesLayer
    AuthLayer -->|"HTTPS"| D1Cloud
    SyncLayer -->|"Protobuf"| D1Cloud
Loading

前后端数据流

flowchart LR
    subgraph Browser["Browser"]
        VueApp["Vue App"]
        SwmDev["swm_dev"]
    end

    subgraph CFWorkers["Cloudflare Workers"]
        subgraph MiddlewareStack["Middleware"]
            envDefaults["envDefaults"]
            securityHeaders["securityHeaders"]
            cors["CORS"]
            authMW["Auth (JWT)"]
            rateLimitMW["Rate Limit"]
        end

        HonoRouter["Hono.js Router"]

        subgraph DurableObjects["Durable Objects"]
            OnlineCounter["OnlineCounter"]
            AuthChallenge["AuthChallenge"]
            RateLimiter["RateLimiter"]
            FocusNotifier["FocusNotifier"]
        end

        subgraph D1DB["D1 Database"]
            Users["users"]
            Credentials["credentials"]
            OAuthAccounts["oauth_accounts"]
            UserData["user_data"]
            TokenBlacklist["token_blacklist"]
            PushSubscriptions["push_subscriptions"]
        end

        MiddlewareStack --> HonoRouter
        HonoRouter --> DurableObjects
        HonoRouter --> D1DB
    end

    subgraph ThirdParty["Third Party"]
        MetingAPI["网易云/QQ音乐"]
        OAuthProviders["OAuth Providers"]
    end

    Browser <-->|"WebSocket"| CFWorkers
    Browser <-->|"REST API"| CFWorkers
    Browser -->|"Meting API"| MetingAPI
    CFWorkers <-->|"OAuth 2.0"| OAuthProviders
Loading

核心设计模式

1. 适配器模式(Adapter Pattern)

应用场景:播放器系统

问题:不同的音乐播放器(APlayer、Spotify Embed)有完全不同的 API 接口,如何让上层代码使用统一的方式控制它们?

解决方案:定义统一的 PlayerAdapter 抽象基类,所有具体播放器实现同一套接口。

// src/player/PlayerAdapter.js
export class PlayerAdapter {
  // 统一接口定义
  async play() { throw new Error('子类必须实现') }
  async pause() { throw new Error('子类必须实现') }
  async seek(time) { throw new Error('子类必须实现') }
  async loadPlaylist(tracks) { throw new Error('子类必须实现') }

  // 内置事件系统
  on(event, callback) { /* ... */ }
  emit(event, data) { /* ... */ }

  // 能力查询
  supportsSeek() { return true }
  hasBuiltInUI() { return false }
}

// src/player/adapters/APlayerAdapter.js
export class APlayerAdapter extends PlayerAdapter {
  async play() {
    this.aplayer.play()
  }
  // ... 其他方法实现
}

// src/player/adapters/SpotifyAdapter.js
export class SpotifyAdapter extends PlayerAdapter {
  async play() {
    this.embedController.play()
  }
  // ... 其他方法实现
}

优势:

  • 上层代码无需关心底层播放器的差异
  • 新增播放器只需实现 PlayerAdapter 接口
  • 便于测试和 mock

2. 外观模式(Facade Pattern)

应用场景:番茄钟系统

问题:番茄钟系统由多个子模块组成(计时器、状态机、记录、统计),直接暴露所有模块会增加使用复杂度。

解决方案:useFocus.js 作为外观,整合所有子模块,提供统一的简洁 API。

// src/composables/useFocus.js
export const useFocus = () => {
  // 内部整合多个子模块
  const session = useSession()   // 状态机管理
  const records = useRecords()   // 记录管理
  const stats = useStats()       // 统计计算

  // 对外暴露统一 API
  return {
    // 状态
    state: session.state,
    mode: session.mode,
    elapsed: session.elapsed,
    remaining: session.remaining,

    // 操作
    start: session.start,
    pause: session.pause,
    resume: session.resume,

    // 统计
    todayStats: stats.todayStats,
    weekStats: stats.weekStats,

    // 记录
    records: records.records,
    queryRecords: records.queryRecords,

    // 导入导出
    exportData,
    importData
  }
}

优势:

  • 使用者只需调用 useFocus() 即可获取所有功能
  • 隐藏内部实现复杂性
  • 便于后续重构内部模块

3. 状态机模式(State Machine Pattern)

应用场景:番茄钟会话管理

问题:番茄钟有多种状态(空闲、运行、暂停)和模式(专注、短休息、长休息),状态转换规则复杂。

解决方案:使用状态机管理状态转换,确保状态变更的合法性。

stateDiagram-v2
    [*] --> IDLE
    IDLE --> RUNNING : start()
    RUNNING --> PAUSED : pause()
    RUNNING --> IDLE : cancel()
    PAUSED --> RUNNING : resume()
    PAUSED --> IDLE : cancel()
    RUNNING --> IDLE : 完成时自动回到 IDLE 并切换模式
Loading
flowchart LR
    FOCUS1["FOCUS"] -->|"完成"| SHORT_BREAK
    SHORT_BREAK -->|"完成"| FOCUS2["FOCUS"]
    FOCUS1 -->|"每4次专注后"| LONG_BREAK
    LONG_BREAK -->|"完成"| FOCUS2
Loading
// src/composables/focus/useSession.js

// 状态定义
const FocusState = {
  IDLE: 'idle',
  RUNNING: 'running',
  PAUSED: 'paused'
}

// 状态转换
const start = () => {
  if (state.value !== FocusState.IDLE) {
    return { success: false, error: 'Session already in progress' }
  }
  state.value = FocusState.RUNNING
  timer.start(duration)
  return { success: true }
}

const pause = () => {
  if (state.value !== FocusState.RUNNING) {
    return { success: false, error: 'Session not running' }
  }
  timer.pause()
  state.value = FocusState.PAUSED
  return { success: true }
}

优势:

  • 状态转换规则清晰明确
  • 防止非法状态转换
  • 便于添加新状态或修改转换规则

4. 单例模式(Singleton Pattern)

应用场景:模块级状态管理

问题:多个组件需要共享同一份状态(如番茄钟状态、播放器状态),如何确保状态一致性?

解决方案:利用 ES Module 的特性,在模块顶层定义 ref(),所有导入该模块的代码共享同一份状态。

// src/composables/focus/useSession.js

// 模块级单例状态(在模块顶层定义)
const state = ref(FocusState.IDLE)
const mode = ref(FocusMode.FOCUS)
const settings = ref({ ...DEFAULT_SETTINGS })
const sessionCount = ref(0)

let initialized = false

export const useSession = () => {
  // 首次调用时初始化
  if (!initialized) {
    initializeSettings()
    initialized = true
  }

  // 返回共享状态的引用
  return {
    state: readonly(state),
    mode: readonly(mode),
    settings: readonly(settings),
    // ...
  }
}

为什么不用 Pinia/Vuex?

  • 项目规模适中,模块级单例足够
  • 减少依赖,降低复杂度
  • Composable 方式更贴近 Vue 3 的设计理念

优势:

  • 零依赖,无需额外状态管理库
  • 代码简洁,易于理解
  • 与 Vue 响应式系统无缝集成

5. 观察者模式(Observer Pattern)

应用场景:Media Session 桥接、播放器事件系统

问题:播放器状态变化时,需要同步更新 Media Session(系统媒体控制);系统媒体控制操作时,需要反向控制播放器。

解决方案:

  1. 播放器事件系统:PlayerAdapter 内置 on/emit/off 事件机制(实现见 Player-System)
  2. Media Session 桥接:使用 Vue watch 监听响应式状态变化
// src/player/mediaSessionBridge.js
export function setupMediaSession(player) {
  const unwatchers = []

  // 播放器状态 → Media Session
  unwatchers.push(
    watch(
      () => player.currentTrack.value,
      (track) => {
        navigator.mediaSession.metadata = new MediaMetadata({
          title: track.name,
          artist: track.artist,
          artwork: [{ src: track.cover }]
        })
      }
    )
  )

  // Media Session 操作 → 播放器
  navigator.mediaSession.setActionHandler('play', () => player.play())
  navigator.mediaSession.setActionHandler('pause', () => player.pause())
  navigator.mediaSession.setActionHandler('nexttrack', () => player.skipNext())

  return () => {
    unwatchers.forEach(unwatch => unwatch())
  }
}

双向绑定流程:

flowchart LR
    usePlayer["usePlayer (Vue Refs)"]
    MediaSession["Media Session (System API)"]

    usePlayer -->|"watch()"| MediaSession
    MediaSession -->|"setActionHandler()"| usePlayer
Loading

优势:

  • 松耦合,播放器不依赖 Media Session
  • 基于 Vue 响应式,自动追踪依赖
  • 便于清理和资源释放

6. Provider Config Registry Pattern

应用场景:OAuth 集成

问题:系统支持多个 OAuth 提供商,每个提供商有不同的 URL、Scope 和 UI 元数据,如何统一管理?

解决方案:将提供商配置集中到注册表对象中,通过 provider key 查找:

// src/config/oauthProviders.js — 前端 UI 元数据
const OAUTH_PROVIDERS = {
  github: { label: 'GitHub', icon: 'mdi:github', hoverBg: '...' },
  google: { label: 'Google', icon: 'flat-color-icons:google', ... },
  // ...
}

export const getProviderMeta = (provider) =>
  OAUTH_PROVIDERS[provider] || { label: provider, icon: 'mdi:account' }
// workers/constants.js — 后端 OAuth 配置
const OAUTH_CONFIG = {
  GITHUB: { AUTHORIZE_URL, TOKEN_URL, USER_URL, SCOPE },
  GOOGLE: { ... },
  // ...
}

新增 OAuth 提供商只需在两个注册表中添加配置条目。


7. Data-Driven Validation

应用场景:后端数据验证

问题:不同 API 端点接受不同的数据格式,如何避免在每个路由中重复编写验证逻辑?

解决方案:使用 Zod 定义 Schema,通过数据类型映射表自动选择对应的 Schema:

// workers/schemas/auth.js
const dataTypeSchemas = {
  focus_records: focusRecordsSchema,
  focus_settings: focusSettingsSchema,
  playlists: playlistsDataSchema,
  user_settings: userSettingsSchema,
  share_config: shareConfigSchema
}

// 路由中使用
const schema = dataTypeSchemas[dataType]
const result = schema.safeParse(data)

新增数据类型只需在 Schema 映射表中添加条目,路由代码无需修改。


8. 事件总线模式(Event Bus Pattern)

应用场景:专注系统与钩子系统的解耦

问题:番茄钟状态机的状态转换需要通知外部系统(通知、提示音、推送、电刺激),但不应直接依赖这些系统。

解决方案:focusEventBus 作为轻量级 pub/sub 中间层。

// src/composables/focus/eventBus.js
export const focusEventBus = {
  on(event, callback) { /* 订阅 */ },
  emit(event, payload) { /* 发布 */ },
  clear(event) { /* 清理 */ }
}

// 发布方 (useSession)
focusEventBus.emit('transition', { action: 'complete', mode: 'focus' })

// 订阅方 (useHooks)
focusEventBus.on('transition', (payload) => {
  handleFocusEvent(payload.action, payload)
})

优势:

  • 发布方和订阅方完全解耦,钩子系统可独立加载/卸载

9. Provider Registry 模式(Provider Registry Pattern)

应用场景:钩子系统的 Action Provider 管理

问题:钩子系统需支持多种通知方式(通知、提示音、推送、电刺激),每种方式有不同的实现和可用性条件。

解决方案:providerRegistry 作为 Map 单例,统一管理 Provider 的注册、查找和生命周期。

// Provider 接口
const provider = {
  id: 'notification',
  init() { /* 初始化 */ },
  isAvailable() { return 'Notification' in window },
  execute(hook, context) { /* 执行 */ },
  destroy() { /* 清理 */ }
}

// 注册
providerRegistry.register(provider)

// 使用
const p = providerRegistry.get(hook.provider)
if (p?.isAvailable?.()) p.execute(hook, context)

优势:

  • 新增 Provider 只需实现接口并注册,核心调度逻辑无需修改。estim provider 按需延迟加载。