做校园二手交易系统这个项目,说实话最开始我是被一个场景触发的。学校二手群里全是消息刷屏,"出高数教材,9成新,5栋自提""电动车出一辆,校内交易",信息很快被淹没,想找特定的东西得翻几百条记录。后来接了一个课程设计的需求,要做一个校园二手物品交易系统,我就顺势用 vue3 + Django 这套前后端分离的方案把它落地了。
这个系统能做什么?一句话总结:学生可以注册登录、发布闲置物品、浏览和搜索商品、收藏、下单,买卖双方在站内约定校内交易,管理员在后台审核商品、管理用户和分类。它本质上不是个电商系统,而是一个"信息撮合 + 轻量订单管理"平台,所以做起来比淘宝简单得多,但比一个单纯的信息发布站又多了一层交易闭环。
如果你正准备做类似的毕设、课设,或者想在一个真实项目里把 vue3 和 Django 前后端串联起来,这篇文章值得你完整看一遍。我会把我踩过的坑、设计取舍和部署细节都写出来,不是把官方文档复述一遍,而是讲实际怎么落地。
1. 需求拆解:校园二手交易系统做给谁用,和普通电商差在哪
1.1 校园交易场景的三个特殊点
第一,线下交付。校园二手交易基本不可能走快递,绝大多数是校内自提,所以系统里不需要购物车之外的支付、物流能力。商品详情里应该标注交易地点、可交易时间,订单状态流转也跟快递链路完全不一样。这个决定了订单模块比商用电商简单很多,但也别简单到只是"下单-确认"两步,至少要有待付款、待完成、已完成、已取消这套状态,否则买卖双方容易扯皮。
第二,强身份信任。交易限定在校内,用户注册时最好绑定学号、学院、宿舍楼等信息,可以降低欺诈风险。做数据库设计时我预留了 UserProfile 扩展字段,没有直接改 Django 自带的 User 表,这样后续接学校统一认证也方便,不会把自己的用户体系焊死。
第三,学期周期明显。开学、期末是活跃高峰,教材、小电器、生活用品是主要品类,分类设计要贴近学生需求。比如教材这个分类下还可以细分公共课、专业课、考研资料,这个细节很影响用户检索效率。
1.2 功能清单:前台C端 + 后台管理端
正式动手前我画了一张功能清单,把前台和后台分开列,每项都标清楚是"必须做"还是"可以砍掉",这个动作帮我省了大量无用功。
前台核心功能:
| 功能模块 | 具体功能 | 优先级 |
|---|---|---|
| 用户 | 注册、登录、JWT登出、个人资料编辑 | 必须 |
| 商品 | 发布、编辑、下架、列表展示、搜索、分类筛选 | 必须 |
| 商品详情 | 多图展示、卖家信息、收藏、留言 | 必须 |
| 订单 | 下单、订单状态流转、买卖双方订单列表 | 必须 |
| 个人中心 | 我的发布、我的订单、我的收藏 | 必须 |
| 社区感 | 商品留言、浏览计数 | 可选 |
后台核心功能直接用 Django admin 做基础版,商品审核、用户管理、分类管理、订单查看都够了。如果你还想做得更像样,可以再用 Vue3 做一套简单的管理界面,但第一版没有必要,admin 能让你把精力全部放在前台。
1.3 技术选型坐实:为什么是 vue3 + Django
我之前也纠结过要不要用 Flask 或者 Node 写后端,后来综合了几个原因定了这套组合:
- Django 的 ORM 和 admin 后台能极大缩短开发时间,商品、订单这类模型关系相对复杂,用 Django 写起来非常顺。
- DRF 把接口开发从手写每个视图函数变成配置序列化器和视图集,一套 ModelViewSet 就能把增删改查全出完,比 Flask 手动路由优雅很多。
- Vue3 用 Composition API 组织业务逻辑比 Options API 清晰,配合 Vite 启动和热更新都很快。
- 前后端分离之后,前端只调接口,后端只出接口,分工明确。虽然一个人做项目不存在团队协作,但调试和二次开发时,边界清晰能省很多事。
版本组合我当时用的是:Python 3.10 + Django 4.2 + Vue 3.4 + Vite 5 + Element Plus。Django 4.2 是长期支持版本,坑少,适合做项目。
2. Django后端:建app、模型设计和DRF接口的完整落地
2.1 开始前的环境准备与项目骨架
我习惯先把虚拟环境建好再动手,避免把系统 Python 环境搞乱。命令很简单:
python -m venv venv source venv/bin/activate # Windows 上执行 venv\Scripts\activate pip install django djangorestframework djangorestframework-simplejwt django-cors-headers Pillow装 Pillow 是因为商品图片用 ImageField 必须依赖它。django-cors-headers 是开发阶段给前端跨域用的,部署到同源之后可以去掉。
项目结构我拆成了两个 app,一个管用户相关,一个管商品和订单:
backend/ manage.py config/ # 配置文件、根路由 settings.py urls.py wsgi.py apps/ users/ # 用户扩展、注册登录 trade/ # 分类、商品、订单、收藏、留言 media/ # 上传的图片 static/ # 静态文件收集目录创建 app 的命令是python manage.py startapp users和python manage.py startapp trade,然后把两个 app 注册进 INSTALLED_APPS。注意 apps 目录如果和 manage.py 不在同一层,需要在 trade/apps.py 里给每个 app 加label或者在配置里写明路径,否则 Django 找不到模块。
2.2 数据模型设计:五个核心模型各管什么事
用户扩展模型我放在 users app 里:
from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') student_id = models.CharField(max_length=20, blank=True) college = models.CharField(max_length=50, blank=True) building = models.CharField(max_length=50, blank=True) phone = models.CharField(max_length=20, blank=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)为什么不用 Django 自带的 User 加字段?因为直接继承 AbstractUser 会导致以后接第三方认证时麻烦,而且这个系统里 User 本身只负责账号、密码、邮箱,学生信息这类非通用属性放 profile 里更干净。
trade app 里的核心模型有这几个:
class Category(models.Model): name = models.CharField(max_length=50) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE) class Product(models.Model): STATUS = ( ('on_sale', '在售'), ('sold', '已售出'), ('off_sale', '已下架'), ) title = models.CharField(max_length=100) description = models.TextField() price = models.DecimalField(max_digits=8, decimal_places=2) original_price = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True) images = models.JSONField(default=list) category = models.ForeignKey(Category, null=True, on_delete=models.SET_NULL) seller = models.ForeignKey(User, on_delete=models.CASCADE, related_name='products') status = models.CharField(max_length=20, choices=STATUS, default='on_sale') views = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True)几个设计要点:
images用 JSONField 存图片 URL 列表,比单独建一张图片表简单,查询商品时少一次关联,适合图片数量不多(我限制最多 9 张)的场景。seller的 related_name 设成products,这样在用户对象上直接user.products.all()就能拿到我发布的商品,不用再写过滤查询。- 删除商品时如果已经产生了订单,不能直接把数据物理删掉,所以我后面在视图里做了处理,商品状态改下架,而不是真正执行 DELETE。
Category用自关联实现二级分类,第一级是"数码电子""教材资料",第二级是"手机""平板""考研英语"这种,列表页筛选按一级分类聚合。
2.3 DRF序列化与视图集:怎么用最少的代码把CRUD做完整
序列化器我主要用了 ModelSerializer,关键点是把卖家信息嵌套进商品详情:
from rest_framework import serializers from .models import Product class ProductSerializer(serializers.ModelSerializer): seller_name = serializers.CharField(source='seller.username', read_only=True) category_name = serializers.CharField(source='category.name', read_only=True) class Meta: model = Product fields = '__all__' read_only_fields = ('seller', 'views', 'status')注意read_only_fields里写了 seller,这样 create 时不能由前端随意指定卖家,而是在视图里取当前登录用户。
视图集直接继承 ModelViewSet:
from rest_framework import viewsets, permissions from rest_framework.decorators import action from rest_framework.response import Response class ProductViewSet(viewsets.ModelViewSet): queryset = Product.objects.filter(status='on_sale') serializer_class = ProductSerializer def perform_create(self, serializer): serializer.save(seller=self.request.user) @action(detail=False, methods=['get']) def mine(self, request): products = request.user.products.all() serializer = self.get_serializer(products, many=True) return Response(serializer.data)/api/products/mine/就是我的发布列表。类似的favorites、my_orders都可以用自定义 action 实现。这样一套下来,商品模块的增删改查、条件过滤接口全部齐了,代码量很小,但功能很完整。
2.4 认证与权限:接入JWT后还要自己做哪些判断
认证方案我选了 djangorestframework-simplejwt,配置很简单:
REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticatedOrReadOnly', ), }schema还没有细说,但这里要提一点:全局权限设置成IsAuthenticatedOrReadOnly的意思是,读操作公开,写操作必须登录。这个正好符合二手交易系统的场景,游客能看到商品列表和详情,但发布、下单、收藏都要登录。
还要写个自定义权限类,防止用户改别人的商品:
from rest_framework.permissions import BasePermission class IsSellerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in ('GET', 'HEAD', 'OPTIONS'): return True return obj.seller == request.user然后放在 ProductViewSet 的permission_classes里。这行判断很重要,不然任何登录用户都能把别人的商品改成已售出。
2.5 查询优化与删除对象的那些坑
商品列表页返回的数据里有卖家名字、分类名,如果用框架默认的懒加载,每查一个商品都会多打几条 SQL,列表一长页面就慢。我在视图集里加了这两行:
queryset = Product.objects.select_related('seller', 'category').filter(status='on_sale')select_related会把外键表 join 出来一次性查完,几十条数据的性能差别肉眼可见。
关于"删除对象",这是很多初学者容易踩坑的地方。DRF 的 ModelViewSet 默认 destroy 方法会直接物理删除数据库记录,但对二手交易系统来说,商品被删了不影响历史订单,可一旦有买家已经下单,删了商品订单详情页就没法展示商品信息了。所以我在视图集里重写了 destroy:
def perform_destroy(self, instance): instance.status = 'off_sale' instance.save()这样接口还是 DELETE 语义,但实际执行的是下架,数据完整性和展示完整性都保住了。这个思路在订单、用户这类核心数据上也通用。
3. Vue3前端:Vite初始化、路由鉴权、状态管理与axios封装
3.1 从create vite到目录规划
前端初始化我用的是官方推荐的 Vite:
npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router@4 pinia axios element-plus项目目录我按习惯分了几个模块,避免所有组件堆在一个 views 里面:
src/ api/ # 接口请求封装,按模块拆分 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 store/ # Pinia 状态 views/ # 页面组件这里有个实操建议:不要按照"页面"拆分 api 文件,而是按照"资源类型"拆。比如api/product.js统一管商品相关的所有接口,api/order.js管订单,api/user.js管登录注册。页面里只调用函数,不直接拼 axios,这样后端接口地址变了只改一个文件。
3.2 路由配置:静态路由、鉴权守卫和动态路由的取舍
我的路由用的是常规静态配置,页面不多,没必要上动态权限路由。核心路由和守卫大概是这样:
const routes = [ { path: '/', component: HomeView }, { path: '/product/:id', component: ProductDetailView }, { path: '/login', component: LoginView }, { path: '/publish', component: PublishView, meta: { requiresAuth: true } }, { path: '/orders', component: OrdersView, meta: { requiresAuth: true } }, { path: '/profile', component: ProfileView, meta: { requiresAuth: true } }, ] router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })热搜词里有"vue3 vite 动态路由",确实有朋友会想在权限系统里做动态路由。但我的经验是:动态路由适合后台管理系统这种"不同角色显示不同菜单"的场景,校园二手交易系统的角色就两种,登录和未登录,用 meta 标记加守卫足够了。动态路由还会带来刷新页面后菜单丢失的问题,处理不好整个界面白屏,没必要为了炫技加复杂度。
3.3 Pinia与axios:用户状态和接口请求的标准化处理
用户状态我用 Pinia 管理,主要保存用户信息和登录状态:
import { defineStore } from 'pinia' import { login, getProfile } from '@/api/user' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: {}, }), actions: { async login(payload) { const data = await login(payload) this.token = data.access localStorage.setItem('token', data.access) this.userInfo = await getProfile() }, logout() { this.token = '' this.userInfo = {} localStorage.removeItem('token') }, }, })axios 封装是所有接口请求的地基,重点看拦截器:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } )拦截器里处理 401 自动跳登录页,这个逻辑在 JWT token 过期时特别重要,不然用户在详情页停留久了点收藏,接口报错后什么反馈都没有。
3.4 核心页面与组件拆分逻辑
页面拆分我的原则是:一个页面只管数据获取和页面编排,卡片、弹窗、列表项这类独立 UI 单元拆成组件。商品卡片是复用度最高的,首页列表、搜索结果、我的发布都用它。
商品卡片组件接收一个 product 对象,内部自己处理价格格式化和图片展示:
<script setup> import { computed } from 'vue' const props = defineProps({ product: { type: Object, required: true } }) const priceText = computed(() => `¥${Number(props.product.price).toFixed(2)}`) </script> <template> <div class="product-card" @click="$router.push(`/product/${product.id}`)"> <img :src="product.images?.[0]" :alt="product.title" /> <div class="title">{{ product.title }}</div> <div class="price">{{ priceText }}</div> </div> </template>价格格式化这种小逻辑用computed处理,模板里就不会出现乱七八糟的运算。热搜词里有"vue3 computed",它是一个很基础但很常用的 API,处理这种派生数据是最标准的用法。
发布商品页有两个注意点:富文本描述我用的 vue-quill-editor 的 Vue3 兼容版本,如果你只是想要功能简单,用 textarea 加多行文本也行,富文本让描述排版好看但对信息检索帮助不大。分类选择器直接级联绑定一级分类和二级分类,提交时把数据分别传给接口。
4. 图片上传链路:el-upload到Django存储的完整走通
4.1 Django端:MEDIA配置与Pillow依赖
图片上传这块我先说后端,因为大部分人卡在"前端传了但后端收不到"。
Django 处理上传文件需要在 settings 里配置:
MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'然后在根路由加一个静态服务,开发阶段让上传的图片能直接访问到:
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ ... ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)Pillow 必须安装,不然 ImageField 会报错。如果你打算用 JSONField 存多图,也可以用 FileField 先接收图片再手动拼接 URL,但 ImageField 写起来更省事,而且还能做尺寸校验。
4.2 序列化器返回图片地址的坑
坑点在哪里?序列化器直接返回图片字段时,Django 默认给的是相对路径/media/goods/xxx.jpg,而你的前端部署在同源下这个问题不大,但如果在开发环境前后端端口不同,这个相对路径就会指向前端端口,图片直接 404。
解决办法是序列化器里自定义输出:
class ProductSerializer(serializers.ModelSerializer): image_list = serializers.SerializerMethodField() class Meta: model = Product fields = ('id', 'title', 'price', 'image_list', '...') def get_image_list(self, obj): request = self.context.get('request') if not request: return obj.images return [request.build_absolute_uri(url) for url in obj.images]用request.build_absolute_uri把相对地址拼成完整地址,这样无论前端在本地还是服务器都能正确访问图片。
4.3 前端上传组件:token携带、多图与回显
前端我用 el-upload 实现,有两个关键配置:
<el-upload v-model:file-list="fileList" action="/api/products/upload/" name="image" :headers="uploadHeaders" :limit="9" list-type="picture-card" :on-success="handleSuccess" >第一个关键配置是 headers 里带 token,因为上传接口要求登录:
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }第二个是 on-success 回调里把后端返回的图片地址收集到表单的 images 数组:
const handleSuccess = (response) => { form.images.push(response.url) }提交发布商品时,images 数组直接作为 JSON 传给后端。等回到商品编辑页时,回显只需要把 images 数组转成 el-upload 需要的 fileList 格式,每项有 url 字段就行。
还有一个坑是跨域。开发阶段前端口是 5173,后端口是 8000,图片上传请求会跨域。django-cors-headers 要在中间件里加corsheaders.middleware.CorsMiddleware,而且CORS_ALLOW_ALL_ORIGINS = True只建议开发阶段用,上线前必须关掉。
5. 部署到宝塔:Vue打包、uwsgi与nginx的联合配置
5.1 后端部署:虚拟环境、依赖和uwsgi
部署我用的是宝塔面板,整个过程和命令行操作没有本质区别,主要是管理界面顺手。先在前端项目里导出依赖:
pip freeze > requirements.txt把项目和前端压缩包传到服务器,在宝塔里找到 Python 项目管理器,新建虚拟环境。如果你不是用宝塔,就自己在项目目录执行:
python -m venv venv source venv/bin/activate pip install -r requirements.txt关于"django虚拟环境怎么删除"这个问题,我从热搜词里看到不少人在问。其实虚拟环境本质就是一个目录,删除时直接删掉 venv 文件夹即可。但注意:删之前确保当前 shell 已经退出虚拟环境,也就是命令行里没有(venv)前缀,直接deactivate即可。
uwsgi 配置我单独建了一个uwsgi.ini:
[uwsgi] socket = 127.0.0.1:8000 chdir = /www/wwwroot/secondhand/backend module = config.wsgi:application master = true processes = 4 threads = 2 vacuum = true daemonize = /var/log/uwsgi/secondhand.log注意 socket 和 nginx 的 proxy_pass 地址要对应上,然后启动:
uwsgi --ini uwsgi.ini5.2 前端构建与nginx站点配置
前端在本地构建或者服务器构建都行:
npm run build生成 dist 目录后,在宝塔的 Nginx 站点里配置三个关键 location:
server { listen 80; server_name your-domain.com; root /www/wwwroot/secondhand/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /media/ { alias /www/wwwroot/secondhand/backend/media/; } location / { try_files $uri $uri/ /index.html; } }第一个 location /api/ 把前端请求转发给 uwsgi,第二个 location /media/ 暴露上传的图片,第三个 location / 是 SPA 应用的核心,配置了try_files之后,刷新页面就不会 404 了。
这是部署里最容易忘的一步:Vue Router 使用 history 模式,前端刷新/product/1时 nginx 会去找真实的文件路径,找不到直接 404。加了try_files $uri $uri/ /index.html;后,所有未知路径都回退到 index.html,由前端路由接管。
5.3 部署后常见的三个故障与排查思路
部署完成不等于运行正常,我线上踩过的坑基本集中在三个地方。
第一是静态文件 404。Django admin 页面找不到 css,需要先在 settings 里配置 STATIC_ROOT,然后执行python manage.py collectstatic,再把 STATIC_URL 对应的路径配到 nginx。
第二是图片上传路径问题。媒体文件有时写到backend/media,但 nginx 的 alias 指向另一个目录,最简单的办法是在 Django 里打印MEDIA_ROOT确认绝对路径,再和 nginx alias 路径对比。
第三是端口冲突。uwsgi 起不来或者 nginx 502,先看日志,nginx error log 和 uwsgi log 都会给出原因,最常见的其实是 Django 的ALLOWED_HOSTS没有加域名或 IP,Django 会报 400。
宝塔部署的另一个建议:修改任何 Python 代码后需要重启 uwsgi 才能生效,不要只刷新页面。我在uwsgi.ini里加了touch-reload = /www/wwwroot/secondhand/backend/reload.txt,需要重启时执行touch reload.txt就行,不用杀进程。
最后分享一个扩展想法。这套系统把前后端接口设计得很干净,商品、订单、用户模块都是标准 RESTful 接口,后续如果想出微信小程序,前端完全重写,后端基本不用动。我在做的时候也刻意把图片上传、JWT认证这些都做成通用服务,以后接其他项目直接复用。如果你是想拿这个项目练手,建议不要一开始就追求花哨功能,先把"发布商品→浏览→下单→订单管理"这条链路跑通,再逐步加收藏、留言、销量排序这些锦上添花的东西。