Django+MySQL校园二手交易网站开发实战:从选型到部署
简介面向毕业设计和Python Web学习者基于PythonDjangoMySQL的校园二手交易跳蚤市场完整源码案例覆盖用户、商品、购物车、订单交易、安全防护等核心模块可直接部署运行或作为课程设计参考。压缩包共494个文件整体41.29MB包含50个py源码文件、124个pyc编译文件、24个html页面模板、11个js和7个css前端文件、2个sql数据库脚本以及大量jpg/png图片素材源码、模板、静态资源与数据库脚本分层清晰。已有94人学习浏览项目展示了从数据库建模、ORM查询到前端页面渲染的完整开发流程帮助理解Django框架的MTV架构、MySQL数据交互以及前后端协作方式。通过阅读和改造代码可快速上手毕业设计答辩准备学习用户认证、商品管理、购物车结算等典型业务逻辑也能参考其中的安全处理与目录结构设计提升实际项目开发能力。1. 校园二手交易网站为什么这个题目值得做成 Django 项目每年毕业季某高校宿舍楼下的公告栏都会被「出售考研资料」「九成新自行车」这类纸条贴满。与其说这是校园传统不如说是一个真实存在的信息匹配需求买家想低价拿到学长学姐的闲置物品卖家想腾空宿舍。把这个场景搬到线上做一个基于 Python Django MySQL 的校园二手交易跳蚤市场网站就成了计算机专业毕业设计里最常被选中的题目之一——技术栈经典、需求清晰、功能边界明确做完能跑通讲起来也有东西可讲。这个项目解决的不只是「发帖卖东西」这一件事。它要覆盖用户注册登录、商品发布与分类浏览、关键词搜索、下单与订单状态管理、站内留言这几个核心闭环。对新手来说Django 自带的 Admin 后台和 ORM 能省掉一大半重复劳动对想冲高分的同学来说订单状态机、图片上传、分页搜索、部署上线这些点都是可以写进论文和答辩 PPT 的亮点。这篇文章我按自己做过类似项目的习惯把从选型到落地、从参数设置到排错思路完整拆给你。2. Django MySQL 技术选型先搞清楚为什么是这三件套2.1 为什么选 Django 而不是 Flask 或 Spring Boot做校园二手交易网站这种典型的 CRUD 加少量业务状态流转的项目Django 是性价比最高的选择。它自带 Admin 后台商品分类、用户管理、订单查看这些功能不用写一行前端代码就能在后台操作自带认证体系用户注册、登录、会话管理直接基于内置的django.contrib.auth扩展省去自己写密码加密和 session 存储的麻烦ORM 让你操作 MySQL 时写 Python 对象而不是拼 SQL 字符串大幅降低 SQL 注入风险。对比 FlaskFlask 胜在轻量灵活但用户认证、Admin 后台、表单校验这些都得自己集成第三方库做一个完整交易站点的工作量会明显变大。对比 Spring BootJava 技术栈在校园项目里往往意味着更重的配置和更长的编译时间而 Django 的开发调试循环短改完代码重启服务就能看到效果。如果这个项目是你的毕设时间通常只有几个月Django 能让你把精力留给业务逻辑而不是框架配置。2.2 项目结构长什么样一个可复用的目录骨架我通常会把 Django 项目拆成多个应用app每个应用只负责一块业务。下面这个结构是我做同类项目常用的布局你拿到源码包后可以对照着看secondhand_market/ ├── manage.py ├── requirements.txt ├── market/ # 主配置文件目录 │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── users/ # 用户模块注册/登录/个人信息 │ ├── goods/ # 商品模块发布/列表/详情/搜索 │ ├── orders/ # 订单模块下单/确认/取消 │ └── messages/ # 留言模块站内私信 ├── static/ # 前端静态文件 ├── media/ # 用户上传图片目录 └── templates/ # HTML 模板把不同业务放进独立的 app核心好处是迁移migration互不干扰。比如商品模块要加一个「是否加急」字段只需要生成goods应用的迁移文件不会牵连订单表结构。另外一个容易被忽略的点是static和media目录必须分开static放你自己写的 CSS/JS 和 Django 自带的静态资源media放用户上传的商品图片两者混在一起会导致部署时静态文件收集出错。2.3 数据模型设计二手交易的核心表就这五张设计数据库表时不要一上来就追求大而全二手交易平台最核心的实体只有用户、商品、订单、留言和分类。下面是商品表的模型定义我把字段注释写在代码里方便你对照理解# apps/goods/models.py from django.db import models from django.contrib.auth.models import User class Category(models.Model): name models.CharField(max_length32, uniqueTrue, verbose_name分类名) sort_order models.IntegerField(default0, verbose_name排序权重) class Meta: ordering [sort_order, id] class Goods(models.Model): STATUS_CHOICES [ (on_sale, 在售), (sold, 已售出), (off_shelf, 已下架), ] title models.CharField(max_length64, verbose_name商品标题) desc models.TextField(max_length1024, verbose_name商品描述) price models.DecimalField(max_digits10, decimal_places2, verbose_name价格) original_price models.DecimalField( max_digits10, decimal_places2, nullTrue, blankTrue, verbose_name原价 ) category models.ForeignKey( Category, on_deletemodels.PROTECT, related_namegoods, verbose_name分类 ) owner models.ForeignKey( User, on_deletemodels.CASCADE, related_namegoods, verbose_name发布者 ) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaulton_sale) view_count models.IntegerField(default0, verbose_name浏览次数) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) def __str__(self): return self.title这里有两个参数值得单独说明。price字段用DecimalField而不是FloatField是因为浮点数在 MySQL 里存储金额会出现0.1 0.2 ! 0.3的精度问题虽然二手交易不涉及银行级精度但答辩时被问到「为什么用 Decimal」答不上来会很尴尬。category外键的on_deletemodels.PROTECT是我刻意选的如果某个分类下还有商品删除分类会被数据库拒绝避免「分类删了商品指向空分类」这种脏数据。相比之下owner用CASCADE是合理的——用户注销时他的商品一并删除符合校园二手平台的使用预期。3. 核心功能怎么落地注册、发布、搜索和订单流转3.1 用户注册与登录站在 Django 内置 Auth 的肩膀上用户模块不需要自己写密码加密和 session 逻辑Django 的django.contrib.auth已经把这些做完了。但二手交易平台需要额外的用户信息比如学号、宿舍楼栋、联系方式所以常见的做法是新建一个 Profile 模型做一对一扩展# apps/users/models.py from django.db import models from django.contrib.auth.models import User class Profile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameprofile) student_id models.CharField(max_length20, blankTrue, verbose_name学号) dorm_building models.CharField(max_length32, blankTrue, verbose_name宿舍楼栋) contact models.CharField(max_length32, blankTrue, verbose_name联系方式) def __str__(self): return self.user.username注册的视图逻辑我会写在views.py里用 Django 的UserCreationForm做基础校验再手动补一个 Profile 的保存# apps/users/views.py from django.contrib.auth.forms import UserCreationForm from django.shortcuts import render, redirect from django.contrib.auth import login from .models import Profile def register(request): if request.method POST: form UserCreationForm(request.POST) if form.is_valid(): user form.save() # 自动处理密码哈希 Profile.objects.create(useruser) login(request, user) # 注册后直接登录减少一次跳转 return redirect(goods:list) else: form UserCreationForm() return render(request, users/register.html, {form: form})这里有个值得注意的细节login(request, user)这行代码是注册后体验的关键。如果不调用它用户注册完还要再去登录页输一遍账号密码校园用户本来就没有耐心多一步跳转就可能流失。另外Profile.objects.create(useruser)理论上应该在user.save()之后因为Profile的外键需要主键值但 Django 的form.save()是原子操作可以直接拿到带主键的user对象所以顺序没问题。3.2 商品发布用 ModelForm 做校验省掉一半表单处理代码商品发布页是用户最常用的功能包含标题、描述、价格、分类和图片上传。直接手写表单校验很容易漏掉「价格必须大于 0」「图片大小不能超过 2MB」这类边界条件所以我习惯用 ModelForm# apps/goods/forms.py from django import forms from .models import Goods class GoodsForm(forms.ModelForm): class Meta: model Goods fields [title, desc, price, original_price, category, image] widgets { desc: forms.Textarea(attrs{rows: 4, placeholder: 描述一下物品的新旧程度、入手渠道...}), } def clean_price(self): price self.cleaned_data[price] if price 0: raise forms.ValidationError(价格必须大于 0) return price def clean_image(self): image self.cleaned_data.get(image) if image and image.size 2 * 1024 * 1024: raise forms.ValidationError(图片大小不能超过 2MB) return imageModelForm 的好处在于它自动从 Goods 模型读取字段定义你不用重复声明每个字段的类型和校验规则。clean_price和clean_image是 Django 钩子方法分别对应「验证价格非负」和「验证图片大小」只要方法名以clean_加字段名命名Django 就会在表单提交时自动调用。图片大小限制是必须做的否则用户传一张 20MB 的照片进来Django 处理上传时会拖垮服务器后面静态文件目录也会被塞满。3.3 搜索与分页Q 对象做多字段查询Paginator 控制每页条数校园二手平台的搜索场景很典型用户想找「考研数学」相关的书但标题里写的是「数学一复习全书 2026 版」只搜标题容易漏。所以我在搜索视图里同时匹配标题和描述字段# apps/goods/views.py from django.views.generic import ListView from django.db.models import Q from .models import Goods class GoodsListView(ListView): model Goods template_name goods/list.html context_object_name goods_list paginate_by 12 # 每页 12 件商品 def get_queryset(self): qs Goods.objects.filter(statuson_sale) keyword self.request.GET.get(keyword, ).strip() if keyword: qs qs.filter( Q(title__icontainskeyword) | Q(desc__icontainskeyword) ) category_id self.request.GET.get(category, ) if category_id.isdigit(): qs qs.filter(category_idint(category_id)) return qs.select_related(category, owner)paginate_by 12这个参数是根据校园用户浏览习惯定的手机端一屏大约显示 4 到 6 个商品卡片12 条正好是两到三屏的量翻页频率适中。select_related(category, owner)是性能优化的关键它会在 SQL 层面用 JOIN 一次性把商品对应的分类和用户信息查出来避免每渲染一个商品就多执行两次查询。如果你的商品列表页有 N 条商品不加这行就是 1 N 条 SQL加了就只有 1 条这个差异在数据量到几百条时就能明显感知到。3.4 订单状态流转把交易状态写成状态机而不是随便改字段订单模块最容易翻车的地方是状态管理。如果只用一个status字段随改随存会出现「已取消的订单还能确认收货」「卖家还没发货买家就能点击完成」这种逻辑漏洞。我的做法是在模型中定义状态流转的合法性用常量加校验方法控制# apps/orders/models.py from django.db import models from django.core.exceptions import ValidationError class Order(models.Model): STATUS_FLOW { pending: [paid, cancelled], # 待付款 - 已付款 / 已取消 paid: [shipped, cancelled], # 已付款 - 已发货 / 申请取消 shipped: [completed], # 已发货 - 已完成 completed: [], # 终态 cancelled: [], # 终态 } goods models.ForeignKey(goods.Goods, on_deletemodels.PROTECT) buyer models.ForeignKey(auth.User, on_deletemodels.PROTECT) status models.CharField(max_length16, defaultpending) created_at models.DateTimeField(auto_now_addTrue) def transition_to(self, new_status): if new_status not in self.STATUS_FLOW.get(self.status, []): raise ValidationError(f非法状态流转: {self.status} - {new_status}) self.status new_status self.save()STATUS_FLOW字典定义了一张状态转移表这是从有限状态机理论落到代码的最简实现。transition_to方法在每次状态变更时检查合法性非法流转直接抛异常。它的好处不只是逻辑严谨答辩时你还能顺势讲出「状态机避免了逻辑散落各处」的设计思路这比「我在视图里判断了一下」要有说服力得多。4. 把 MySQL 接进 Django配置、驱动和初始化数据4.1 settings 配置五个必填参数和两个容易忽视的选项Django 连接 MySQL 的配置集中在settings.py的DATABASES字典里如果你拿到的是sqlite3的演示版本迁移到 MySQL 的第一步就是改这里# market/settings.py DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: secondhand_market, # 数据库名需提前在 MySQL 中创建 USER: root, # 数据库账号 PASSWORD: your_password, # 数据库密码 HOST: 127.0.0.1, # MySQL 主机地址 PORT: 3306, # 默认端口 OPTIONS: { charset: utf8mb4, # 中文与 emoji 全支持 init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }这里有两处是我反复强调的。第一charset必须写成utf8mb4而不是utf8因为 MySQL 的 utf8 实际上是 utf8mb3不支持 emoji 和部分生僻字用户发布「 九成新教材」这种标题时写入数据库会报错。第二sql_modeSTRICT_TRANS_TABLES让 MySQL 在写入超长字符串或非法日期时直接报错而不是静默截断这能帮你早点发现数据问题而不是等数据烂了再排查。4.2 PyMySQL 驱动与__init__.py补丁Django 本身不自带 MySQL 驱动常见做法是安装 PyMySQL然后在项目主配置目录的__init__.py里注册它pip install pymysql# market/__init__.py import pymysql pymysql.install_as_MySQLdb()这行补丁的意义在于Django 默认import MySQLdb而 PyMySQL 提供了完全兼容的模块install_as_MySQLdb()会让 Django 把 PyMySQL 当作 MySQLdb 使用。另一个可选方案是安装mysqlclient驱动它的性能略好但需要系统编译环境Windows 上经常因为缺 VC 编译器安装失败。我的建议是本地开发和毕设演示用 PyMySQL 足够别在环境上浪费太多时间。4.3 迁移、初始数据与常见导入错误数据库连接配置完成后依次执行下面三条命令python manage.py makemigrations # 生成迁移文件 python manage.py migrate # 把迁移文件应用到数据库 python manage.py loaddata initial_data.json # 导入初始分类数据makemigrations会扫描所有应用下的 models 变更生成迁移文件只生成不执行migrate才是真正建表。新手最容易犯的错是改完模型直接跑migrate而跳过makemigrations导致 Django 报No changes detected。初始分类数据我一般用dumpdata生成 JSON 文件放进fixtures目录这样每次重建库都能一键恢复分类、管理员账号等基础数据不用手动重建。5. 避坑指南Django 二手交易项目常见的五个翻车现场5.1 图片上传后前端无法显示页面一片空白现象商品发布成功后台能看到图片文件但前端img标签的地址是 404。原因media目录的 URL 没有映射到 Django 路由。Django 默认只处理static目录media是用户上传文件需要手动加一条路由且DEBUG False时 Django 根本不处理媒体文件服务。解决在urls.py中添加from django.conf import settings from django.conf.urls.static import static urlpatterns [...] # 你的其他 URL if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)同时检查settings.py里有没有MEDIA_URL /media/和MEDIA_ROOT BASE_DIR / media这两行。部署环境的图片服务应交给 Nginx 之类的 Web 服务器处理不要依赖 Django。5.2 数据库迁移时报IndexError: string index out of range现象执行python manage.py migrate时某一步突然抛出这个异常表建了一半。原因常见于项目从 SQLite 切换 MySQL 后产生的迁移依赖混乱特别是历史迁移文件引用了旧数据库的字段类型或不存在的 app 标签。解决如果数据不重要最省事的办法是删掉所有 app 下的migrations目录中除__init__.py外的文件连同数据库一起重建python manage.py makemigrations --empty users python manage.py makemigrations python manage.py migrate --run-syncdb如果已有用户数据不想丢则要逐个检查迁移文件中的依赖项找到报错的哪一行做修正。说句实在话这种问题卡太久就直接重建库毕设项目的数据量没到值得花两天去修迁移历史的程度。5.3 注册时用户名冲突导致 500 错误现象注册一个已存在的用户名页面报 500而不是提示「用户名已注册」。原因UserCreationForm的is_valid()里会检查用户名唯一性并生成错误信息但你没有把错误信息渲染到模板或者视图写成了if request.method POST: user form.save()而不检查is_valid()。解决注册视图必须先调用form.is_valid()再取数据模板里用{{ form.errors }}输出错误。另外一个容易踩的是大小写问题——Django 默认用户名不区分大小写Admin和admin视为同一个前端提示信息要写清楚否则用户会困惑为什么换了大小写还是提示已占用。5.4 搜索关键字是空字符串时返回空列表现象搜索页默认进入时没有传keyword结果商品列表为空正常应该显示全部商品。原因get_queryset里写了if keyword:的判断但视图先执行了filter(statuson_sale)空字符串关键字不会进入过滤分支问题一般出在模板的表单里给搜索框设置了namekeyword以外的名字或者前端把空值也提交成了一个空格字符。解决在视图里统一做keyword self.request.GET.get(keyword, ).strip()先.strip()掉首尾空格再判断。同时在模板表单用methodget保证搜索参数出现在 URL 里这样用户刷新页面不会丢失搜索结果也能直接复制 URL 分享给别人。5.5 下架商品后商品详情页仍可通过直达链接访问现象卖家把商品下架了但持有旧链接的人仍能打开详情页并下单。原因详情页视图只按主键查了商品没有校验状态。解决商品详情视图的查询条件加上statuson_sale或者至少对非在售状态做区分处理# apps/goods/views.py from django.shortcuts import get_object_or_404 from .models import Goods def detail(request, pk): goods get_object_or_404(Goods, pkpk, statuson_sale) # 如果商品已下架直接返回 404 而不是渲染详情页 return render(request, goods/detail.html, {goods: goods})同理add to order的操作也要重复检查商品状态防止「详情页看不到但通过直接构造 POST 请求下单」的漏洞。政务类系统的血泪经验告诉我们前端隐藏入口不等于后端安全。6. 最后一公里让项目从「能跑」变成「拿得出手」如果只追求功能跑通项目到这里已经完成了。但想在答辩或实际使用时不翻车我还会做三件事。第一给搜索接口加缓存——校园二手平台的访问曲线很集中午休和晚间是高峰用 Django 自带的cache_page装饰器缓存搜索结果页 60 秒能显著降低 MySQL 的压力from django.views.decorators.cache import cache_page urlpatterns [ path(goods/, cache_page(60)(GoodsListView.as_view()), namegoods-list), ]第二给关键接口补充单元测试至少覆盖注册、发布、下单三条主流程。Django 的TestCase跑起来很快十几秒就能验证改造没有破坏核心逻辑这比在浏览器里手动点半天要高效得多。第三部署前把DEBUG关掉、把SECRET_KEY改成环境变量、用whitenoise处理静态文件——这三件事不做完项目一放到公网就会在安全性和资源加载上出问题。我记得有一次给模拟项目X 做演示环境部署就是因为SECRET_KEY写死在代码仓库里被扫描工具拉出了风险告警虽然只是校园内网项目但也够让人冒冷汗的。所以你现在花半小时把这些配置从代码里拆出去省的是将来的补救时间。做这类项目最重要的不是炫技而是把状态流转、查询性能、数据安全这些基础点做扎实。希望帮到你。本文还有配套的精品资源点击获取