Django+Vue校园二手交易系统全栈实战:从设计到部署
2026/9/15 23:19:34 网站建设 项目流程

说说这个校园二手交易项目。Django + Vue这套组合,在前后端分离的项目里算是非常经典的技术栈搭配,而校园二手交易系统又是一个业务边界清晰、用户场景明确、能覆盖“注册登录、商品发布、下单购买、订单管理”完整链路的练手项目。它适合正在学完Python基础想实战Django的人,也适合准备毕业设计的学生,以及想彻底搞明白前后端分离项目如何联调、如何部署的开发者。这篇文章我会把整个系统的功能设计、后端实现、前端实现、联调部署踩坑过程完整过一遍,把我实际操作中遇到的经验和教训也一并写出来,照着做基本能复刻出一套可运行的项目。

1. 项目整体设计与技术选型思路

1.1 为什么是校园二手场景,为什么要用Django + Vue

校园里的二手交易需求一直存在——毕业生离校要处理书本和宿舍用品,新生入学要买教材和自行车,平时还有数码产品、音乐器材、小家电之类的闲置。但校园里的信息传播一直很分散,要么靠QQ群刷屏,要么靠朋友圈转发,要么在宿舍楼下贴纸条。这套系统的核心价值,就是把分散的闲置信息收拢到一个统一的平台上,让学生能快速发布、浏览、搜索和购买自己需要的东西。

技术选型上,我最终定了Django + Vue前后端分离的方案,这里面有几个实际考量。后端选Django,是因为它的ORM非常省事,学生用户、商品、订单、留言这些表结构在Django里用一个model类就能直接映射到数据库,自动生成表,不用手写SQL。Django自带的admin后台在调试阶段也特别有用,商品数据、用户数据能用图形界面直接看、直接改,这对前期联调帮助很大。另外一个关键点是DRF(Django REST Framework),它把序列化、路由、视图封装得比较完整,写API接口的效率比Flask手写一堆路由要高不少。

前端选Vue,看重的是它的组件化思路和响应式数据绑定。二手交易页面里有很多重复的UI单元,比如商品卡片、订单列表、留言列表,用Vue组件可以只写一次,到处复用。而且Vue的生态成熟,配合Vue Router做页面跳转、Pinia或Vuex做状态管理,一个完整的前端SPA(单页应用)很快就能搭起来。

1.2 功能模块划分与核心流程

这套二手交易系统从业务上切割成四个核心模块:

  • 用户模块:注册、登录、个人信息维护、我发布的商品、我购买的商品
  • 商品模块:商品发布、商品列表、商品详情、商品搜索、商品下架
  • 订单模块:创建订单(购买)、订单状态流转(待付款/已付款/已完成/已取消)
  • 互动模块:商品收藏、商品留言

从用户视角走一遍核心流程:学生A注册登录后,在发布页填写商品标题、描述、价格、图片,点击发布,商品就进入数据库并出现在首页列表。学生B浏览首页,点击进入商品详情,看到留言和卖家信息,如果想买就点击“立即购买”,系统生成一条订单,订单状态是待付款。后续卖家可以在后台订单列表里看到这笔订单,双方线下交易后,订单状态改已完成。

这个流程看起来简单,但每个环节都有不少技术细节值得展开说。下面我会按后端、前端、部署三个阶段逐一拆解。

2. 后端Django核心模块实现

2.1 环境准备与项目初始化

后端开发我推荐用虚拟环境隔离依赖。命令行操作大致是这样:

# 创建项目目录并进入 mkdir campus_secondhand cd campus_secondhand # 创建Python虚拟环境 python3 -m venv venv # 激活虚拟环境(Windows/Linux/macOS命令有差异) # Windows: venv\Scripts\activate # Linux/macOS: source venv/bin/activate # 安装Django和DRF及相关依赖 pip install django pip install djangorestframework pip install django-cors-headers pip install pillow pip install mysqlclient

