简介:这是一份基于Django框架开发的文档管理系统完整源码,面向Python Web初学者及需要快速搭建内容管理后台的开发者,覆盖用户认证、文档上传下载、搜索过滤与权限控制等典型功能模块。压缩包共64个文件,主要由Python脚本、Django模板、静态资源及样式表构成,其中15个py文件对应模型、视图、URL配置与管理逻辑,12个css与10个js用于前端交互,3个html模板负责界面展示,另有txt、md及字体文件用于配置说明和界面装饰,整体仅1.62MB,结构紧凑,便于对照学习。已有1891人浏览学习,可见其作为实践项目颇受欢迎。源码包含manage.py、settings.py、urls.py等标准Django工程文件,目录层次清晰,通过阅读可系统掌握Django的MVC分层、ORM数据库交互、模板渲染、表单处理、文件上传机制及RESTful扩展思路,适合动手复现并在此基础上扩展为企业级文档管理应用。
1. 这套 Django 文档管理系统能帮你解决什么
先说结论:基于 Python 和 Django 做的文档管理系统,真正值钱的部分不是"上传一个文件再下载回来",而是把文件变成了可检索、有权限、有分类的业务数据。很多人下载这类源码包后第一件事是跑python manage.py runserver,看到能上传下载就觉得成了,实际上文件躺在media/里和数据库毫无关联,换个服务器连备份都做不干净,这才是这类项目最常翻车的地方。
这篇文章按一条完整的落地方案来讲:项目结构怎么拆、上传下载怎么做、全文检索用什么思路、权限怎么收敛到人,最后把 Django 项目从 SQLite 切到 MySQL 的部署过程也一并说清楚。你不是来读科普的,照着复现一遍,大概两到三个小时能跑通,中间每一段我都会把参数、边界和坑位标出来。
这套方案适合谁?适合刚把 Python 基础过完、想用 Django 做一个真正能交差的 Web 项目的新手,也适合公司内部缺一个轻量文档库、想用现成源码快速改造成私有部署的开发者。你不需要先懂分布式存储或者 Elasticsearch,Django 自带的能力已经能把百分之八十的需求吃掉,剩下的两成靠调参数和换数据库解决。
2. 文档管理系统的核心模型:文件实体与业务数据分离
2.1 为什么说只用 FileField 存文件是不够的
常见做法是给模型挂一个models.FileField(upload_to='documents/'),上传、下载、删除都能跑,但你要面对的问题是:文件名一改,数据库里存的路径就失效了;文件被人手动从服务器上删掉,数据库里还留着一行脏数据;更麻烦的是检索——你没法对文件内容做查询,只能拿文件名去icontains碰运气。
我一般会把文件实体和业务元数据分开设计:一个模型管物理文件本身(路径、大小、哈希值、MIME 类型),另一个模型管业务属性(标题、分类、上传人、标签、可见范围)。这样将来你无论换成 FastDFS、MinIO 还是对象存储,业务层代码不需要动,只改文件实体的存储后端就行。源码包里如果你看到Document和FileRecord两个 model,走的就是这个套路。
2.2 models.py 怎么写才不至于返工
直接看一个最小可用的模型设计,这段代码是整个系统的地基:
import os import hashlib from django.db import models from django.contrib.auth.models import User class FileRecord(models.Model): # 物理文件:存路径、大小、哈希,不存业务语义 file = models.FileField(upload_to='documents/%Y/%m/') md5 = models.CharField(max_length=32, blank=True) size = models.BigIntegerField(default=0) mime_type = models.CharField(max_length=100, blank=True) uploaded_at = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): if not self.md5: # 读取文件内容计算 MD5,用于去重和秒传 sha = hashlib.md5() for chunk in self.file.chunks(): sha.update(chunk) self.md5 = sha.hexdigest() self.size = self.file.size super().save(*args, **kwargs) class Document(models.Model): # 业务元数据:标题、分类、权限、标签 title = models.CharField(max_length=200) category = models.CharField(max_length=50, choices=[ ('contract', '合同'), ('report', '报告'), ('manual', '手册') ]) file_record = models.ForeignKey(FileRecord, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.PROTECT) tags = models.CharField(max_length=200, blank=True) allow_roles = models.ManyToManyField('auth.Group', blank=True) created_at = models.DateTimeField(auto_now_add=True)FileRecord.save()里重写了保存逻辑,用chunks()边读边算 MD5,不一次性把整个大文件读进内存。size用BigIntegerField而不是IntegerField,因为一个文档管理系统迟早会碰到超过 2GB 的媒体文件。upload_to='documents/%Y/%m/'让文件按年月分目录存放,避免单目录文件数过多导致 IO 变慢。
Document里的allow_roles是多对多关联到 Django 的Group,这是后面做权限控制的关键。你要临时让某个人看一份合同,就把那个人所在的组加进来,而不是给文档加一个布尔型的is_public——布尔字段太粗,没法表达"哪些群组可见"这层意思。on_delete=models.PROTECT是为了防止误删用户时把文档连带删掉,这个细节能让你少陪一次业务方喝茶。
2.3 上传视图与表单:校验、落盘、建索引的完整顺序
有了模型,下一步是把上传动作写出来。视图的顺序很重要:先校验文件类型,再保存到FileRecord,然后创建Document元数据。顺序反了会出现文件落盘了但数据行没建成的中间状态,排查起来很费劲。
import os from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from django.http import HttpResponseForbidden from .models import FileRecord, Document from .forms import DocumentUploadForm ALLOWED_EXTENSIONS = {'.pdf', '.docx', '.xlsx', '.md', '.txt'} @login_required def upload_document(request): if request.method == 'POST': form = DocumentUploadForm(request.POST, request.FILES) if form.is_valid(): uploaded = request.FILES['file'] ext = os.path.splitext(uploaded.name)[1].lower() if ext not in ALLOWED_EXTENSIONS: form.add_error('file', f'不支持 {ext} 格式') else: # 先存物理文件 file_record = FileRecord(file=uploaded) file_record.save() # 再建业务元数据 doc = form.save(commit=False) doc.file_record = file_record doc.owner = request.user doc.save() form.save_m2m() return redirect('document_detail', pk=doc.pk) else: form = DocumentUploadForm() return render(request, 'documents/upload.html', {'form': form})你要注意form.save(commit=False)的用法:Django 的 ModelForm 默认会直接把Document存进库,但我们还没挂上file_record和owner,所以必须先把实例拿到手,补全字段后再手动save()。request.FILES['file']拿到的是InMemoryUploadedFile或TemporaryUploadedFile对象,20MB 阈值由FILE_UPLOAD_MAX_MEMORY_SIZE控制,超过的会被 Django 自动落到临时文件夹,所以你的视图代码不需要区分大小文件。
下载视图就比较简单了:拿到Document.file_record.file.path,用FileResponse流式返回。千万别用open()读完整文件再HttpResponse包一层,大文件会直接把内存吃满,流量一大进程就挂。
3. 全文检索怎么做:Django Q 查询组合与数据库索引的取舍
3.1 为什么基础版不需要引入 Elasticsearch
这类项目的检索需求通常分两层:一是按文件名和标签做模糊匹配,二是对文件内容做全文检索。第一层用 Django 的Q对象就能解决,比如标题里含"合同"或者标签里含"2025"这样,一句话的事。第二层才是分水岭,你要是直接给所有文档做content__icontains查询,数据库会把每条记录的 body 字段都扫一遍,文档一多就慢得没法看。
我给一个务实的判断标准:文档总数低于五万份、单文件文本量低于几百 KB,SQLite 内置的 FTS5 或者 PostgreSQL 的 tsvector 就足够;超过这个量级,再上 Whoosh 或者 Elasticsearch 不迟。很多下载源码的朋友一上来就配 Whoosh,配半天分词器,最后发现业务上用户根本只用标题搜,这就是典型的投入产出比失调。
Django 自带的Q对象组合查询是这个场景的正解。你只需要把icontains、search和Q的|(或)与&(且)用工整的方式串起来:
from django.db.models import Q def search_documents(request): keyword = request.GET.get('q', '').strip() # 空关键词直接返回全部,避免无意义扫描 if not keyword: return render(request, 'documents/search.html', {'results': []}) results = Document.objects.filter( Q(title__icontains=keyword) | Q(tags__icontains=keyword) | Q(file_record__file__icontains=keyword) ).select_related('file_record').order_by('-created_at') return render(request, 'documents/search.html', { 'results': results[:50], # 限制条数,防止一次性取太多 'keyword': keyword, })file_record__file__icontains是跨关联表的字段查询,Django 会自动帮你生成 JOIN,但这个写法会产生一次额外查询,所以加了select_related('file_record')用 JOIN 把FileRecord一次取出来,这是让页面不卡的关键。results[:50]是硬性切片,不是优化技巧,而是面试八股之外真正让你避免内存溢出的操作——你永远不知道哪条文档记录的文件名是个两千字的标题党。
搜出来的结果要做高亮的话,不要偷懒用正则去匹配,直接在模板里把关键词用 HTML 标红是最简单也最安全的做法:{{ result.title|cut:keyword }}不安全,用自定义template filter做 escape 后再替换。
3.2 全文索引的三种可选方案与参数
如果业务确实要搜文件正文,源码包里最常见的技术是给模型加一个IndexedText字段,但这里要注意:
- 方案一:SQLite FTS5,建虚拟表
CREATE VIRTUAL TABLE doc_fts USING fts5(title, body),写入时同步插入,查询时用MATCH。适合单机部署,零额外依赖。 - 方案二:PostgreSQL tsvector,你需要给 Django 配
django.contrib.postgres.search里的SearchVector,配合 GIN 索引,查询性能比 SQLite 好一个量级,适合上生产。 - 方案三:Whoosh,它需要你在模型里加一个
content字段存纯文本,每次上传后手动调用索引构建函数。它的坑在于索引文件路径配置,settings.py里定义了INIT_INDEX之后,如果你是在 Windows 下开发、Linux 下部署,索引路径一不一样,启动时就会报错。
你要是拿到的源码包走的是方案三,先检查settings.py里是否写了WHOOSH_INDEX_PATH,这个路径建议直接写成相对路径,别写死绝对路径,不然换机器就废。
3.3 文本解析库的选择差异
docx和pdf的文本提取是不一样的事,python-docx只能读.docx不能读.doc,PyPDF2读扫描版 PDF 什么都抽不出来。建议做好解析失败时静默降级到标题检索的设计,而不是让上传接口直接 500。我做过一个粗糙但好用的兜底:解析产出文本少于 20 个字符时,将该文档标记为searchable=False,检索结果里靠后展示并加"仅文件名匹配"标签。
4. 权限与分类:让不同角色只能看到该看的文档
4.1 用 Django Group 做简化版 RBAC
Django 自带的auth应用已经实现了用户、组、权限三层模型,很多新手拿到源码包不知道Group怎么用,于是自己造轮子加一个is_admin字段,结果做了一半发现"管理员"和"普通用户"之间还要加"部门主管"这个角色,代码就改不动了——因为布尔字段没法表达多层级关系。
正确做法是先建三个组:admin、manager、member。然后在Document模型上加一个allow_roles多对多字段,默认每个文档给member组。视图层用装饰器判断用户是否属于某个组:
from django.contrib.auth.decorators import user_passes_test def in_manage_group(user): return user.groups.filter(name__in=['admin', 'manager']).exists() @user_passes_test(in_manage_group) def manage_documents(request): # 只有管理员和管理岗能进入管理页 documents = Document.objects.select_related('file_record').all() return render(request, 'documents/manage.html', {'documents': documents})这个方案的巧妙之处在user.groups.filter(...),它是一次数据库查询,比user.groups.all()拿到列表再逐个判断快了不知道多少。user_passes_test是 Django 内置装饰器,不用额外装包,失败时默认跳转到登录页或返回 403,比你手写if not request.user...干净得多。
下载文件时能不能访问,也要在视图里再验证一次。很多人只做了菜单隐藏,没有做下载接口的校验,用户直接访问/media/documents/2025/03/xxx.pdf就能绕过权限拿到文件,这是文档管理系统最大的安全洞。Django 的X-Accel-Redirect(Nginx)或FileResponse权限校验是必须的:
def download_document(request, doc_id): doc = get_object_or_404(Document, pk=doc_id) # 当前用户不在 allow_roles 的任何一组里,直接拒绝 user_groups = request.user.groups.all() if not doc.allow_roles.filter(id__in=user_groups).exists(): return HttpResponseForbidden('无权下载该文档') return FileResponse( doc.file_record.file.open('rb'), as_attachment=True, filename=doc.file_record.file.name.split('/')[-1] )allow_roles.filter(id__in=user_groups)比set(doc.allow_roles.all()) & set(user_groups)这种 Python 层面的集合运算要省事得多,而且它是在数据库层面做 IN 查询,性能上完全没有问题。FileResponse会自动处理大文件的分块读取,不用你操心内存问题。
4.2 分类字段的业务含义与前端展示
分类不是随便写的,它决定了首页的展示逻辑。建议分类字段不要存中文标签,而是存英文枚举值,中文展示放到模板的get_category_display里。这样你将来改分类显示名的时候不用动数据库,只要动models.py里的 choices 元组就行。如果你想把分类做成动态增删的,那就不能写死在models.py里了,要拆成一张Category表:
class Category(models.Model): name = models.CharField(max_length=50, unique=True) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)有的源码包里叫Department或者Folder,核心思想都一样:给文档一个归属维度,列表页按这个维度做筛选。父子结构则是为了满足"一级目录/二级目录"的浏览习惯,Django 的mptt库做这个最顺手,但如果你不需要无限极分类,一个自关联的parent字段就够了。
4.3 django-unfold 提升管理后台的迁移成本
如果源码包里自带 Django 默认的admin界面,你可以花五分钟换上django-unfold,它能让后台界面从" Django 默认的丑"变成类似现代管理系统的样子。更换方式的坑在于settings.py的INSTALLED_APPS顺序,必须把unfold放在django.contrib.admin之前,不然模板覆盖不生效。其次是它依赖django.contrib.humanize等内置应用,漏加会在启动时报TemplateDoesNotExist,这类报错不用去翻源码,先检查INSTALLED_APPS。
unfold 的价值在于它能直接把Document模型的管理界面变成带搜索框、筛选器、批量操作的样子,不用你额外写前端页面。适合明白"管理后台是内部工具,不值得花两周去打磨样式"的开发者。
5. 避坑指南:五个最常让 Django 项目当场翻车的问题
5.1 上传文件后页面报 404——MEDIA_URL 和 MEDIA_ROOT 配置缺失
现象:文件上传成功,数据库里有记录,但访问http://127.0.0.1:8000/media/xxx.pdf直接 404。
原因:开发服务器不会自动映射/media/路径,urls.py里少了一段static()配置。这是个人人都踩但文档里很少强调的坑。
解决:在项目的urls.py末尾加
from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)注意这是开发环境专用,上了 production 必须交给 Nginx 处理,runserver去服务媒体文件是灾难。
5.2 下载的 docx 文件打开报损坏——FileResponse 缺少 as_attachment
现象:文件能下载,但 Word 打开提示"文件已损坏,是否尝试修复"。
原因:FileResponse默认的行为不是让浏览器触发下载,而是让浏览器尝试内联打开。如果响应头里少了Content-Disposition,一个.docx可能被浏览器当成 HTML 解析,内容就坏了。
解决:FileResponse生成时必须传as_attachment=True,如果你用的是旧版 Django,需要手动在响应上设置response['Content-Disposition'] = 'attachment; filename="xxx.docx"'。上传文件名里有空格或中文的,还要用urllib.parse.quote处理一下再放进 header,否则某些浏览器会截断文件名。
5.3 VSCode 写<img>标签,Django 的 static 文件显示不了
现象:<img src="{% static 'img/logo.png' %}">页面上一片空白,后台报FileNotFoundError或TemplateSyntaxError。
原因:这个坑有九成是settings.py里STATICFILES_DIRS配置不对。Django 默认只会在每个 app 的static目录下找静态文件,你放在项目根目录下的static/文件夹不在搜索范围内。另一个常见原因是STATIC_URL忘加尾部斜杠,导致拼接出来的路径变成了/staticimg/logo.png。
解决:在settings.py里确认有STATICFILES_DIRS = [BASE_DIR / "static"],且BASE_DIR用的是Path(__file__).resolve().parent.parent。然后在模板里检查一下{% static %}标签是否在文件开头{% load static %}了——忘记load时模板不会报错,它会把{% static ... %}当成普通文本直接输出,页面上显示的是源码。注意以上两个文件路径要提前建好并在python manage.py collectstatic时先测一次,不要等到部署完再排查。
5.4 删除文档记录时把文件删了,数据库和磁盘的状态不同步
现象:在后台删除一条记录,media目录里的文件还在,磁盘空间越用越大;或者反过来,文件被手动删了,数据库还显示可下载,点下载直接报错。
原因:Django 的on_delete=models.CASCADE只负责数据库层面,FileField对应的物理文件不会被自动删除。很多人不处理这个问题,系统上线半年后磁盘就满了。
解决:写一个信号处理器,在post_delete时把文件一并删掉:
from django.db.models.signals import post_delete from django.dispatch import receiver @receiver(post_delete, sender=FileRecord) def auto_delete_file_on_delete(sender, instance, **kwargs): if instance.file: if os.path.isfile(instance.file.path): os.remove(instance.file.path)这段代码放在models.py底部,要在apps.py的ready()里导入信号模块。注意用os.path.isfile先判断存在再删,不然文件已经被手动清掉时会抛FileNotFoundError,连删除记录的操作也会一起失败。
5.5 Django 3.2 之后的index_together报错——迁移失败
现象:python manage.py makemigrations报RemovedInDjango51Warning,或者在迁移时报AttributeError: 'Options' object has no attribute 'index_together'。
原因:源码包里可能用了老版本 Django 的写法,你的解释器装的是新版。index_together在 Django 4.2 起被废弃,改用Meta.indexes。
解决:把模型的class Meta里的index_together = [('category', 'created_at')]改成
class Meta: indexes = [models.Index(fields=['category', 'created_at'], name='doc_cat_time')]改完删掉数据库里的旧迁移记录(如果还没生产数据),重新makemigrations就行。这属于典型的"源码包版本老、运行环境版本新"导致的兼容问题,别去降级 Django,降级会引发依赖冲突,更麻烦。
6. 部署到生产环境:从 SQLite 平滑迁移到 MySQL 的实操记录
6.1 数据迁移,不只是改 settings 里的 DATABASES
本地开发用 SQLite 没什么问题,上线前要切 MySQL,这时候你遇到的第一个坑是文件路径里的media根目录配额和数据库连接数。迁移步骤我按顺序给你列:
第一步,修改settings.py:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'doc_system', 'USER': 'doc_app', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'CONN_MAX_AGE': 60, # 连接复用,减少握手开销 'OPTIONS': {'charset': 'utf8mb4'}, # 支持中文和 emoji } }第二步,给 MySQL 建库并授权:CREATE DATABASE doc_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;,注意COLLATE如果选错,Django 的字段排序会和你本地 SQLite 的结果不一致,具体表现在order_by('-created_at')上。第三步,在新库上直接python manage.py migrate建表,然后通过python manage.py dumpdata --exclude=contenttypes --exclude=admin.logentry > data.json导出旧数据,再到新环境python manage.py loaddata data.json。
dumpdata和loaddata是 Django 自带的序列化工具,比 Navicat 直接导 SQL 可靠得多,因为loaddata会处理外键顺序和自增 ID 的记录,直接导.sql文件很容易因为表顺序错乱导致外键冲突。
6.2zip源码包解压部署时的常见小问题
标题里的源码包是.zip格式,Windows 上右键解压是默认操作,但这里有一个高频翻车点:如果你用的解压工具是 WinRAR 或 7-Zip,解压后文件属性可能被标记为"受保护"或者权限丢失,导致 Django 的manage.py报Permission denied。这不是代码问题,右键文件属性里去掉"只读"再执行即可。Linux 服务器上如果是上传的 zip 包,用unzip -o解压后建议跑一句chmod -R 755修正目录权限。
另外你的项目里如果包含.env文件(存放SECRET_KEY和数据库密码的),千万别把它一起打进 zip 里发出去。SECRET_KEY泄露后攻击者可以直接伪造 session 和数据签名。把.env放到项目根目录并写进.gitignore,生产环境的密钥由部署平台的环境变量注入,这是源码包二次分发时最容易被忽略的安全问题。
MySQL 连接参数上,CONN_MAX_AGE=60能大幅降低频繁握手带来的延迟,但副作用是数据库重启后连接池里的旧连接会失效,你需要在启动脚本里加一句python manage.py check或者在 MySQL 侧把wait_timeout和interactive_timeout适当调大(比如 28800)。不然你会在某个半夜收到监控报警,页面突然大量 500,查后端日志发现一堆OperationalError: (2006, "MySQL server has gone away')——这就是旧连接超时被断开导致的。
6.3 性能回来验证:三分钟确认系统没有带病上线
部署完成后不要急着关电脑,按这个清单快速验证一遍:第一,上传一个 20MB 的文档,观察media/documents/下是否出现按年月分类的目录,文件大小和数据库里记录是否一致;第二,搜索一个只有文件内容里才有的词,确认结果能出现——如果搜不到,说明你的文本解析链断了;第三,用一个非admin组的账号尝试访问一个受控文档的下载链接,确认返回 403 而不是 200;第四,停掉 MySQL 再启动,连着访问三个页面,确认没有 500 浪潮。
说到这我想起一个血泪教训:我当年第一次部署这类系统,急着把文档管理后台给业务方看,跳过全文检索验证,结果业务方上传了上百份 PDF 后搜不到任何内容,排查了半天发现是解析库没装——pdftotext命令行工具缺失,PyPDF2 解析出来的全部是空字符串。后来我把"解析失败自动标记不可检索"做成了默认行为,并在文档详情页展示"内容已入库/不可检索"的状态,从此这类问题再也没在深夜打爆过我的手机。希望你这次在部署环节多留十分钟做完整验证,不要跳过检索那条线,希望帮到你。
本文还有配套的精品资源,点击获取