🔥 肝了一周,我开源了这个基于 FastAPI + React 的企业级网盘 (支持秒传/断点续传)
摘要:市面上的网盘系统要么太臃肿,要么也是闭源的。作为一名全栈开发者,我利用业余时间基于 FastAPI (Python) 和 React 构建了一套高性能、轻量级的私有云盘系统 —— SkyDrive Enterprise。
本文将深度复盘项目的核心架构,从数据库设计、文件秒传原理、分片上传实现到 Docker 容器化部署,毫无保留地分享给大家。
💻 项目开源地址: https://github.com/ChaceQC/SkyDrive (欢迎 Star ⭐️) 👀 在线演示: https://home.chacewebsite.cn:8686/
📋 目录
- 3.1 元数据与存储分离 (架构之魂)
- 3.2 秒传功能实现 (附流程图)
- 3.3 分片上传与断点续传
1. 为什么造这个轮子?
在数字化办公场景下,数据资产的管理至关重要。传统 FTP 太老旧,公有云盘不仅限速还存在隐私风险。
SkyDrive Enterprise 的目标非常明确:
- 极致轻量:没有复杂的微服务全家桶,一个后端服务 + 一个数据库搞定。
- 极致性能:利用 Python 异步 IO 处理高并发文件流。
- 数据自主:私有化部署,我的数据我做主。
2. 技术栈选型与架构
为了保证开发效率和运行性能,我选择了目前主流的前后端分离架构。
🛠 后端 (Backend)
- Framework: FastAPI (性能接近 Go,自动生成 Swagger 文档,开发体验极佳)
- Database: MySQL 8.0 (存储元数据)
- ORM: SQLAlchemy (处理复杂的数据库关系)
- Security: OAuth2 + JWT (标准的身份认证体系)
🎨 前端 (Frontend)
- Core: React 18 + TypeScript
- Build: Vite (秒级启动,开发体验吊打 Webpack)
- UI: TailwindCSS / Ant Design (根据实际情况调整)
📂 项目结构概览
SkyDrive/
├── backend/ # 后端核心
│ ├── app/
│ │ ├── api/ # 接口路由 (files.py, users.py)
│ │ ├── core/ # 核心配置 (security.py, config.py)
│ │ ├── crud/ # 数据库操作 (CRUD)
│ │ ├── models/ # SQLAlchemy 模型 (数据库表结构)
│ │ └── utils/ # 工具类 (Hash计算等)
│ ├── upload_storage/ # 物理文件默认存储位置
│ └── Dockerfile
├── frontend/ # 前端核心
│ ├── src/
│ │ ├── components/ # 公共组件
│ │ ├── utils/ # 工具函数 (crypto.ts, request.ts)
│ │ └── App.tsx
│ └── vite.config.ts
└── docker-compose.yml # 容器编排
3. 核心功能深扒
3.1 元数据与存储分离 (架构之魂)
做网盘系统,最忌讳的就是把“文件信息”和“物理文件”绑定在一起。我采用了逻辑与物理分离的设计模式。
设计思路:
- FileStore (物理表):只存储文件的 Hash (MD5/SHA256)、物理路径、大小。它不关心是谁传的,只关心文件本身。
- FileMeta (逻辑表):存储文件名、父目录 ID、用户 ID,并通过 file_hash 外键关联到 FileStore。
#mermaid-svg-Gq8K7kJ0vOWG9gWF{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .error-icon{fill:#552222;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .marker.cross{stroke:#333333;}#mermaid-svg-Gq8K7kJ0vOWG9gWF svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Gq8K7kJ0vOWG9gWF p{margin:0;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-Gq8K7kJ0vOWG9gWF .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .node rect,#mermaid-svg-Gq8K7kJ0vOWG9gWF .node circle,#mermaid-svg-Gq8K7kJ0vOWG9gWF .node ellipse,#mermaid-svg-Gq8K7kJ0vOWG9gWF .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Gq8K7kJ0vOWG9gWF .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Gq8K7kJ0vOWG9gWF :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
uploads
references
User
FileMeta
int
id
PK
string
file_name
文件名
int
parent_id
父文件夹ID
string
file_hash
FK
指向物理Hash
FileStore
string
file_hash
PK
MD5 Hash
string
real_path
磁盘绝对路径
int
ref_count
引用计数
代码实现 (backend/app/models/file.py):
class FileMeta(Base):
__tablename__ = "file_meta"
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("sys_user.id"), nullable=False)
file_name = Column(String(255), nullable=False)
# 核心:多条 Meta 可以指向同一个 Store
file_hash = Column(String(64), ForeignKey("file_store.file_hash"), nullable=True)
class FileStore(Base):
__tablename__ = "file_store"
# 核心:Hash 即主键,天然去重
file_hash = Column(String(64), primary_key=True, index=True)
real_path = Column(String(512), nullable=False)
ref_count = Column(Integer, default=1) # 引用计数,为0时才真正删除
3.2 秒传功能实现
当用户上传一个 1GB 的电影时,如果服务器上已经有了这个文件,为什么还要浪费时间重传一遍?
流程逻辑:
- 命中:直接生成 FileMeta 记录,引用计数 +1,返回“上传成功”。
- 未命中:通知前端走普通上传流程。
# backend/app/api/api_v1/endpoints/files.py
@router.post("/check_fast_upload", response_model=schemas.CheckFastUpload)
def check_fast_upload(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_user),
file_hash: str = Form(...),
file_name: str = Form(...),
# …
) –> Any:
# 1. 查库
existing_store = crud.file.get_by_hash(db, file_hash=file_hash)
if existing_store:
# 2. 检查配额…
# 3. 引用计数自增
crud.file.increment_ref_count(db, file_hash=file_hash)
# 4. 仅创建元数据,不涉及 IO 操作
file_meta = crud.file.create_with_user(
db=db,
obj_in=schemas.FileMetaCreate(file_name=file_name, file_hash=file_hash, ...),
user_id=current_user.id
)
return {"can_fast_upload": True, "file_meta": file_meta}
return {"can_fast_upload": False, "file_meta": None}
3.3 分片上传与断点续传
对于大文件,Nginx 默认会限制 body 大小,且中间一旦网络抖动就会前功尽弃。解决方案是 Chunked Upload。
- 前端:使用 Blob.slice() 将文件切割为 4MB/片,并发上传。
- 后端:
- /upload/init:初始化上传任务,生成 upload_id。
- /upload/chunk:接收分片,暂存到临时目录。
- /upload/merge:所有分片上传完毕,合并为完整文件,并校验最终 Hash。
避坑指南:合并文件时,务必再次校验 Hash!
# 后端合并逻辑核心代码
@router.post("/upload/merge")
def merge_chunks(...):
# …获取所有分片路径…
with open(final_path, "wb") as final_file:
for chunk in chunks:
with open(chunk.temp_path, "rb") as chunk_file:
# 流式写入,防止内存溢出
while chunk_content := chunk_file.read(1024 * 1024):
final_file.write(chunk_content)
hasher.update(chunk_content) # 边写边算 Hash
os.remove(chunk.temp_path) # 删除分片
# 校验 Hash
if hasher.hexdigest() != file_hash:
os.remove(final_path)
raise HTTPException(detail="文件完整性校验失败")
4. 前端安全与交互细节
🔐 签名校验 (防止篡改)
为了防止恶意用户绕过前端直接调用 API,我在 frontend/src/utils/crypto.ts 中实现了一套简单的签名机制。
注意:这里的 SALT 必须与后端 .env 文件中的配置严格一致!
// frontend/src/utils/crypto.ts
import CryptoJS from 'crypto-js';
// ⚠️ 部署时请修改此值,并确保与后端 .env 中的 SALT 一致
const SALT = "YOUR_SALT_HERE";
export const calculateHash = (parts: string[], serverNonce: string) => {
// 简单的签名算法:参数 + 盐 + 随机数
const raw = parts.join('') + SALT + serverNonce;
return CryptoJS.SHA256(raw).toString(CryptoJS.enc.Hex);
};
5. Docker 一键部署指南
为了让大家能快速把项目跑起来,我编写了 docker-compose.yml。
前置条件:安装 Docker 和 Docker Compose。
步骤 1:克隆项目
git clone https://github.com/YourUsername/SkyDrive-Enterprise.git
cd SkyDrive-Enterprise
步骤 2:配置环境变量 编辑 backend/app/.env,设置数据库密码和密钥。
MYSQL_PASSWORD=StrongPassword123
SECRET_KEY=RandomSecretString…
SALT=YourSalt… # 记得和前端保持一致
步骤 3:启动服务
# 进入后端目录构建并启动
cd backend
docker-compose up -d –build
启动后:
- 后端 API: http://localhost:8899/docs
- 前端页面: http://localhost:8899 (假设你配置了 Nginx 代理前端静态资源)
6. 未来规划
目前 SkyDrive Enterprise 已经具备了核心的网盘功能,未来计划加入:
- WebDAV 协议支持 (直接挂载为本地磁盘)
- Office 在线预览 (集成 OnlyOffice)
- 图片/视频异步转码 (Celery + Redis)
- 分享链接有效期与密码
如果你觉得这个项目对你有帮助,欢迎在 GitHub 上点个 Star!你的支持是我更新的动力。
👉 项目地址:https://github.com/ChaceQC/SkyDrive