这里我重点说两个坑。第一,mysqlclient在Windows上经常装不上,因为它需要MySQL的C客户端库。如果你不想折腾这个依赖,开发阶段直接用SQLite完全够用,Django对SQLite的支持是开箱即用的,等要上生产再切MySQL。第二,Pillow这个库必须装,Django处理ImageField上传的图片时依赖它,不装的话一上传图片就报错。

项目初始化命令:

django-admin startproject config . python manage.py startapp goods python manage.py startapp users python manage.py startapp orders python manage.py startapp chat

我个人习惯把项目配置文件目录叫config,然后根据业务模块拆不同的app。用.参数让manage.py生成在当前目录,这样后续路径处理会方便一些。

然后在config/settings.py里注册app并做基础配置:

INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt', 'corsheaders', 'users', 'goods', 'orders', 'chat', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 跨域中间件尽量放前面 'django.middleware.security.SecurityMiddleware', ... ] # 数据库配置,先用SQLite DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } } # 媒体文件配置 MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' # DRF配置 REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], } # 跨域配置 CORS_ALLOW_ALL_ORIGINS = True # 开发阶段先全放行

2.2 数据模型设计:用户、商品、订单、留言

数据模型是整个系统的地基。我在设计表结构时重点关注字段的实用性和关联关系。

用户模型,我用的方案是继承Django的AbstractUser扩展字段:

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) phone = models.CharField(max_length=11, null=True, blank=True) student_id = models.CharField(max_length=20, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.username

然后在settings.py里指定:

AUTH_USER_MODEL = 'users.User'

这里有个非常重要的点:自定义用户模型最好在一开始就做。如果你已经跑过migrate生成了默认的用户表,再改成自定义用户模型会非常痛苦,因为数据库里的django内置auth相关的表已经建好了,切换起来很容易报外键关联错误。所以新建项目第一步就是把自定义用户模型配好。

商品模型:

from django.conf import settings from django.db import models class Goods(models.Model): STATUS_CHOICES = [ ('on_sale', '在售'), ('sold', '已售'), ('off_shelf', '已下架'), ] CONDITION_CHOICES = [ ('new', '全新'), ('like_new', '几乎全新'), ('good', '轻微使用'), ('acceptable', '有明显磨损'), ] title = models.CharField(max_length=100, verbose_name='商品标题') description = models.TextField(verbose_name='商品描述') price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='价格') original_price = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True, verbose_name='原价') condition = models.CharField(max_length=20, choices=CONDITION_CHOICES, default='good', verbose_name='成色') image = models.ImageField(upload_to='goods/', null=True, blank=True, verbose_name='商品图片') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='on_sale', verbose_name='状态') seller = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='published_goods', verbose_name='卖家') views_count = models.IntegerField(default=0, verbose_name='浏览量') created_at = models.DateTimeField(auto_now_add=True, verbose_name='发布时间') class Meta: ordering = ['-created_at'] def __str__(self): return self.title

订单模型:

class Order(models.Model): ORDER_STATUS = [ ('pending', '待付款'), ('paid', '已付款'), ('completed', '已完成'), ('cancelled', '已取消'), ] order_no = models.CharField(max_length=32, unique=True, verbose_name='订单号') goods = models.ForeignKey(Goods, on_delete=models.CASCADE, related_name='orders', verbose_name='商品') buyer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='buy_orders', verbose_name='买家') seller = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sell_orders', verbose_name='卖家') amount = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='订单金额') status = models.CharField(max_length=20, choices=ORDER_STATUS, default='pending', verbose_name='订单状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='下单时间')

订单号我一般用时间戳加随机数生成,格式类似202502141030581234,保证唯一性。

留言模型:

class Message(models.Model): goods = models.ForeignKey(Goods, on_delete=models.CASCADE, related_name='messages', verbose_name='商品') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name='留言用户') content = models.TextField(verbose_name='留言内容') created_at = models.DateTimeField(auto_now_add=True, verbose_name='留言时间') class Meta: ordering = ['created_at']

表结构设计好之后执行:

python manage.py makemigrations python manage.py migrate

2.3 用DRF写API接口

Django REST Framework写接口的思路是:序列化器(Serializer)负责数据格式转换和校验,视图集(ViewSet)处理HTTP请求,路由器(Router)自动生成URL。

