-
Notifications
You must be signed in to change notification settings - Fork 0
Backend Architecture
本文档介绍 StudyWithMiku 后端的完整架构,包括 Cloudflare Workers 运行环境、Hono.js 路由结构、中间件栈、Durable Objects、D1 数据库以及 API 端点参考。
后端运行在 Cloudflare Workers 上,使用 Hono.js 作为 Web 框架,提供认证、数据同步、在线计数等服务。核心设计思路是利用 Cloudflare 的边缘计算能力,将有状态逻辑(认证挑战、速率限制、在线计数)托管在 Durable Objects 中,将持久化数据存储在 D1 (SQLite) 数据库中。
flowchart TB
subgraph Client["浏览器"]
VueApp["Vue App"]
end
subgraph CFWorkers["Cloudflare Workers"]
Hono["Hono.js App"]
subgraph Middleware["中间件栈"]
envDefaults["envDefaults"]
securityHeaders["securityHeaders"]
cors["CORS"]
auth["Auth (JWT)"]
rateLimit["Rate Limit"]
end
subgraph Routes["路由"]
AuthRoutes["/auth/*"]
OAuthRoutes["/oauth/*"]
DataRoutes["/api/data/*"]
CountRoutes["/count, /ws"]
PushRoutes["/api/push/*"]
end
subgraph DurableObjects["Durable Objects"]
OnlineCounter["OnlineCounter"]
AuthChallenge["AuthChallenge"]
RateLimiter["RateLimiter"]
FocusNotifier["FocusNotifier"]
end
subgraph Database["D1 Database"]
Users["users"]
Credentials["credentials"]
OAuthAccounts["oauth_accounts"]
TokenBlacklist["token_blacklist"]
UserData["user_data"]
PushSubscriptions["push_subscriptions"]
end
end
subgraph OAuthProviders["OAuth Providers"]
GitHub["GitHub"]
Google["Google"]
Microsoft["Microsoft"]
LinuxDo["LINUX DO"]
end
Client -->|"HTTPS / WebSocket"| Hono
Hono --> Middleware
Middleware --> Routes
Routes --> DurableObjects
Routes --> Database
OAuthRoutes -->|"OAuth 2.0"| OAuthProviders
workers/
├── index.js # Worker 入口,Hono 应用创建与路由挂载
├── constants.js # 全局常量(WebAuthn、JWT、OAuth、速率限制、数据配置)
├── auth-challenge.js # Durable Object: WebAuthn 挑战存储
├── rate-limiter.js # Durable Object: 分布式速率限制
├── online-counter.js # Durable Object: WebSocket 在线计数
├── focus-notifier.js # Durable Object: 番茄钟推送通知
│
├── db/
│ ├── index.js # Drizzle ORM 客户端工厂
│ └── schema.js # 数据库表定义(6 张表)
│
├── middleware/
│ ├── envDefaults.js # 环境变量自动检测
│ ├── securityHeaders.js # 安全响应头
│ ├── cors.js # CORS 跨域配置
│ ├── auth.js # JWT 认证中间件
│ └── rateLimit.js # 速率限制中间件
│
├── routes/
│ ├── auth/
│ │ ├── index.js # 认证路由聚合
│ │ ├── register.js # WebAuthn 注册端点
│ │ ├── login.js # WebAuthn 登录端点
│ │ ├── token.js # Token 刷新与注销
│ │ ├── profile.js # 用户资料管理
│ │ ├── devices.js # 设备管理与账号合并
│ │ └── methods.js # 认证方法列表
│ ├── oauth.js # OAuth 流程(4 个提供商)
│ ├── data.js # 用户数据同步端点
│ └── push.js # Web Push 推送路由
│
├── services/
│ ├── user.js # 用户 CRUD
│ ├── jwt.js # JWT 生成与验证
│ ├── webauthn.js # WebAuthn 凭证验证
│ ├── credential.js # WebAuthn 凭证 CRUD
│ ├── oauth.js # OAuth 提供商集成
│ ├── oauthAccount.js # OAuth 账号 CRUD
│ ├── userData.js # 用户数据存储与同步
│ ├── merge.js # 账号合并逻辑
│ ├── counter.js # 在线计数 DO 访问器
│ └── push.js # Web Push 推送服务
│
├── schemas/
│ └── auth.js # Zod 验证模式
│
└── utils/
├── authHelpers.js # 认证辅助函数
├── cookie.js # Refresh Token Cookie 工具
├── avatar.js # 头像 URL 构建
└── protobufServer.js # Protobuf 编解码封装
flowchart LR
Request["HTTP Request"]
envDefaults["envDefaults()"]
securityHeaders["securityHeaders()"]
cors["cors()"]
rateLimit["rateLimit()"]
auth["requireAuth()"]
Handler["Route Handler"]
Response["HTTP Response"]
Request --> envDefaults --> securityHeaders --> cors
cors --> rateLimit --> auth --> Handler --> Response
不是所有路由都经过全部中间件。全局中间件只有 envDefaults 和 securityHeaders,CORS 仅应用于特定路径前缀,rateLimit 和 auth 按路由组配置。
// workers/index.js
const app = new Hono()
// 全局中间件
app.use('*', envDefaults())
app.use('*', securityHeaders())
// 按路径前缀启用 CORS
app.use('/ws', cors(corsConfig))
app.use('/count', cors(corsConfig))
app.use('/auth/*', cors(corsConfig))
app.use('/oauth/*', cors(corsConfig))
app.use('/api/*', cors(corsConfig))
// 路由挂载
app.get('/count', handleCount) // 在线人数
app.get('/ws', handleWebSocket) // WebSocket 连接
app.route('/auth', authRoutes) // 认证路由组
app.route('/oauth', oauthRoutes) // OAuth 路由组
app.route('/api/data', dataRoutes) // 数据同步路由组
// 全局错误处理
app.onError((err, c) => {
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
})
export default app
export { OnlineCounter, AuthChallenge, RateLimiter, FocusNotifier }从请求 URL 中自动推断环境变量,减少手动配置:
| 变量 | 推断方式 |
|---|---|
WEBAUTHN_RP_ID |
从 request.url 的 hostname 提取 |
WEBAUTHN_RP_NAME |
默认 'Study with Miku'
|
OAUTH_CALLBACK_BASE |
从 request.url 的 origin 提取 |
为所有响应添加安全头:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
- 解析
CORS_ORIGINS环境变量 - 开发环境自动允许
localhost - 自动允许同源请求
- 处理
OPTIONS预检请求 - 允许方法:
GET, POST, PUT, DELETE, OPTIONS - 允许头:
Content-Type, Authorization
提供两种认证模式:
| 中间件 | 行为 |
|---|---|
requireAuth() |
必须提供有效 JWT,否则 401 |
optionalAuth() |
有 JWT 则解析,没有也放行 |
认证流程:
- 从
Authorization: Bearer <token>提取 token - 验证签名和过期时间
- 检查 token 是否在黑名单中
- 设置
c.set('user', { id, username, jti, exp })
通过 RateLimiter Durable Object 实现分布式速率限制:
| 类型 | 窗口 | 上限 | 适用路由 |
|---|---|---|---|
| AUTH | 60s | 10 次/分 | /auth/* |
| DATA | 60s | 30 次/分 | /api/data/* |
| GENERAL | 60s | 100 次/分 | 其他 API |
超过限制返回 429 Too Many Requests,响应包含 X-RateLimit-* 头和 Retry-After。
文件: workers/online-counter.js
职责: 通过 WebSocket 追踪实时在线用户数并广播给所有连接的客户端。
工作机制:
- 客户端通过
/ws建立 WebSocket 连接 - 使用 DO 内置的 WebSocket 管理 (
this.state.getWebSockets()) - 每次连接/断开时向所有客户端广播当前连接数
- 响应
ping消息保持连接活跃 -
GET /count返回当前在线人数(不需要 WebSocket)
文件: workers/auth-challenge.js
职责: 临时存储 WebAuthn 注册/登录挑战,以及账号合并 Token。
工作机制:
-
PUT— 存储挑战(5 分钟 TTL)或合并 Token(10 分钟 TTL) -
GET— 读取挑战/Token -
DELETE— 验证后立即删除(防止重放攻击) -
alarm()— 定时清理过期条目
每个挑战在验证后立即删除,确保一次性使用。
文件: workers/rate-limiter.js
职责: 按 IP + 端点组合进行分布式速率限制。
工作机制:
- 每个
keyPrefix:ip组合对应一个隔离的 DO 实例 - 在滑动窗口内追踪请求计数
- 返回速率限制状态:
{
allowed: boolean, // 是否放行
count: number, // 当前窗口请求数
remaining: number, // 剩余配额
resetTime: number, // 窗口重置时间
retryAfter: number // 超限后等待秒数
}文件: workers/focus-notifier.js
职责: 服务端番茄钟状态机,当客户端关闭后通过 Durable Object Alarm 定时触发推送通知。
工作机制:
- 客户端通过 Push 路由 schedule 请求
- DO 内部维护 running/paused/idle 状态和 alarm
- alarm 触发时发送 Web Push 通知
- 支持自动切换到下一阶段(autoStartBreaks/autoStartFocus)
API: /schedule, /pause, /resume, /cancel, /update-settings, /state
使用 Drizzle ORM 定义 6 张表,通过 drizzle-kit 管理迁移。
erDiagram
users ||--o{ credentials : "1:N"
users ||--o{ oauth_accounts : "1:N"
users ||--o{ user_data : "1:N"
users ||--o{ push_subscriptions : "1:N"
users {
text id PK
text username UK
text displayName
text avatarUrl
text email
text qqNumber
}
credentials {
text id PK
text userId FK
blob publicKey
integer counter
text transports
text deviceType
text deviceName
integer backedUp
integer lastUsedAt
}
oauth_accounts {
text id PK
text userId FK
text provider
text providerId
text displayName
text avatarUrl
text email
integer linkedAt
}
token_blacklist {
text jti PK
integer expiresAt
}
user_data {
text userId PK
text dataType PK
blob data
text dataFormat
integer version
}
push_subscriptions {
text id PK
text userId FK
text endpoint
text p256dh
text auth
integer createdAt
text userAgent
}
| 列 | 类型 | 说明 |
|---|---|---|
| id | text PK | 随机生成的 hex ID |
| username | text UNIQUE | 用户名(3-20 位字母数字下划线) |
| displayName | text | 显示名称 |
| avatarUrl | text | 头像 URL |
| text | 电子邮件 | |
| qqNumber | text | QQ 号(用于 QQ 头像) |
索引: idx_users_username (username)
| 列 | 类型 | 说明 |
|---|---|---|
| id | text PK | 凭证 ID (Base64URL) |
| userId | text FK | 关联用户,级联删除 |
| publicKey | blob | 公钥 |
| counter | integer | 签名计数器(克隆检测) |
| transports | text | 传输方式 JSON (internal/usb/ble/nfc) |
| deviceType | text | 设备类型 |
| deviceName | text | 设备名称(自动生成) |
| backedUp | integer | 是否已备份 (0/1) |
| lastUsedAt | integer | 最后使用时间戳 |
索引: idx_credentials_user_id (userId)
| 列 | 类型 | 说明 |
|---|---|---|
| id | text PK | 随机 hex ID |
| userId | text FK | 关联用户,级联删除 |
| provider | text | 提供商 (github/google/microsoft/linuxdo) |
| providerId | text | 提供商用户 ID |
| displayName | text | 提供商显示名 |
| avatarUrl | text | 提供商头像 |
| text | 提供商邮箱 | |
| linkedAt | integer | 关联时间戳 |
唯一索引: idx_oauth_accounts_provider (provider, providerId)
| 列 | 类型 | 说明 |
|---|---|---|
| jti | text PK | JWT ID |
| expiresAt | integer | 过期时间戳(用于自动清理) |
索引: idx_token_blacklist_expires (expiresAt)
Token 黑名单通过概率性清理保持精简:每次登录有 10% 概率触发过期条目清理。
| 列 | 类型 | 说明 |
|---|---|---|
| userId | text PK | 关联用户 |
| dataType | text PK | 数据类型 |
| data | blob | Protobuf 编码的二进制数据 |
| dataFormat | text | 格式标识 ('protobuf') |
| version | integer | 乐观并发版本号 |
复合主键: (userId, dataType)
| 列 | 类型 | 说明 |
|---|---|---|
| id | text PK | 订阅 ID |
| userId | text FK | 关联用户,级联删除 |
| endpoint | text | 推送服务端点 URL |
| p256dh | text | 客户端公钥 |
| auth | text | 认证密钥 |
| createdAt | integer | 创建时间戳 |
| userAgent | text | 用户代理字符串 |
索引: idx_push_subs_user_id (userId)
唯一索引: idx_push_subs_endpoint (endpoint)
// workers/db/index.js
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './schema.js'
export const createDb = (d1) => drizzle(d1, { schema })在路由中使用:
// workers/routes/auth/profile.js
const db = createDb(c.env.DB)
const user = await db.select().from(users).where(eq(users.id, userId))| 索引 | 表 | 列 | 用途 |
|---|---|---|---|
| idx_users_username | users | username | 登录时按用户名查找 |
| idx_credentials_user_id | credentials | userId | 查询用户的所有凭证 |
| idx_oauth_accounts_user_id | oauth_accounts | userId | 查询用户的 OAuth 关联 |
| idx_oauth_accounts_provider | oauth_accounts | provider, providerId | OAuth 登录时查找关联(唯一) |
| idx_token_blacklist_expires | token_blacklist | expiresAt | 清理过期黑名单条目 |
| idx_push_subs_user_id | push_subscriptions | userId | 查询用户的推送订阅 |
| idx_push_subs_endpoint | push_subscriptions | endpoint | 按端点查找订阅(唯一) |
name = "study-with-miku"
main = "workers/index.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
# 静态资源
assets = { directory = "./dist", binding = "ASSETS" }
# 自定义域名
[routes]
pattern = "swm.frez79.io"
zone_name = "frez79.io"
custom_domain = true| Binding | 类型 | 用途 |
|---|---|---|
ASSETS |
Static Assets | 前端构建产物 |
DB |
D1 Database | 用户数据持久化 |
ONLINE_COUNTER |
Durable Object | 在线计数服务 |
AUTH_CHALLENGE |
Durable Object | WebAuthn 挑战存储 |
RATE_LIMITER |
Durable Object | 速率限制 |
FOCUS_NOTIFIER |
Durable Object | 番茄钟推送通知 |
| 变量 | 说明 | 自动推断 |
|---|---|---|
JWT_SECRET |
JWT 签名密钥 | 否(Secret) |
CORS_ORIGINS |
允许的跨域来源 | 部分(自动检测 localhost) |
WEBAUTHN_RP_ID |
WebAuthn 信赖方 ID | 是(从请求 hostname) |
WEBAUTHN_RP_NAME |
WebAuthn 信赖方名称 | 是(默认 'Study with Miku') |
OAUTH_CALLBACK_BASE |
OAuth 回调基础 URL | 是(从请求 origin) |
GITHUB_CLIENT_ID |
GitHub OAuth | 否(Secret) |
GITHUB_CLIENT_SECRET |
GitHub OAuth | 否(Secret) |
GOOGLE_CLIENT_ID |
Google OAuth | 否(Secret) |
GOOGLE_CLIENT_SECRET |
Google OAuth | 否(Secret) |
MICROSOFT_CLIENT_ID |
Microsoft OAuth | 否(Secret) |
MICROSOFT_CLIENT_SECRET |
Microsoft OAuth | 否(Secret) |
LINUXDO_CLIENT_ID |
LINUX DO OAuth | 否(Secret) |
LINUXDO_CLIENT_SECRET |
LINUX DO OAuth | 否(Secret) |
VAPID_PUBLIC_KEY |
VAPID 推送公钥 | 否(Secret) |
VAPID_PRIVATE_JWK |
VAPID 推送私钥 (JWK) | 否(Secret) |
VAPID_SUBJECT |
VAPID 主体 (mailto:) | 否 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /count |
无 | 获取当前在线人数 |
| GET | /ws |
无 | WebSocket 连接(实时在线计数) |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /auth/config |
无 | 获取可用认证方式配置 |
| POST | /auth/register/options |
无 | 请求注册挑战 |
| POST | /auth/register/verify |
无 | 验证注册响应 |
| POST | /auth/login/options |
无 | 请求登录挑战 |
| POST | /auth/login/verify |
无 | 验证登录响应 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| POST | /auth/refresh |
Cookie | 刷新 Access Token |
| POST | /auth/logout |
Bearer | 注销并吊销 Token |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /auth/me |
Bearer | 获取当前用户信息 |
| PATCH | /auth/me |
Bearer | 更新用户资料 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /auth/devices |
Bearer | 列出已注册设备 |
| POST | /auth/devices/add/options |
Bearer | 请求添加设备挑战 |
| POST | /auth/devices/add/verify |
Bearer | 验证添加设备 |
| DELETE | /auth/devices/:id |
Bearer | 删除设备 |
| POST | /auth/devices/merge |
Bearer | 合并设备关联的账号 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /auth/methods |
Bearer | 列出所有认证方法(WebAuthn + OAuth) |
| DELETE | /auth/methods/oauth/:id |
Bearer | 解除 OAuth 关联 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /oauth/:provider |
无 | 跳转到 OAuth 授权页 |
| GET | /oauth/:provider/callback |
无 | OAuth 回调处理 |
| POST | /oauth/link/:provider |
Bearer | 关联 OAuth 账号 |
| POST | /oauth/merge |
Bearer | 合并 OAuth 关联的账号 |
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/data/:type |
Bearer | 下载指定类型数据 |
| GET | /api/data/:type/version |
Bearer | 获取数据版本号 |
| PUT | /api/data/:type |
Bearer | 上传/更新数据(支持冲突检测) |
| DELETE | /api/data/:type |
Bearer | 删除指定类型数据 |
其中 :type 为以下值之一:focus_records, focus_settings, playlists, user_settings, share_config, hook_settings。
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /api/push/vapid-key |
无 | 获取 VAPID 公钥 |
| POST | /api/push/subscribe |
Bearer | 注册推送订阅 |
| DELETE | /api/push/subscribe |
Bearer | 删除推送订阅 |
| POST | /api/push/session/schedule |
Bearer | 注册定时推送 |
| POST | /api/push/session/pause |
Bearer | 暂停推送定时器 |
| POST | /api/push/session/resume |
Bearer | 恢复推送定时器 |
| POST | /api/push/session/cancel |
Bearer | 取消推送定时器 |
| GET | /api/push/session/state |
Bearer | 获取推送定时器状态 |
| POST | /api/push/session/update-settings |
Bearer | 更新推送定时器设置 |
详细的认证流程参见 Authentication-System,数据同步协议参见 Cloud-Sync,Hook 系统参见 Hook-System。