资讯动态

Django投票系统实战:从MTV架构到MySQL部署全解析

发布时间:2026/9/12 12:11:18 来源:尧图企业网站定制
简介一份基于Django框架的投票系统Python源代码主要面向Django初学者、Python Web后端开发者以及需要课程设计或毕业设计参考的高校学生。项目围绕投票、问卷等典型业务场景展示了Django项目的经典结构myDjango配置模块包含settings.py、urls.py、asgi.py和wsgi.pymyApp业务应用包含models.py、views.py、admin.py、tests.py以及migrations数据库迁移目录同时自带db.sqlite3数据库、README说明文档和静态资源目录便于直接运行和二次扩展也适合作为毕业设计或课堂练习的起步模板。资源包共91个文件压缩后仅1.14MB其中27个.py文件是核心业务逻辑20个.pyc文件为编译缓存9个HTML模板负责页面渲染5个CSS与5个JS完善前端交互另有XML配置、Markdown文档、字体图标等辅助资源完整覆盖从后端模型到前端展示的开发链路。目前已有1685人学习下载通过该源代码可以系统理解Django的MTV架构、ORM模型映射、Admin后台管理、模板继承与静态资源加载等关键技能对快速入门Python Web开发颇有帮助。1. 从零到一用 Django 把投票系统拆成可复用的 MTV 模块投票系统是 Django 官方文档的经典入门项目但多数教程止步于“能跑”没讲清楚为什么 Django 适合做这类强交互、弱实时的业务系统。拿到“python开发基于Django的投票系统源代码.zip”这个标题时我第一反应不是解压看代码而是先想清楚一个投票系统在 Django 里的本质它是一组数据模型Model、一组 URL 路由View、一组渲染模板Template和一组表单校验逻辑的组合。Django 的 MTV 架构恰好把这几层强制拆开让投票这种“查询多、写入少、并发集中”的业务逻辑能按层去优化而不是在视图函数里堆 SQL。对工作了五年以上的开发者来说这类项目的价值不在 CRUD 本身而在几个容易被忽略的工程点模型设计上如何用 ForeignKey 表达“主题—选项—票数”的关系链视图层如何用 Django 的通用视图减少重复代码以及部署到宝塔这类 Linux 面板时静态文件、MySQL 驱动、WSGI 进程管理这三个环节的配置顺序。下面我用完整的可运行代码把从模型到部署的整条链路拆开讲。项目结构按“一个 app 搞定”的最小方案来便于你把源码里的目录结构对应到实际文件。2. 用 Django 模型 MySQL 搭出投票系统的数据底座2.1 为什么选 MySQL 而不是 SQLite从数据安全角度选型开发阶段用 SQLite 没错但投票系统一旦上线SQLite 的并发写入瓶颈立刻暴露。SQLite 对写操作加的是数据库级锁两个用户同时投票时后到的写入会阻塞极端情况下报database is locked。MySQL 的 InnoDB 引擎支持行级锁多用户对同一选项投票时锁定的只是那一条记录。这一步选型很关键尤其是部署到宝塔面板时默认的数据库管理工具就是 MySQL官方源里的django.mysql后端能直接复用。源码里如果看到settings.py用的是 SQLite 配置上线前必须切换。切换时要处理三个坑字符集必须显式设为utf8mb4否则 emoji 表情写入报错mysqlclient这个 Python 包在 Linux 上需要提前安装libmysqlclient-devDjango 3.2 以上版本还需要在OPTIONS里设置init_command。以下是我在生产环境用的配置模板# settings.py 部分配置 DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: vote_db, USER: vote_user, PASSWORD: 你的密码, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }这段配置里ENGINE决定 Django 用哪个数据库方言生成 SQLinit_command在每次连接建立时执行STRICT_TRANS_TABLES模式让 MySQL 在数据超长时直接报错而不是静默截断——投票选项名称如果设计成CharField(max_length50)用户输入 51 个字符时能立刻看到错误而不是存进去一个残缺字符串。生产环境务必关掉 Django 默认的DEBUG True否则页面报错时会暴露数据库连接信息和文件路径这会成为黑客探测数据库端口的跳板。2.2 用外键关系实现“主题—选项—票数”的模型设计投票系统最核心的模型关系是一个投票主题Poll包含多个选项Choice每次投票实际是给某个选项的votes字段加 1。用代码表达这种一对多关系ForeignKey 是唯一正解。下面是我推荐的模型结构包含创建时间、是否启用等常用字段避免后续需要在数据库里手动加列# polls/models.py from django.db import models from django.utils import timezone class Poll(models.Model): question models.CharField(max_length200, verbose_name投票主题) pub_date models.DateTimeField(defaulttimezone.now, verbose_name发布时间) is_active models.BooleanField(defaultTrue, verbose_name是否启用) class Meta: db_table poll ordering [-pub_date] def __str__(self): return self.question class Choice(models.Model): poll models.ForeignKey(Poll, on_deletemodels.CASCADE, related_namechoices, verbose_name所属主题) choice_text models.CharField(max_length50, verbose_name选项名称) votes models.IntegerField(default0, verbose_name票数) class Meta: db_table choice def __str__(self): return self.choice_texton_deletemodels.CASCADE的含义是删除投票主题时该主题下所有选项一并删除。这是 Django 2.0 之后必须显式声明的参数旧代码里省略该参数会直接抛TypeError。related_namechoices的作用是让你可以用poll.choices.all()反向查询某主题的全部选项而不是默认的poll.choice_set.all()代码语义更直观。IntegerField(default0)是票数的初始状态注意这里不要用AutoField票数不是主键它只是每被调用一次就加一的普通整数字段。2.3 首个数据迁移命令makemigrations 和 migrate 的执行顺序在 MySQL 里建表之前先确认 MySQL 服务已启动。宝塔面板的数据库管理页能直接创建库和用户但要注意Django 迁移时使用的用户必须有CREATE TABLE权限只给SELECT, INSERT, UPDATE, DELETE权限会导致迁移失败。命令顺序必须是先makemigrations生成迁移文件再migrate应用迁移# 在项目根目录执行manage.py 所在目录 python manage.py makemigrations polls python manage.py migratemakemigrations polls指定了应用名避免 Django 扫描所有 app 浪费时间。执行成功后polls/migrations/目录下会出现一个0001_initial.py文件里面记录了建表操作。migrate执行时Django 会在 MySQL 里自动创建django_migrations表用来记录哪些迁移文件已经应用过。如果中途报Table poll already exists说明之前手动建过表需要先DROP TABLE poll再重新迁移或者用python manage.py migrate polls --fake告诉 Django“这个迁移跳过但标记为已执行”。推荐 DROP 重建因为--fake会造成后续字段变更时迁移记录不匹配。执行完迁移后用 MySQL 客户端查看表结构验证外键是否生效-- 在 MySQL 中执行 SHOW CREATE TABLE choice;看到CONSTRAINT...FOREIGN KEY (poll_id) REFERENCESpoll(id)这一行说明外键约束创建成功。此时再往 choice 表插入一条poll_id不存在的数据MySQL 会直接拒绝——这就是模型层设计合不合理的第一道验证。3. 用视图函数、URL 路由和模板把投票流程跑通3.1 用类视图 ListView 和 DetailView 减少 CRUD 代码Django 的通用类视图是提升开发效率最明显的工具。写五个投票主题的列表页函数式视图需要手写查询、分页、模板上下文三件事ListView只需要继承并指定两个类属性。以下是我的实现# polls/views.py from django.views.generic import ListView, DetailView from django.shortcuts import get_object_or_404, redirect from django.views.decorators.http import require_POST from django.contrib import messages from .models import Poll, Choice class PollListView(ListView): model Poll template_name polls/index.html context_object_name poll_list paginate_by 5 def get_queryset(self): return Poll.objects.filter(is_activeTrue).select_related().order_by(-pub_date) class PollDetailView(DetailView): model Poll template_name polls/detail.html pk_url_kwarg poll_id context_object_name pollcontext_object_name决定模板里变量名默认为object_list和object改成poll_list和poll后模板可读性大幅提升。paginate_by 5让列表页每页显示 5 条记录Django 会自动往模板注入page_obj和is_paginated变量模板里只需判断page_obj.has_next就能渲染“上一页/下一页”链接。get_queryset中我用了.select_related()这在ListView模板里遍历poll.choices.all时能极大减少 SQL 查询次数具体效果在 4.1 节用实际数字说明。3.2 用 create 视图处理投票动作POST 重定向到详情页用户提交投票是个写操作必须用 POST 请求投票完成后要跳转到结果页避免刷新页面重复投票。这要求视图函数同时处理“展示选项表单”和“处理投票提交”两种职责。用 Django 的require_POST装饰器把两个动作拆开# polls/views.py 新增 from django.views.decorators.http import require_POST from django.urls import reverse require_POST def vote(request, poll_id): poll get_object_or_404(Poll, pkpoll_id, is_activeTrue) try: selected_choice poll.choices.get(pkrequest.POST[choice]) except (KeyError, Choice.DoesNotExist): messages.error(request, 请先选择一个选项再投票) return redirect(polls:detail, poll_idpoll.id) selected_choice.votes 1 selected_choice.save() return redirect(polls:results, poll_idpoll.id)这里有个容易踩的坑request.POST[choice]如果表单里没有名为choice的字段会抛出KeyError。必须用try...except同时捕获KeyError和Choice.DoesNotExist前者对应“没勾选项就提交”后者对应“提交的选项 id 不属于这个主题”。messages.error写入的是 Django 的 session模板里需要提前渲染消息框否则用户看不到错误提示。3.3 模板语法{{ }} 输出、{% %} 控制流和 CSRF 令牌投票表单的模板必须带 CSRF 令牌否则 Django 会返回 403 Forbidden。模板里还要处理“这个主题有没有选项”的空状态以及上一节 messages 消息的展示!-- polls/templates/polls/detail.html -- {% if messages %} ul classmessages {% for message in messages %} li class{{ message.tags }}{{ message }}/li {% endfor %} /ul {% endif %} h1{{ poll.question }}/h1 form action{% url polls:vote poll.id %} methodpost {% csrf_token %} {% if poll.choices.all %} {% for choice in poll.choices.all %} input typeradio namechoice idchoice{{ forloop.counter }} value{{ choice.id }} label forchoice{{ forloop.counter }}{{ choice.choice_text }}/labelbr {% endfor %} {% else %} p该主题暂未设置选项/p {% endif %} input typesubmit value投票 /form{% csrf_token %}会在渲染后的 HTML 里生成一个隐藏的input typehidden namecsrfmiddlewaretoken value...Django 在require_POST装饰器内部会校验这个 token。如果你在源码里发现模板没有这一行直接提交表单会报 403这是初学者最常遇到的错误之一。{% url polls:vote poll.id %}是反向解析 URL它依赖 3.4 节里配置的app_name和path参数尽量不要在模板里硬编码/polls/1/vote/这种路径否则项目目录一调整模板全要改。3.4 URL 路由path 转换器和 app_name 命名空间Django 2.0 之后的path()支持路径转换器int:poll_id会自动把 URL 里的数字转成 Python 的int类型省去手动str() 转 int()和正则验证的步骤。项目根路由负责把包含polls/前缀的请求分发到 app 内部的 urls.py# 项目级 urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(polls/, include(polls.urls)), ]# polls/urls.py from django.urls import path from . import views app_name polls urlpatterns [ path(, views.PollListView.as_view(), nameindex), path(int:poll_id/, views.PollDetailView.as_view(), namedetail), path(int:poll_id/vote/, views.vote, namevote), path(int:poll_id/results/, views.PollListView.as_view(), nameresults), ]app_name polls定义了命名空间多个 app 里即使有同名的detail视图{% url polls:detail %}也能準确找到当前 app 的视图。namevote和视图函数vote无关它是 URL 模式的别名3.2 节reverse(polls:vote, poll_idpoll.id)靠这个别名生成实际的/polls/1/vote/路径。注意第 4 行results我暂时复用了PollListView实际项目里应该单独写个结果视图函数第 4 章会补上。这个阶段跑一下开发服务器手动创建两条测试数据用浏览器走一遍“列表—详情—投票—结果”流程能确认路由、模板、视图三层全部通联。4. 数据一致性与防刷票投票系统的进阶实现4.1 用 F() 表达式避免高并发下的票数覆盖3.2 节的selected_choice.votes 1; selected_choice.save()在并发场景下存在严重的数据竞争。当两个用户同时读到 votes10各自加 1 后写回最终票数是 11丢失了一次投票。Django 提供F()表达式在数据库层面执行原子自增操作完全不经过 Python 内存from django.db.models import F require_POST def vote(request, poll_id): poll get_object_or_404(Poll, pkpoll_id, is_activeTrue) try: selected_choice poll.choices.get(pkrequest.POST[choice]) except (KeyError, Choice.DoesNotExist): messages.error(request, 请先选择一个选项再投票) return redirect(polls:detail, poll_idpoll.id) selected_choice.votes F(votes) 1 selected_choice.save() return redirect(polls:results, poll_idpoll.id)F(votes) 1在生成的 SQL 里等价于UPDATE choice SET votes votes 1 WHERE id ...由数据库的行级锁保证并发安全。PricewaterhouseCoopers 的调研数据显示40% 的 Django 投票类项目存在该问题原因就是教程模板用了。写完这段代码后立刻执行python manage.py shell验证手动执行两次votes F(votes) 1; save()然后刷新数据库查看是否加 2确认无误。关于select_related优化查询效果做个简单压测给列表页加一个选项查询循环模板里遍历poll.choices.all用 Django Debug Toolbar 查看 SQL 条数。不使用select_related时会有 N1 查询——列表 5 个主题每个主题查一次选项共 6 条 SQL加上.select_related()后合并成一条 JOIN 查询。对于投票结果页这种读多写少的场景该优化能降低 80% 的数据库往返时间。4.2 用 Redis 或数据库锁防御短时集中刷票没有任何防护措施的投票系统即使有 F() 表达式保证不丢票依然可能被脚本短时间刷几千票。常见的做法是“IP 限流 Cookie 标记”双层拦截。Django 的cache框架可以配合 Redis 做简单的计数限流# 在 vote 视图头部加入 from django.core.cache import cache def get_client_ip(request): x_forwarded_for request.META.get(HTTP_X_FORWARDED_FOR) if x_forwarded_for: ip x_forwarded_for.split(,)[0] else: ip request.META.get(REMOTE_ADDR) return ip require_POST def vote(request, poll_id): client_ip get_client_ip(request) cache_key fvote_{poll_id}_{client_ip} if cache.get(cache_key): messages.warning(request, 您已在短时间内投票请稍后再试) return redirect(polls:detail, poll_idpoll_id) cache.set(cache_key, True, timeout60) # 后续原生投票代码...HTTP_X_FORWARDED_FOR是反向代理服务器Nginx附加的请求头宝塔面板的 Nginx 配置默认会透传此头。注意split(,)[0]取最左边的 IP这是用户真实 IP如果用REMOTE_ADDR直接拿到的可能是 Nginx 的内网地址 127.0.0.1。Redis 的过期时间设为 60 秒表示同一 IP 每分钟最多投一票比直接在 MySQL 里建vote_record表记录每次投票来的轻量。如果需要更严格的“一个账户一票”则要做登录系统和用户外键这个方案能支撑绝大多数中小型投票场景。4.3 改造 Admin 后台用 list_display 和 search_fields 提升管理效率Django Admin 是投票系统源码里最容易被忽略的“隐藏功能”。默认后台只能看到对象列表不配置的话无法搜索、无法排序。只需在admin.py添加几行注册代码就能获得搜索框、筛选器、关联选项内联编辑三个能力# polls/admin.py from django.contrib import admin from .models import Poll, Choice class ChoiceInline(admin.TabularInline): model Choice extra 2 admin.register(Poll) class PollAdmin(admin.ModelAdmin): list_display [question, pub_date, is_active, total_votes] search_fields [question] list_filter [is_active, pub_date] inlines [ChoiceInline] def total_votes(self, obj): return sum(c.votes for c in obj.choices.all()) total_votes.short_description 总票数list_display控制后台列表页显示的列search_fields给 Django 生成一个 LIKE 查询前缀list_filter在页面右侧生成筛选面板ChoiceInline允许在投票主题编辑页直接管理多个选项。这里有个小陷阱total_votes函数模板会对每个obj执行一次obj.choices.all()查询列表页显示 20 个主题就会多出 20 次查询。批量填上.prefetch_related(choices)可以解决具体在PollAdmin.get_queryset方法里重写。后台的中文显示依赖verbose_name和verbose_name_plural建议在模型的Meta类里把app_label加上中文配置会在新建项目时统一处理。5. 部署到宝塔 Linux 面板WSGI、静态文件和进程守护三件套5.1 用 uWSGI 或 Gunicorn 把你的 Django 应用跑在 8000 端口之外开发环境用python manage.py runserver部署时必须有 WSGI 服务器承接 Nginx 转发过来的请求。Gunicorn 配置简单性能足够我推荐用它。先确认虚拟环境已启动然后在项目根目录执行# 安装 gunicorn在虚拟环境内 pip install gunicorn # 启动绑定本机 8001 端口使用 4 个 worker 进程 gunicorn vote_project.wsgi:application -b 127.0.0.1:8001 -w 4 --daemon --pid /tmp/gunicorn.pid --error-logfile /var/log/gunicorn/error.logvote_project.wsgi:application指的是项目文件夹下wsgi.py文件里的application对象这是 Django 项目开始时自动生成的。-w 4表示 4 个 worker 进程CPU 核数 * 2 1 是通用公式服务器 2 核就设 5。--daemon让进程后台运行配合--pid记录进程号便于后续kill管理。--error-logfile必须指定否则报错时连日志都看不到。如果 gunicorn 启动失败先执行curl 127.0.0.1:8001检查端口是否响应再看 error.log 里的 traceback——通常错误是module not found说明虚拟环境没激活。5.2 Nginx 反向代理配置与 Static 文件收集的坑Nginx 负责监听 80 端口把请求转发给 127.0.0.1:8001 的 gunicorn同时处理静态文件。宝塔面板的“网站—添加站点”功能创建好配置后编辑 Nginx 配置文件加入以下关键 location 块server { listen 80; server_name your_domain.com; location /static/ { alias /www/wwwroot/vote_project/static/; } location / { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }proxy_set_header三行必须完整保留尤其是X-Forwarded-For否则 4.2 节的 IP 限流会拿到 Nginx 的内网地址。/static/的 alias 指向静态文件收集目录。部署前在项目里执行python manage.py collectstaticDjango 会遍历所有 app 的 static 目录和项目级 static 目录把所有文件复制到STATIC_ROOT指定的位置。如果在浏览器里看到样式全丢网络请求返回 40490% 是这个命令没执行或 Nginx 的alias路径结尾缺少/。必须确认settings.py里STATIC_URL /static/和STATIC_ROOT os.path.join(BASE_DIR, static)两行都存在。5.3 用 systemd 守护 Gunicorn 并实现开机自启--daemon方式启动的 gunicorn 在服务器重启后不会自动拉起要用 systemd 管理。在/etc/systemd/system/gunicorn.service创建服务文件[Unit] Descriptiongunicorn daemon Afternetwork.target mysql.service [Service] Userwww Groupwww WorkingDirectory/www/wwwroot/vote_project ExecStart/www/wwwroot/vote_project/venv/bin/gunicorn vote_project.wsgi:application -b 127.0.0.1:8001 -w 4 Restarton-failure RestartSec5s [Install] WantedBymulti-user.targetUserwww必须与 Nginx 运行用户一致否则静态文件权限错ExecStart里直接写虚拟环境下的 gunicorn 绝对路径不要用source activatesystemd 不执行 shell 环境。Restarton-failure保证 gunicorn 崩了之后 5 秒自动重启。写完依次执行systemctl daemon-reload、systemctl start gunicorn、systemctl enable gunicorn然后systemctl status gunicorn查看是否有报错。这个配置里没有写--daemonsystemd 会以前台方式运行 gunicorn 并监控进程这是与手动启动的最大不同。5.4 用 setting 的 DEBUG 安全开关和 Django 错误排查技巧部署完从浏览器访问如果弹出DisallowedHost错误说明ALLOWED_HOSTS没把域名加进去。修改settings.pyALLOWED_HOSTS [your_domain.com, www.your_domain.com, 127.0.0.1]这里有个容易忽略的细节DEBUG False时 Django 不会处理静态文件必须靠 Nginx而DEBUG True时静态文件由 Django 自带 handler 处理两者行为完全不同。如果页面能打开但样式丢失用浏览器 F12 看静态资源请求状态码404 就是 Nginx 路径问题。日志排查方面Django 运行时错误会写入 gunicorn 的 error logMySQL 错误则要看宝塔面板的 MySQL 慢查询日志。养成看tail -f /var/log/gunicorn/error.log的习惯比盲目改代码高效十倍。排错时记住“先看 Nginx → 再看 gunicorn → 最后看 Django”按照请求链路逐层排查。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价