以商品序列化器为例:

from rest_framework import serializers from .models import Goods class GoodsSerializer(serializers.ModelSerializer): seller_name = serializers.CharField(source='seller.username', read_only=True) seller_id = serializers.IntegerField(source='seller.id', read_only=True) class Meta: model = Goods fields = ['id', 'title', 'description', 'price', 'original_price', 'condition', 'image', 'status', 'seller_name', 'seller_id', 'views_count', 'created_at'] read_only_fields = ['id', 'status', 'views_count', 'created_at']

商品视图集:

from rest_framework import viewsets, permissions, status from rest_framework.decorators import action from rest_framework.response import Response from .models import Goods from .serializers import GoodsSerializer class GoodsViewSet(viewsets.ModelViewSet): queryset = Goods.objects.all() serializer_class = GoodsSerializer def get_permissions(self): if self.action in ['create', 'update', 'partial_update', 'destroy']: return [permissions.IsAuthenticated()] return [permissions.AllowAny()] def perform_create(self, serializer): serializer.save(seller=self.request.user) def list(self, request, *args, **kwargs): queryset = self.get_queryset() # 按关键词搜索 keyword = request.query_params.get('keyword', '') if keyword: queryset = queryset.filter(title__icontains=keyword) # 按价格排序 sort = request.query_params.get('sort', 'newest') if sort == 'price_asc': queryset = queryset.order_by('price') elif sort == 'price_desc': queryset = queryset.order_by('-price') else: queryset = queryset.order_by('-created_at') if not request.user.is_authenticated: queryset = queryset.filter(status='on_sale') serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def favorite(self, request, pk=None): goods = self.get_object() # 收藏逻辑 fav, created = Favorite.objects.get_or_create(user=request.user, goods=goods) if not created: fav.delete() return Response({'favorited': False}) return Response({'favorited': True})

这里我遇到了一个比较典型的业务场景问题:未登录用户能不能浏览商品?我当时的设计是允许游客浏览在售商品列表和详情,但发布、下单、留言、收藏这些操作必须要登录。所以get_permissions里对不同action做了不同权限控制,然后在list方法里根据登录状态对查询集做了过滤。

用户认证部分,我用的是SimpleJWT。登录接口配置:

# users/views.py from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): @classmethod def get_token(cls, user): token = super().get_token(user) token['username'] = user.username token['user_id'] = user.id return token class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer

配置URL路由:

from django.urls import path, include from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenRefreshView router = DefaultRouter() router.register(r'goods', GoodsViewSet, basename='goods') urlpatterns = [ path('api/', include(router.urls)), path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/auth/register/', RegisterView.as_view(), name='register'), ]

登录后前端拿到access_token和refresh_token。access_token的有效期我一般设置成30分钟,refresh_token设置成7天,这样用户在一次会话里操作比较顺畅,也不会因为token长期有效带来太多安全隐患。

2.4 图片上传与admin后界面

商品图片上传是二手交易系统的刚需,Django处理起来很简单。ImageField字段配好MEDIA_URLMEDIA_ROOT之后,开发阶段只需要在config/urls.py里加一行:

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)

这样开发服务器就会自动服务media目录下的文件。但要注意,这只适合开发环境,生产环境必须由Nginx来处理图片的访问,否则性能会很差。这个后面部署部分会讲到。

Django自带admin后台对我们开发调试帮助太大了。默认情况下admin界面比较简陋,我做了几个改进:让商品、订单、用户这些模型注册进admin,并通过list_display控制字段展示,用search_fields开搜索,用list_filter开按状态筛选。

# goods/admin.py from django.contrib import admin from .models import Goods @admin.register(Goods) class GoodsAdmin(admin.ModelAdmin): list_display = ['id', 'title', 'price', 'status', 'seller', 'created_at'] search_fields = ['title', 'seller__username'] list_filter = ['status', 'condition'] list_editable = ['status']

admin后台表面上是管理工具,但在我开发联调阶段,它比任何数据库管理工具都直观。我在模拟前端请求时,发现某条数据有问题,直接进admin改掉重新测,效率高得多。

