好,现在我有了所有细节,给你做一次系统的深度讲解。

一、分块上传(Chunked Upload)全链路

设计目标

前端:upload-manager.ts

全局常量

CHUNK = 8 MB       // 每片大小
CONCURRENCY = 3    // 同一文件最多 3 片并发
MAX_SIZE = 4 GB    // 文件大小上限

UploadManager 类(单例 uploadManager

内部维护四个 Map:

字段含义
itemslocalID → UploadItem(UI 状态,含 status/loaded/name/size)
uploadIDslocalID → 服务端 upload_id(用于后续 putChunk / complete / cancel)
cancellerslocalID → 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

后端: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()

  1. 校验文件名(不允许 /\,长度 ≤ 255)、文件大小(1B ~ 4GB)、chunk 大小(1MB ~ 64MB)

  2. 校验目标目录存在且未被软删除

  3. 计算 total = ceil(size / chunk)

  4. 创建 bitmap(received_mask)[]byte,长度 (total+7)/8,初始全 0,每 bit 对应一个 chunk

  5. 生成 32-char 随机 upload ID,在磁盘上创建目录 {basePath}/drive/_chunks/{uploadID}/

  6. 插入 drive_uploads 行,status='uploading',expires_at = now + 24h

PutChunk()

  1. 查找并验证 session(未过期、status='uploading'、idx 在范围内)

  2. 计算预期字节数(最后一片可以小于 chunk_size)

  3. 先写到 {idx}.part 临时文件,写入后验证字节数精确匹配,再 os.Rename{idx}.bin(利用文件系统的原子 rename)

  4. 更新 bitmap 时使用 BEGIN IMMEDIATE 事务,原因:

    • 默认 DEFERRED 事务先 SHARED 锁读,再升级到 RESERVED 写锁

    • 多 chunk 并发时升级竞争会导致 SQLITE_BUSY(即使设了 busy_timeout 也可能超时)

    • IMMEDIATE 直接拿 RESERVED 锁,从根本上避免锁升级竞争

  5. 更新 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 毫秒时间戳

关键索引:

约束: folder 必须 blob_path=NULL + size=NULL,file 必须两者非 NULL(数据层保证结构完整)

软删除设计: 删除时设 deleted_at + delete_batch_id(UUID),父目录的子节点通过 CASCADE 也会被删除(ON DELETE CASCADE)。垃圾桶视图用递归 CTE 只显示每批次最顶层的已删节点,而不是把整个子树都平铺出来。

mime_typeext 不存库,每次 JSON 序列化时通过 MarshalJSONname 派生,避免冗余存储。

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 BIGINT

received_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)实现原理

创建分享

  1. 前端打开 ShareDialog,设置可选密码 + 有效期(1天/7天/30天/永久)

  2. POST /api/drive/shareDriveShareService.Create()

  3. 后端验证:只有 file 类型、未删除的节点才能分享(不支持分享文件夹

  4. 生成 32 字节随机 URL-safe token(Base64 URL 编码,约 43 字符)

  5. 计算 SHA-256(token)token_hash,取前 8 字符 → token_prefix

  6. 如果有密码:bcrypt.GenerateFromPassword(默认 cost)

  7. 插入 drive_shares明文 token 也存入(目的:让认证用户能从"Shared Files"页面复制已有链接)

  8. 返回给前端:{ url: "https://domain/shared-files/{token}", token, ... }

令牌查找(防时序攻击)

Resolve(token) 不做 WHERE token_hash = ? 全量哈希查找,而是:

SELECT * FROM drive_shares WHERE token_prefix = ?  -- 先用前缀索引快速缩小候选

然后对每个候选行做 crypto/subtle.ConstantTimeCompare(hash, computedHash) 恒时比较。

这样同时满足:

公开访问页(无需认证)

挂载在独立路由 /shared-files/*(在 auth 中间件之外):

密码验证的 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 归组