
简介在数据分散于手机、电脑、平板等设备的日常中个人文件管理成为高频需求。网盘技术本质是文件存储与同步的工程化实践而Python作为高效后端语言配合Django框架成熟的全栈能力能够快速构建可靠的文件服务。Django提供的ORM、认证体系与Admin后台结合MySQL稳定的事务支持保障了文件元数据的一致性与并发安全。在此基础上秒传、断点续传、分享链接等功能提炼出文件传输的通用原理使系统兼具实用性与学习价值。本文完整呈现一个基于DjangoMySQL的个人网盘项目涵盖数据库建模、上传下载、权限控制及线上部署踩坑适合开发者构建私有云盘或学习Web工程实践。 不知道你有没有遇到过这种场景手机里存了几百张照片电脑上有一堆工作文档平板里还有十几个PDF教程想互相传一下全靠微信“文件传输助手”急了就直接开数据线再狠一点就买个U盘来回插。我反正折腾了很久最后索性花了一个周末用 Python 自己搭了一个网盘。项目不大但五脏俱全基于 Django MySQL前后端加权限、上传下载、分享、秒传全都有。这套源码我放在 GitHub 上也实跑过真机下文把完整设计思路、核心实现和踩坑记录全部整理出来给想做个人网盘、毕设或者练手 Django 的朋友当参考。先交代清楚这套东西的价值它不需要你懂分布式也不用上 Kafka、HDFS 这种重型依赖就是一套主流的 Django 单体项目配合 MySQL 做主存储文件系统存物理文件。它解决的问题很直接——让一个人或者一个小团队把散落在各设备的文件收敛到一台自己可控的服务器上通过网页完成上传、浏览、下载、分享、回收站这些核心操作。适合的人群很明确熟悉 Python 基础想用 Django 完整走一遍后端项目的同学被各类网盘限速搞烦了、想自己捏一个私有云盘的开发者以及正在选型做个人网站 / NAS 文件模块的人。项目核心需求与整体架构设计1.1 个人网盘到底要解决哪些核心问题网盘乍一听无非是上传下载四个字但真上手做就会发现很多细节没有想清楚的话后面每一个功能都会打架。我最初把需求拆成了四块文件管理、用户系统、分享协作、可靠性保障。文件管理是基本面包含文件的上传、下载、删除、重命名、移动以及目录的创建和浏览用户系统解决的是“你上传的东西不能被别人看到”所以注册、登录、鉴权、用户间隔离是必须的分享协作看起来是加分项但实际用起来概率极高给同事传个包、给朋友发个相册链接 密码的方式最省事可靠性保障则是上线后才会意识到多重要的东西比如回收站、断点续传、秒传、重复文件处理这些都属于“没有也能跑有了才敢用”的范畴。1.2 为什么选择 Django MySQL 而不是 Flask 或其他方案选型期我也想过用 Flask FastAPI 自己拼插线板但最后被 Django 的“全家桶”属性拉回来了。Django 自带 Admin 后台、ORM、Auth 认证体系、表单与校验、中间件机制这些对网盘项目属于必需品Auth 帮你搞定注册登录和 session 会话避免自己写密码加盐和 session 管理Admin 后台可以直接体检整个文件表记录调试时不用开数据库客户端ORM 对 MySQL 的支持很成熟迁移建表一条命令搞定。换句话说一个人开发的时候Django 把大量底层后勤扛了你专注写文件业务就行。MySQL 选择的原因也很朴素它稳定、部署普遍、运维资料多且对事务、索引的支持可以满足文件元数据这种中等规模并发读写的场景不会像 SQLite 那样写多几路就锁库。数据库设计与模型层实现2.1 用户模块设计用户系统直接继承 Django 的 AbstractUser 扩展是最稳的路径。别自己从零写 User 表原因是 Django 的 auth 模块内置了权限表、session 表跟用户表的关联硬重写容易踩到坑。我是这样设计的from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): nickname models.CharField(max_length64, blankTrue, verbose_name昵称) avatar models.ImageField(upload_toavatar/, nullTrue, blankTrue) quota models.BigIntegerField(default10737418240, verbose_name配额默认10GB) used_quota models.BigIntegerField(default0, verbose_name已用容量) is_vip models.BooleanField(defaultFalse)这里有两个值得注意的点。第一自定义用户模型必须在第一次 migrate 前设置 AUTH_USER_MODEL不然后续迁移会提示关联冲突这个坑几乎人人都会踩。第二网盘项目必须在用户上记录容量配额不能真的让单用户无限上传不然磁盘爆了后悔都来不及。2.2 文件与目录模型设计文件和目录我放在同一张表里而不是分两张表。一开始按“文件夹一张表、文件一张表”设计很快发现移动目录、重命名、分享子树时逻辑会得极其繁琐。用一张表加 parent 自引用之后整个文件树非常直观class FileNode(models.Model): owner models.ForeignKey(User, on_deletemodels.CASCADE) name models.CharField(max_length255) parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren) file_type models.CharField(max_length32, choices[(folder, 目录), (file, 文件)]) size models.BigIntegerField(default0) storage_path models.CharField(max_length512, nullTrue, blankTrue) file_hash models.CharField(max_length64, db_indexTrue, nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) is_trash models.BooleanField(defaultFalse) trash_time models.DateTimeField(nullTrue, blankTrue)parent 自关联的妙处在于不管你读多少层目录本质上都是一条递归查询配合前端面包屑做路径导航后端的逻辑会非常干净。file_hash 字段就是给秒传用的传 SHA1 值先查库相同的就不落盘直接引用旧文件这样重复文件几乎零成本。2.3 MySQL 适配与索引、编码的细节Django 对 MySQL 的适配整体没有太多黑魔法但有几个配置项必须显式处理。DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: pan_db, USER: pan_user, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }utf8mb4 是必须的因为 MySQL 8.0 默认字符集虽然已经是 utf8mb4但如果你从老版本迁移过来表里的 utf8 只能存 3 字节的字符遇到生僻字、表情符号直接报错。设置 STRICT_TRANS_TABLES 是避免字段超长时 MySQL 静默截断这种“软错误”排查起来非常折磨人。索引方面除了主键和 owner 外经常用于查询的字段主要有两个parent 和 is_trash。用户进入首页要列出“某一目录下所有非回收站项”所以组合索引比单列索引更有价值我建了一个联合索引class Meta: indexes [ models.Index(fields[owner, parent, is_trash]), ]这样目录列表页的查询基本走覆盖索引在几万条数据量下响应仍然在毫秒级。文件上传与下载核心实现3.1 文件上传的完整技术流程Django 处理上传文件有两种常用方式小文件直接读进内存大文件写入 TemporaryFile。我选择的是让 Django 自己判断。在 settings 里设置FILE_UPLOAD_MAX_MEMORY_SIZE 2621440默认是 2.5MB代表少于这个尺寸的直接放内存大于的则落到临时文件避免上传大文件时内存被吃满。上传视图里拿到的request.FILES[file]是一个 UploadedFile 对象可以拿到 chunks 方法分块写入最终位置。实际生产服务器上前面通常有一层 Nginxnginx 的client_max_body_size也要同步加大比如文件上限是 4GB就设置成client_max_body_size 4096m;否则你会发现 Django 层什么都没做错文件却一直报 413。这是 Nginx 在拦你不是 Django 的问题。文件保存的核心逻辑如下def handle_uploaded_file(f, user, parent_dir, dest_name): hasher hashlib.sha1() save_path os.path.join(settings.MEDIA_ROOT, files, str(user.id)) os.makedirs(save_path, exist_okTrue) dest os.path.join(save_path, uuid.uuid4().hex _ dest_name) with open(dest, wb) as destination: for chunk in f.chunks(): hasher.update(chunk) destination.write(chunk) file_hash hasher.hexdigest() # 如果文件已经存在则删除刚写入的物理文件并复用已有记录 existing FileNode.objects.filter(file_hashfile_hash, is_trashFalse).first() if existing: os.remove(dest) return existing return dest, file_hash单独提一下 UUID 文件名的作用。真实存储路径如果直接用用户原始文件名两个不同目录上传同名文件会冲突而且文件名里夹带特殊字符各种乱入。我采取 UUID 原名拼接的方式保留原名的同时避免冲突展示时再从数据库里读真正的 name 字段来显示。3.2 分片上传与断点续传方案网盘不比普通博客动不动就是几个 G 的压缩包单次请求超大文件在公网环境下基本不可靠。分片上传是把大文件切成固定大小的块前端切片后端一个块一个块接收最终再合并。我用的分片方案比较轻不引消息队列纯粹用一张分片表维护状态class UploadChunk(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE) file_identifier models.CharField(max_length128, db_indexTrue) # md5(文件名大小修改时间) chunk_index models.IntegerField() total_chunks models.IntegerField() chunk_path models.CharField(max_length512) uploaded_at models.DateTimeField(auto_now_addTrue)前端用 spark-md5 先算出文件整体 md5再通过 File.slice 按 4MB 切块循环上传。后点击“上传”后实际会先请求一个初始化接口传 identifier 和总块数服务端返回这个文件目前缺失了哪些分片前端只补传缺失的部分天然支持断点续传。合并时服务端按 chunk_index 顺序读取所有分片文件写入同一个文件再走一次 SHA1 入库逻辑同时清理分片记录。这里需要注意分片上传的合并操作最好放在事务或者唯一约束里。我在合并前会先检查文件是否存在避免两个请求同时写同一个文件导致互相覆盖。3.3 下载与断点续传下载逻辑相对于上传要简单一些但大文件下载同样有细节。Django 的普通 HttpResponse 会先读入内存再返回几 G 的文件可以直接把服务器内存吃爆。必须用 StreamingHttpResponse 或者 FileResponsefrom django.http import FileResponse def download_file(request, node_id): node get_object_or_404(FileNode, pknode_id, ownerrequest.user) real_path os.path.join(settings.MEDIA_ROOT, node.storage_path) filename urllib.parse.quote(node.name) response FileResponse(open(real_path, rb), as_attachmentTrue) response[Content-Disposition] fattachment; filename*UTF-8{filename} return response给 Content-Disposition 做 URL 编码是非常关键的一步。如果不处理中文文件名Chrome 下载时中文会乱码或者直接被截断传filename*UTF-8{filename}可以兼容现代浏览器。断点续传下载这里其实是个偏前端的功能后端不需要特别实现。FileResponse 本身依赖的 wsgi 服务器支持 Range 请求比如 uWSGI 或 Nginx 层会自动处理 Range客户端只要发送带 Range 的请求就能接着下。唯一要注意的是如果开了 Gzip 压缩Range 会失效所以给文件接口必须加上Content-Encoding: identity。3.4 分享链接与提取码实现分享功能的常见形式是“外链 提取码 有效期”。我用一张 ShareLink 表来实现token 使用 secrets.token_urlsafe(16) 生成保证不可猜测。extract_code 是用户填的 4 位数字暴露给提取人的。过期时间由用户从前端选择后端做校验。class ShareLink(models.Model): node models.ForeignKey(FileNode, on_deletemodels.CASCADE) owner models.ForeignKey(User, on_deletemodels.CASCADE) token models.CharField(max_length64, uniqueTrue) extract_code models.CharField(max_length8) expires_at models.DateTimeField(nullTrue, blankTrue) visitor_count models.IntegerField(default0) created_at models.DateTimeField(auto_now_addTrue)访客访问分享链接时前端会拿 token 请求一个“是否过期、是否正需要提取码”的校验接口。如果分享的是目录后端会把整棵子树拉出来拼成类似/share/token/node_id的列表下载时同样套用上面的 FileResponse 逻辑只不过校验的是分享 token 而不是登录态。路由、视图与权限控制实战4.1 URL 设计与视图层组织路由设计上我按前后台模式拆分成两套 URL管理的 API 给内部页面 Ajax 使用分享的 URL 是公开的。内部 API 统一带 /api/ 前缀方便以后接 Nginx 统一限流。路由表大致如下urlpatterns [ path(, include(core.urls)), path(admin/, admin.site.urls), path(api/auth/, include(apps.user.urls)), path(api/files/, include(apps.file.urls)), path(share/str:token/, share_views.share_index), ]视图层我优先使用 CBVClass-Based View尤其是列表页和上传页。原因很简单Django 内置的 ListView、CreateView 已经把分页、表单校验、表单错误回显这套做好了继承后只要改少量方法省下一大堆重复代码。比如目录列表的 ListViewclass FileListView(LoginRequiredMixin, ListView): model FileNode template_name file/list.html paginate_by 30 def get_queryset(self): parent_id self.request.GET.get(parent, 0) qs FileNode.objects.filter( ownerself.request.user, parent_idNone if parent_id 0 else parent_id, is_trashFalse, ).order_by(-file_type, name) return qs4.2 登录态与会话管理Django 自带的 login_required 装饰器和 LoginRequiredMixin 可以解决大部分需要登录才能访问的资源。但要做个人网盘不能只防页面级未登录访问更重要是防用户越权操作。我的处理方式是在每个视图内再次校验 owner 字段即使被猜到了 URL也拿不到别人的文件。比如objs FileNode.objects.filter(ownerrequest.user, pknode_id)而不是直接get(pknode_id)然后手动判断。这种过滤式查询是一条 SQL 走完的效率和安全性都好。另外如果使用了自定义 User 模型建议给 Session 增加过期时间比如默认 7 天防盗用风险。4.3 前端页面交付方式个人网盘这类管理后台属性强的项目我不推荐重前端。初学者如果一开始就上 Vue Axios 跨域很容易被 CORS 和 JWT 吃掉大量时间反而偏离了网盘核心逻辑。我最初就用 Django 模板 Bootstrap 居中弹窗配少量原生 JS fetch 完成上传交互。模板复用继承一套 base.html 就够了。等核心功能全通后如果想做更丝滑的交互再单独抽一层 DRF 接口前端配 Vue3。前后端分离是渐进式改造出来的不是一上来就火箭配置。常见问题与排查技巧实录5.1 MySQL 连接失败的典型场景我在本地环境装好全部依赖后启动项目第一个报错就是django.db.utils.OperationalError: (1045, Access denied for user pan_userlocalhost)。排查后发现是 MySQL 8 默认用的caching_sha2_password插件而 PyMySQL 在旧版本对它支持不友好。解决方案有两个一是把 MySQL 用户改为 mysql_native_password二是装较新版本的 PyMySQL0.12 以上并在 Django 的 OPTIONS 里加上auth_plugin: mysql_native_password。我建议直接升级 PyMySQL改密码插件的方案留到老系统兼容时再用。5.2 上传大文件总超时或直接被 413自测阶段上传 600MB 的压缩包总是传一会儿就断。排查出三个层次的原因Nginx client_max_body_size 未设置、Django 的DATA_UPLOAD_MAX_MEMORY_SIZE太小、网络中间代理层超时时间太短。解决方案是把 Nginx 的 client_max_body_size 调到 4gDjango 侧不要动 DATA_UPLOAD_MAX_MEMORY_SIZE 太大会影响缓冲策略改用分片上传绕开单次请求体积限制。前端再把分片大小调到 4MB 左右连续失败重试 3 次体验就稳定了。5.3 中文文件名和路径中的隐藏坑文件下载时 Chrome 偶尔会把中文显示成下划线或乱码这是 Content-Disposition 编码问题。我前面已经写了 urllib.parse.quote 方案这里提醒另一个坑跨平台路径拼接时不要用字符串拼接必须用os.path.join。我最初图省事写real_path settings.MEDIA_ROOT / node.storage_path在 Linux 上问题不大在 Windows 上跑测试就把路径组合成了D:\media\path\to\file \whatever空格和反斜杠全乱。后来全部换成 os.path.join收工。5.4 文件总是传完后没有出现在列表里这类问题十有八九是缓存或事务未提交。如果是用了模板自带的分页器确认分页的页面参数page是否被前端传入如果是上传后目录树没有刷新可以在表单提交后window.location.reload()或者手动刷新当前目录接口。很多次经验告诉我用户普遍急躁上传完想立刻看到结果所以前端在上传完成回调里再加一个小延迟后刷新体验会比等用户手动刷新好很多。项目扩展方向与二次开发建议这套源码只把网盘最核心的主干打通真要长期自用我建议往这几个方向扩展。第一是接入对象存储把实体文件从本地磁盘搬到 MinIO 或阿里云 OSS元数据继续放 MySQL这样服务器硬盘不用扩容下载带宽也能吃满。第二是引入后台异步任务比如文件压缩、视频转码用 Celery Redis 在后台执行避免上传耗时操作阻塞 Web 进程。第三是增加目录级分享权限现在分享是基于单个文件夹或文件如果需要同事之间协作编辑就得引入更细粒度的权限模型比如只读、可写、可管理这可以套用 Django 自带的 ContentType 做通用权限表。从我的实际体验讲这个项目最大的收益不是“搞定文件上传下载”而是完整走了一遍“从需求拆分 → 数据库建模 → 核心流程 → 权限安全 → 部署运维”的项目全流程。如果你也想拿它练手建议先把上传和目录树做出来再把分享和回收站铺上一步步来过程中踩的每一个坑都会成为复试或者简历里最有分量的项目经验。最后再分享一个小技巧——Django 生产环境千万不要用 runserver 硬抗。我在真实部署时用的是 uWSGI NginxuWSGI 里设http-timeout600和harakiri600否则大文件并发上传时 worker 会被拖死。配置一次之后整个项目跑在低配云服务器上跑了大半年没出过幺蛾子。这个项目的源码地址在 github.com 上可以找到搜索“Django 个人网盘项目”或者“django-pan”就能看到相关的 README 里我也写了完整的部署步骤直接照着抄就行。本文还有配套的精品资源点击获取