3. 前端Vue核心功能实现

3.1 Vue项目创建与依赖安装

前端开发环境需要Node.js。我建议用LTS版本,版本太低会出现依赖解析失败,太高也偶尔会遇到兼容性警告。

创建Vue项目我推荐用官方脚手架Vite:

# 安装并创建项目 npm create vue@latest # 或传统方式 npm create vite@latest campus_secondhand_web -- --template vue

创建过程会询问是否安装Vue Router、Pinia、ESLint等,这些可以根据需要选。然后进入项目目录安装依赖:

cd campus_secondhand_web npm install # 安装额外依赖 npm install axios npm install element-plus npm install @element-plus/icons-vue npm install sass -D

Element Plus在校园管理后台或者商品管理页面很好用,表格、表单、分页、消息提示这些组件开箱即用,能省下不少样式的时间。但要注意,如果项目对UI定制要求很高,全部引入Element Plus会有点重,可以考虑按需引入。

3.2 路由设计与页面结构

前端页面的结构,我按业务场景划分:

  • /首页:商品瀑布流列表 + 搜索框 + 分类筛选
  • /goods/:id商品详情
  • /goods/publish发布商品(需要登录)
  • /orders我的订单(需要登录)
  • /favorites我的收藏(需要登录)
  • /profile个人中心(需要登录)
  • /login登录
  • /register注册

