好,现在我有了所有细节,给你做一次系统的深度讲解。
一、分块上传(Chunked Upload)全链路
设计目标
支持大文件(最大 4 GB),不能单次 POST
网络中断后可以断点续传(resumable),不从头重传
多 chunk 并发上传,提高吞吐量
名称冲突时用户可交互选择策略(保留两份 / 覆盖 / 跳过)
前端:upload-manager.ts
全局常量
CHUNK = 8 MB // 每片大小
CONCURRENCY = 3 // 同一文件最多 3 片并发
MAX_SIZE = 4 GB // 文件大小上限UploadManager 类(单例 uploadManager)
内部维护四个 Map:
| 字段 | 含义 |
|---|---|
items | localID → UploadItem(UI 状态,含 status/loaded/name/size) |
uploadIDs | localID → 服务端 upload_id(用于后续 putChunk / complete / cancel) |
cancellers | localID → AbortController 的 abort 函数 |
listeners / completedListeners | 观察者集合,分别给 UploadDock 和文件列表刷新用 |
add() 完整流程
1. 校验文件大小 ≤ 4GB
2. 生成 localID = "u_{timestamp}_{random}",插入 items,emit() 通知 Dock 立刻显示
3. POST /api/drive/upload/init → 拿回 {upload_id, total_chunks, chunk_size, received_chunks}
- received_chunks 非空说明这是一次断点续传恢复
4. 过滤掉已接收的 chunk 索引,构建 queue(待上传的 idx 数组)
5. 如果恢复,先把已传字节数更新到进度条(避免从 0% 跳到某个百分比)
6. 创建 AbortController,注册到 cancellers
7. 用共享 pos 游标 + Promise.all 启动 3 个 worker,每个 worker:
while (!cancelled) {
idx = queue[pos++]
blob = file.slice(idx * chunkSize, ...)
await putChunkWithRetry(upload_id, idx, blob, signal)
update loaded += blob.size
}
8. 全部 chunk 传完后,POST /api/drive/upload/complete
- 若 409 → 状态设为 'conflict',等用户在 Dock 里选择策略
- 若成功 → 状态设为 'done',2500ms 后自动从 Dock 移除
9. 失败时(非 AbortError)→ status 'failed',Best-effort DELETE 删服务端 chunks并发模型的巧妙之处
next 函数通过一个共享的 pos 变量做分派——三个 worker 各自循环 while,竞争 pos++ 来领取下一个 chunk index。这比预先分配分片要简洁,且自动负载均衡(快的 worker 会多传)。
putChunkWithRetry
对 5xx / 网络错误做最多 4 次指数退避重试(200ms → 400ms → 800ms → 1600ms)。注释里特别说明原因:SQLite 在高并发写入时会产生 BUSY,后端返回 500,客户端重试一次通常就能成功。
UI:UploadDock
用
useSyncExternalStore订阅uploadManager(React 18 并发安全的外部 store 接入方式)每个 UploadRow 显示:进度条、百分比、取消按钮,冲突时显示"保留两份 / 替换 / 跳过"三个按钮
完成/失败/取消的条目自动退出或手动清除
后端:DriveUploadService(Go)
路由对应关系:
POST /api/drive/upload/init → Init()
PUT /api/drive/upload/chunk/{id}/{idx} → PutChunk()
POST /api/drive/upload/complete → Complete()
DELETE /api/drive/upload/{id} → Cancel()
GET /api/drive/upload/{id} → Get()(用于恢复时查询已收 chunk)Init()
校验文件名(不允许
/\,长度 ≤ 255)、文件大小(1B ~ 4GB)、chunk 大小(1MB ~ 64MB)校验目标目录存在且未被软删除
计算
total = ceil(size / chunk)创建 bitmap(received_mask):
[]byte,长度(total+7)/8,初始全 0,每 bit 对应一个 chunk生成 32-char 随机 upload ID,在磁盘上创建目录
{basePath}/drive/_chunks/{uploadID}/插入
drive_uploads行,status='uploading',expires_at = now + 24h
PutChunk()
查找并验证 session(未过期、status='uploading'、idx 在范围内)
计算预期字节数(最后一片可以小于 chunk_size)
先写到
{idx}.part临时文件,写入后验证字节数精确匹配,再os.Rename→{idx}.bin(利用文件系统的原子 rename)更新 bitmap 时使用
BEGIN IMMEDIATE事务,原因:默认 DEFERRED 事务先 SHARED 锁读,再升级到 RESERVED 写锁
多 chunk 并发时升级竞争会导致 SQLITE_BUSY(即使设了 busy_timeout 也可能超时)
IMMEDIATE 直接拿 RESERVED 锁,从根本上避免锁升级竞争
更新 bitmap:
mask[idx/8] |= 1 << (idx % 8)
Complete()
这是最复杂的阶段:
1. 原子地把 status 从 'uploading' → 'assembling'
(用 WHERE status='uploading' 的 UPDATE,检查 RowsAffected=1,
防止并发两次 Complete 或在 Cancel 后 Complete)
2. 验证所有 bit 都置 1(即所有 chunk 都已接收)
3. 重新校验父目录仍存在(Init 到 Complete 之间可能被删除)
4. 碰撞检测(FindActiveSibling 查同目录同名未删除节点):
- 'ask' → 返回 ErrDriveNameConflict(HTTP 409),status 回退到 'uploading'
- 'skip' → 直接返回已有节点,删除本次 session
- 'rename' → AutoRename 在文件名后追加 _1/_2...,继续
- 'overwrite' → 落穿,后续调 ReplaceFileNode
5. 拼接所有 chunk:
- 顺序读取 0.bin, 1.bin, ..., N.bin
- 同时写入目标文件并用 io.MultiWriter 计算 SHA-256
- 先写 tmpAbs (.part),完成后 os.Rename 到最终路径 {basePath}/drive/{blobName}
- blobName = UUID + 原始扩展名(避免路径冲突)
6. 插入 drive_nodes 行(或 overwrite 时 UPSERT)
7. 删除 session 行 + 清理 _chunks/{uploadID}/ 目录Cancel()
只删 status='uploading' 的 session(不动 'assembling',防止和 Complete 竞争),然后 os.RemoveAll 清理分片目录。
二、数据库表结构
drive_nodes — 文件树
id INTEGER PK AUTOINCREMENT
parent_id INTEGER FK(self) ON DELETE CASCADE -- NULL 表示根目录
type TEXT 'folder' | 'file'
name TEXT -- 显示名,不唯一(软删除后可重名)
blob_path TEXT NULL(folder) / 相对路径(file) -- e.g. "drive/abc123.pdf"
size BIGINT NULL(folder) / 字节数(file)
hash TEXT NULL | SHA-256 hex -- 文件内容指纹
deleted_at BIGINT NULL=正常 | 毫秒时间戳=已删 -- 软删除
delete_batch_id TEXT NULL | UUID -- 同一批次删除的分组标识
created_at BIGINT 毫秒时间戳
updated_at BIGINT 毫秒时间戳关键索引:
UNIQUE(COALESCE(parent_id,0), LOWER(name)) WHERE deleted_at IS NULL— 同一目录下活跃文件名大小写不敏感唯一(软删除节点不受约束,避免删除后无法创建同名文件)INDEX(parent_id, deleted_at)— 目录列表查询主力INDEX(delete_batch_id)— 垃圾桶"批量删除根"查询
约束: folder 必须 blob_path=NULL + size=NULL,file 必须两者非 NULL(数据层保证结构完整)
软删除设计: 删除时设 deleted_at + delete_batch_id(UUID),父目录的子节点通过 CASCADE 也会被删除(ON DELETE CASCADE)。垃圾桶视图用递归 CTE 只显示每批次最顶层的已删节点,而不是把整个子树都平铺出来。
mime_type 和 ext 不存库,每次 JSON 序列化时通过 MarshalJSON 从 name 派生,避免冗余存储。
drive_uploads — 分块上传会话
id TEXT PK -- 随机 token,32 hex chars
parent_id INTEGER FK(drive_nodes) ON DELETE SET NULL
-- 目标目录删了也不影响上传会话(Complete 时会重新校验)
name TEXT -- 目标文件名
size BIGINT -- 总字节数
chunk_size BIGINT -- 协商好的每片大小
total_chunks INTEGER -- 总片数
received_mask BLOB -- 位图,bit[i]=1 表示第 i 片已收
status TEXT 'uploading' | 'assembling' | 'done' | 'failed'
expires_at BIGINT -- 24h TTL,过期后视为不存在
created_at, updated_at BIGINTreceived_mask 位图:比存 JSON 数组省空间,1000 个 chunk 只需 125 字节。decodeMask 函数把位图转成 []int 返回给客户端(received_chunks),客户端用来跳过已上传的片。
Session 状态机:
uploading → assembling → (done/清除)
↑_____ (Complete 失败时回滚)assembling 状态是一个"锁",防止并发 Complete 或先 Cancel 后 Complete。
drive_shares — 分享链接
id INTEGER PK AUTOINCREMENT
node_id INTEGER FK(drive_nodes) ON DELETE CASCADE
-- 文件被永久删除时分享自动消失
token_hash TEXT UNIQUE -- SHA-256(token)
token_prefix TEXT INDEXED -- token_hash 前 8 字符,用于 B-tree 快速过滤
token TEXT -- 明文 token(供认证用户查看自己的分享链接)
password_hash TEXT NULL -- bcrypt 哈希,NULL=无密码
expires_at BIGINT NULL -- 毫秒时间戳,NULL=永不过期
created_at BIGINT三、分享(Share)实现原理
创建分享
前端打开 ShareDialog,设置可选密码 + 有效期(1天/7天/30天/永久)
POST
/api/drive/share→DriveShareService.Create()后端验证:只有 file 类型、未删除的节点才能分享(不支持分享文件夹)
生成 32 字节随机 URL-safe token(Base64 URL 编码,约 43 字符)
计算
SHA-256(token)→token_hash,取前 8 字符 →token_prefix如果有密码:
bcrypt.GenerateFromPassword(默认 cost)插入
drive_shares,明文 token 也存入(目的:让认证用户能从"Shared Files"页面复制已有链接)返回给前端:
{ url: "https://domain/shared-files/{token}", token, ... }
令牌查找(防时序攻击)
Resolve(token) 不做 WHERE token_hash = ? 全量哈希查找,而是:
SELECT * FROM drive_shares WHERE token_prefix = ? -- 先用前缀索引快速缩小候选然后对每个候选行做 crypto/subtle.ConstantTimeCompare(hash, computedHash) 恒时比较。
这样同时满足:
性能:利用 prefix 索引,几乎 O(1) 找到候选行
安全:恒时比较避免根据响应时间推测 token 内容
公开访问页(无需认证)
挂载在独立路由 /shared-files/*(在 auth 中间件之外):
GET /{token}→ LandingAccept: application/json → 返回 JSON(前端 SPA 用)
否则 → 返回服务端渲染的 HTML landing page(包含 video/audio 预览、密码表单)
POST /{token}/auth→ 密码验证(有 Redis 限流:每 token+IP 5分钟内最多 10 次)GET /{token}/download→ 强制Content-Disposition: attachment下载GET /{token}/preview→inline,支持 HTTP Range(由http.ServeContent实现,视频可 seek)
密码验证的 Cookie 机制
密码验证通过后,设置一个 HttpOnly cookie,绑定到 /shared-files/{token} 路径:
name: drive_share_pw_{token前8字符}
value: HMAC-SHA256(password_hash, token)这个 cookie value 是 HMAC(bcrypt哈希值, 明文token) 的结果。验证时只需重新计算 HMAC 并恒时比较,无需再次 bcrypt(bcrypt 很慢)。即使有人拿到 cookie,也无法从中反推密码。
share_count 徽章
列目录/搜索时,populateShareCounts 对所有文件类型节点批量执行:
SELECT node_id, COUNT(*) FROM drive_shares
WHERE node_id IN (...)
AND (expires_at IS NULL OR expires_at > {now})
GROUP BY node_id只有未过期分享才计数。前端如果 share_count > 0 就在文件名旁显示蓝色分享角标。
分享到期清理
后台定时任务 DriveShareService.PurgeExpired() 定期 DELETE expires_at <= now 的行;DriveUploadService.PurgeExpired() 同样清理过期上传 session 及其 _chunks 目录。
总结
| 设计点 | 实现方式 |
|---|---|
| 大文件 | 8MB/片分块,前端 File.slice() |
| 断点续传 | Init 返回 received_chunks 位图,前端跳过已传片 |
| 并发上传 | 3 worker 共享 pos 游标,Promise.all |
| 原子性 chunk 写入 | 先写 .part,rename 到 .bin |
| 并发 bitmap 更新 | BEGIN IMMEDIATE 事务,避免锁升级死锁 |
| 名称冲突 | ask/overwrite/rename/skip 四策略,冲突时 HTTP 409 让用户选 |
| 文件组装 + 完整性 | 顺序拼 .bin,io.MultiWriter 计算 SHA-256 |
| Token 安全 | 只索引前缀,恒时比较完整哈希,防时序攻击 |
| 密码验证 | bcrypt 存储,验证后 HMAC cookie 缓存(避免每次请求都 bcrypt) |
| 软删除 | deleted_at + delete_batch_id,批量删除用批次 ID 归组 |