Vue Router配置:

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') }, { path: '/goods/:id', name: 'goods-detail', component: () => import('@/views/GoodsDetailView.vue') }, { path: '/goods/publish', name: 'goods-publish', component: () => import('@/views/GoodsPublishView.vue'), meta: { requiresAuth: true } }, { path: '/orders', name: 'orders', component: () => import('@/views/OrderListView.vue'), meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue') }, { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue') }, { path: '/profile', name: 'profile', component: () => import('@/views/ProfileView.vue'), meta: { requiresAuth: true } }, ], }) // 全局路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('access_token') if (to.meta.requiresAuth && !token) { next({ name: 'login', query: { redirect: to.fullPath } }) } else { next() } }) export default router

路由懒加载我这里全用了动态导入,这样首屏只加载首页需要的代码,商品详情、订单这些页面等用户真正访问时才加载,首屏性能会好一些。

3.3 Axios封装与接口对接

前端和后端通信,我用Axios做请求库。如果不做封装,每个组件里直接调axios.get('/api/goods/')也能跑,但一旦要统一处理token过期、错误提示、请求前缀,就会非常麻烦。所以我在src/utils/request.js里做了一层封装:

// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000, }) // 请求拦截器:自动附带token request.interceptors.request.use( (config) => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) // 响应拦截器:统一处理错误 request.interceptors.response.use( (response) => response.data, (error) => { if (error.response) { const status = error.response.status if (status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') router.push({ name: 'login' }) } else if (status === 403) { ElMessage.error('没有权限执行此操作') } else if (status >= 500) { ElMessage.error('服务器开小差了,请稍后重试') } else { const detail = error.response.data.detail || error.response.data.message || '请求失败' ElMessage.error(detail) } } else { ElMessage.error('网络异常,请检查连接') } return Promise.reject(error) } ) export default request

首页调用商品列表的接口,大概是这样的写法:

// src/views/HomeView.vue import { ref, onMounted } from 'vue' import request from '@/utils/request' const goodsList = ref([]) const loading = ref(false) const keyword = ref('') const fetchGoods = async () => { loading.value = true try { const data = await request.get('/goods/', { params: { keyword: keyword.value } }) goodsList.value = data } finally { loading.value = false } } onMounted(() => { fetchGoods() })

首页商品卡片我抽成了一个独立的GoodsCard.vue组件,接收一个商品对象作为props,展示图片、标题、价格、发布时间。因为首页列表、商品搜索结果、我的发布列表都要用同一套卡片样式,抽成组件后只需要维护一份代码。

3.4 关键页面实现:商品发布、商品详情、订单流程

商品发布页的难点在于图片上传。Element Plus上传组件本身支持action属性直接指定上传URL,但为了统一走我们封装的Axios实例、附带认证token,我采用手动上传的方式:

<template> <div class="publish-page"> <el-form :model="form" label-width="80px"> <el-form-item label="商品标题"> <el-input v-model="form.title" placeholder="请输入商品标题" /> </el-form-item> <el-form-item label="商品描述"> <el-input v-model="form.description" type="textarea" :rows="4" /> </el-form-item> <el-form-item label="价格"> <el-input-number v-model="form.price" :min="0" :precision="2" /> </el-form-item> <el-form-item label="成色"> <el-select v-model="form.condition"> <el-option label="全新" value="new" /> <el-option label="几乎全新" value="like_new" /> <el-option label="轻微使用" value="good" /> <el-option label="有明显磨损" value="acceptable" /> </el-select> </el-form-item> <el-form-item label="商品图片"> <el-upload :show-file-list="false" :before-upload="handleBeforeUpload" > <img v-if="form.image" :src="form.image" class="upload-preview" /> <el-icon v-else><Plus /></el-icon> </el-upload> </el-form-item> <el-button type="primary" @click="submitGoods">发布</el-button> </el-form> </div> </template> <script setup> import { reactive } from 'vue' import { ElMessage } from 'element-plus' import request from '@/utils/request' const form = reactive({ title: '', description: '', price: 0, condition: 'good', image: '', }) const handleBeforeUpload = async (file) => { const formData = new FormData() formData.append('file', file) const data = await request.post('/upload/image/', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) form.image = data.url return false // 阻止默认上传行为 } const submitGoods = async () => { if (!form.title || !form.description) { ElMessage.warning('请填写商品标题和描述') return } await request.post('/goods/', form) ElMessage.success('发布成功') // 跳转到首页或商品列表 } </script>

因为Content-Type要设成multipart/form-data,我单独写了一个图片上传接口,放在Django后端。上传接口返回图片的URL,这个URL直接存到表单的image字段,提交商品时随其他字段一起传给后端。这个小设计让我在发布商品时不必再单独处理图片与商品的绑定关系。

商品详情页的核心交互是:展示商品信息、查看留言、发送留言、收藏、立即购买。详情页的数据结构大概是:

const goods = ref(null) const messages = ref([]) const isFavorited = ref(false) const fetchDetail = async () => { const data = await request.get(`/goods/${route.params.id}/`) goods.value = data }

详情页的“立即购买”按钮,点击后调用创建订单接口,传商品ID。后端在创建订单时会校验商品是否在售状态,避免已经卖掉的商品被重复下单。

订单列表页则分两个Tab:我买到的、我卖出的。接口可以通过?role=buyer?role=seller参数区分,后端返回不同视角的订单数据。前端展示订单对应的商品缩略图、标题、金额、状态,以及状态操作按钮。

4. 前后端联调、跨域与常见问题排查

4.1 dev模式下如何用Vite代理解决跨域

前后端分离开发时,前端跑在localhost:5173,后端跑在localhost:8000,端口不同就意味着跨域。解决的方案有两种思路:后端开CORS,或前端用代理。

开发阶段我推荐前端开Vite代理,这样浏览器看到的请求是同源的,最干净。在vite.config.js里配置:

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true, }, }, }, })

这样我在前端代码里请求/api/goods/,Vite开发服务器会把请求转发到http://localhost:8000/api/goods/。浏览器端看到的是从localhost:5173/api/goods/返回的响应,不存在跨域问题。

生产环境部署后,这个代理就不生效了,需要依赖Nginx做反向代理把/api前缀的请求转发到Django。这个后面部署部分会详细说。

4.2 Django后端的CORS配置

虽然开发阶段用了Vite代理,但有时候直接拿Postman或者用其他域名调试时,还是需要后端开启CORS。django-cors-headers这个库配置很简单:

# 安装后 # pip install django-cors-headers # settings.py中 INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 必须放在CommonMiddleware之前 ... ] # 开发阶段可以直接全放行 CORS_ALLOW_ALL_ORIGINS = True # 生产环境应该限定具体域名 # CORS_ALLOWED_ORIGINS = [ # 'https://your-frontend-domain.com', # ]

这里有个容易踩的坑:CorsMiddleware的位置很重要,官方文档明确要求它尽量放在靠前的位置,最好是放在CommonMiddleware之前。如果放错位置,偶尔会出现请求跨域失败但后端日志又看不到任何报错的诡异情况,排查起来很浪费时间。

4.3 Nginx + Gunicorn + 宝塔部署Django

部署阶段的方案我想重点说宝塔面板。宝塔在国内环境下的普及度很高,图形化界面直接管理Nginx、MySQL、Python环境,确实能省不少事情。Python项目的部署思路是:

用Gunicorn作为WSGI服务器启动Django应用,Nginx作为反向代理,处理静态文件和图片的访问,以及把API请求转发给Gunicorn。

先在服务器上安装好Python环境和依赖:

# 进入项目目录,创建虚拟环境 cd /www/wwwroot/campus_secondhand_backend python3 -m venv venv source venv/bin/activate # 安装依赖 pip install -r requirements.txt # 收集静态文件 python manage.py collectstatic

用Gunicorn启动Django:

gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 4

这里用127.0.0.1:8000而不是0.0.0.0:8000,是安全考虑。因为Nginx和Gunicorn在同一个服务器上,Gunicorn没必要监听公网端口,只需要接受Nginx转发的请求即可。

Nginx配置:

server { listen 80; server_name your-domain.com; # 前端Vue打包后的静态文件 root /www/wwwroot/campus_secondhand_web/dist; index index.html; # 图片等媒体文件 location /media/ { alias /www/wwwroot/campus_secondhand_backend/media/; } # 静态文件 location /static/ { alias /www/wwwroot/campus_secondhand_backend/static/; } # API请求转发 location /api/ { proxy_pass http://127.0.0.1:8000; 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-Proto $scheme; } # Vue路由配置:history模式刷新后不404 location / { try_files $uri $uri/ /index.html; } }

前端打包:

npm run build

输出的dist目录上传到服务器,Nginx的root指向它即可。

4.4 常见问题与排查技巧实录

我把自己在开发和部署过程中遇到的高频问题整理成了一张速查表,供参考:

问题现象可能原因解决方案
mysqlclient安装失败Windows缺少MySQL C客户端库开发环境用SQLite,或用pymysql替代
Vue刷新404Nginx没有配置try_files增加try_files $uri $uri/ /index.html;
图片上传后访问404media路由或Nginx配置缺失开发环境加static(),生产配location /media/
前端请求401频繁access_token过期时间太短调长有效期,或增加token自动刷新逻辑
collectstatic后样式丢失没有配STATIC_ROOTSTATIC_ROOT = BASE_DIR / 'static',再执行collectstatic
页面能打开但接口502Gunicorn没启动或端口不对检查Gunicorn进程和Nginx代理端口
商品图片上传成功后不显示CORS或MEDIA_URL配置不对检查图片URL前缀是否匹配MEDIA_URL与Nginx alias
Vue打包后布局异常静态资源引用了绝对路径base配置改为'./',或Nginx配置正确base

这里挑几个典型问题详细说说。

第一个是Vue部署后刷新404。Vue Router用history模式时,刷新/goods/123这样的路由,Nginx会去磁盘找/goods/123这个文件,找不到就返回404。解决办法就是Nginx的try_files指令,让所有找不到文件的请求都回退到/index.html,由前端路由接管。

第二个是图片上传后访问404。开发阶段最常见的原因是MEDIA_ROOTMEDIA_URL配置不匹配,或者忘了加static()辅助函数。生产环境则要注意Django只在DEBUG模式才主动服务media文件,生产环境必须把media目录交给Nginx。我在部署时因为Nginx配置里少写了一个location /media/,图片全是404,排查了将近一个小时才搞清楚。

第三个是JWT过期导致用户操作突然失败。我在设计时把access_token设成30分钟,用户发布商品写到一半token过期,请求就401了。实际体验不太好。后续优化方案是前端在响应拦截器里检测到401时,自动用refresh_token刷新token然后重放请求。这个逻辑会稍微复杂一点,但体验会好很多。如果只是毕设或内部系统使用,也可以直接把access_token有效期调长到24小时,省事很多。

4.5 部署之外:上线前还要做哪些事

系统跑通之后,上线前还有一些细节不能忽视。第一,数据库要从SQLite切换到MySQL或PostgreSQL。SQLite在单用户开发调试时非常方便,但并发一上来就明显吃力。在宝塔面板上可以一键安装MySQL,然后在Django的settings里改数据库配置,重新执行migrate即可。注意SQLite里已有的数据需要导出迁移,或者直接清空重新录入,因为表结构转换比较麻烦。

第二,Django的DEBUG必须设为False,同时配置好ALLOWED_HOSTS。如果不改这两项,不仅会暴露详细的报错堆栈信息,而且访问时会直接报DisallowedHost错误。通常在settings里用环境变量注入:

import os DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', 'your-domain.com').split(',')

第三,安全方面至少要保证用户密码的哈希存储、HTTPS访问。Django默认密码存储用的是PBKDF2算法,这已经够了,但如果在公网上用HTTP明文传输,用户的密码和token都有被中间人截获的风险。有条件的话,Nginx里配置一个免费的SSL证书,把HTTP强制跳转到HTTPS。

第四,订单流程中的防重复下单逻辑。我在后端创建订单的接口里做了这样一个校验:如果商品状态不是on_sale,直接返回400;如果同一个买家已经对同一个商品创建过一个待付款的订单,也直接返回一个友好提示。这些逻辑不复杂,但在实际使用中能避免很多bug。

安全这块我再多说一句:前后端分离项目的身份认证依赖token,而token一旦被偷,等于账号被偷。我在前端没有使用localStorage存用户敏感信息,只存了access_token和refresh_token和用户基本资料。同时,Django REST Framework里面我统一设置了权限类,列表和详情允许匿名访问,但是创建、修改、删除操作一律需要认证。发布商品接口还要额外校验request.user.is_authenticated,防止未登录用户伪造请求。

5. 项目后续功能扩展建议

系统跑通之后,如果再想往上加东西,有几个方向性价比很高。

第一个是消息通知。当买家下单时,给卖家推送一条消息;当留言有新回复时,通知原留言人。Django这边可以建一个Notification模型,前端在导航栏里用一个红点展示未读数量,点进去查看通知列表。这个功能对交易体验的提升非常明显。

第二个是商品多图片支持。目前商品只有一个主图,但实际二手交易中,买家往往想看多角度照片。我在商品详情页预留了GoodsImage关联表的空间,如果要做,就创建一个GoodsImage模型,用外键关联Goods,前端在上传组件里支持多图preview。

第三个是数据统计。统计每个商品的浏览量、收藏量、成交转化率。Django的views_count字段已经有了浏览量累计逻辑,收藏字段也有了,再往后可以加一个简单的数据看板,用Chart.js或者ECharts展示“本周发布量”“成交额Top10商品”这类维度,不管是毕设答辩还是真实运营,都很加分。

第四个是聊天系统。二手交易中买卖双方沟通是刚需,但做站内实时聊天对技术要求一下上升不少——需要WebSocket、在线状态管理、离线消息推送。如果不想上WebSocket,也可以做一个留言式的“站内信”功能,逻辑跟留言板类似,用户给用户发消息,对方下次登录时看到。这个折中方案在毕设阶段完全够用。

我在做这个系统时的真实感受是:技术选型再花哨,不如把一个核心业务链路彻底跑通。Django和Vue之所以能成为很多项目的前后端标配,不是因为它们多新潮,而是因为它们的生态和范式足够成熟——Django帮你把数据库、管理员后台、认证这些后端基础事都安排得明明白白,Vue帮你把页面交互组织得顺畅自然。你在做这类项目时如果遇到卡壳,不要急着怀疑框架,先看看是不是某个配置没配对、某个依赖版本不兼容,这些才是前后端分离项目里最常见的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询