☰
基于Django与Vue3的二手图书交易系统开发实践与踩坑总结
2026/10/1 3:19:27 网站建设 项目流程

毕业设计选了“基于 Django Web 的二手图书交易系统”,前端用了 Vue3,折腾了一个多月总算是把整个系统从前端到后端打通了。写这篇东西主要是想记录一下我自己从需求分析到上线部署踩过的坑,顺便给准备做类似项目的同学一个可以直接抄的完整思路。这个项目说穿了就是做一个校园或社区场景下的二手书买卖平台,核心功能无非是图书发布、浏览检索、下单交易、个人中心这几块,但真正做起来你会发现,工作量全藏在那些不起眼的细节里:图书图片上传、登录状态保持、并发下单防超卖、前后端联调跨域……每一个都能让你卡上一阵子。

我用的技术栈很明确:后端 Django + Django REST Framework,前端 Vue3 + Vite + Element Plus,数据库 MySQL。之所以选这套组合,一是 Python 生态写后端业务逻辑确实快,Django 自带 Admin 后台和 ORM,做这类 CRUD 系统非常省事;二是 Vue3 现在前端社区里已经是绝对主流,Composition API 写起来比 Vue2 的 Options API 更灵活,而且 Vite 的启动速度比 Webpack 舒服太多。下面我会把这套系统的设计思路、核心实现、常见问题完整梳理一遍,尤其偏向后端接口设计和前后端联调的部分。如果你是刚学完 Django 和 Vue3 基础、想做真实项目的朋友,这篇文章应该能帮你少走不少弯路。

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

1.1 为什么是 Django + Vue3,而不是 Django 模板直接渲染

很多人会问,一个二手图书交易系统,直接用 Django 的模板系统 + 原生 JS 不也能做吗?为什么还要单独整一套 Vue3 前端?我的答案是:看你的项目定位和后续扩展需求。

Django 的模板渲染是服务端渲染,每个页面跳转都要刷新整个页面,交互体验比较“古典”。而二手图书交易系统里有很多高频的局部交互,比如搜索框联动、图书列表的实时筛选、购物车数量加减、下单时的表单校验,这些用 Vue 来做要顺滑得多。更重要的是,前后端分离之后,后端只需要提供 JSON 接口,以后如果要做小程序端或者移动端 App,可以直接复用同一套 API,不用再为每个端单独写一套逻辑。所以我最终选了 Django 只做 REST API,Vue3 负责页面展示和交互。

这种方案当然也有代价:开发时你需要同时跑两个服务,联调时要处理跨域,部署时要考虑前端构建产物怎么被后端伺服。但从一个系分课设或者个人项目练手的角度看,这些代价恰好是值得学习的点,而且实际操作下来并不复杂。

1.2 核心需求拆解:二手图书交易的业务闭环

动工之前我先把系统的业务闭环画了一遍。一个二手图书交易系统,用户角色至少分两种:买家、卖家(也可以是一个人同时兼任)。围绕这两种角色,核心功能链路是:

  • 用户注册登录,系统读取当前用户的身份和凭证。
  • 卖家可以发布图书,填写书名、作者、原价、售价、成色(九成新、七成新等)、图书描述、封面图片等。
  • 图书进入首页/检索页,买家可以按书名、分类、价格区间筛选。
  • 买家查看图书详情,加入购物车或立即购买。
  • 下单后生成订单,订单状态需要支持待付款、待发货、已发货、已完成、已取消、退款等状态流转。
  • 卖家可以在“我卖出的”中看到订单并发货,买家在“我买到的”中确认收货。
  • 交易完成后,双方可以互相评价,评价会显示在图书详情和用户主页上。

看起来功能不多,但每一条链路背后都涉及到数据库表设计、接口设计、页面逻辑。尤其是订单状态流转,必须把字段状态定义清楚,否则后期改起来非常痛苦。我在项目初期就定义了一个ORDER_STATUS的常量表,包含UNPAID、UNSHIPPED、SHIPPED、FINISHED、CANCELED等状态,并且规定状态只能按照特定路径跳转,比如已发货的状态不能直接回到待付款,避免出现业务上的逻辑漏洞。

1.3 技术栈细节与版本选型

具体到版本,我这套系统使用的技术栈如下:

模块选择版本说明
后端框架Django4.2.x长期支持版,生态成熟
REST APIDjango REST Framework3.14.x提供序列化、验证、视图集
数据库MySQL8.0日常开发也可以用 SQLite 代替
前端框架Vue33.4.xComposition API
构建工具Vite5.x开发时热更新极快
UI 组件库Element Plus2.x表单、表格、弹窗省力
状态管理Pinia2.x替代 Vuex,更简洁
HTTP 客户端Axios1.x统一封装请求

Python 版本我用了 3.10。这里提醒一句,Django 4.2 对 Python 3.10/3.11 支持都很不错,但不要为了追新直接用 Python 3.12 + Django 最新版,有些第三方库的兼容性可能还没跟上,稳定比版本号好看更重要。虚拟环境我用了venv,依赖管理用requirements.txt,前端的包用 npm 管理。

这些选型背后我都有一个朴素的原则:不追新技术,只用经过社区验证、文档齐全的稳定组合。对于一个交易系统来说,稳定和可维护性永远是第一位的。

2. 数据库设计与后端接口实现

2.1 核心模型设计:用户、图书、订单、评论

数据库表是整个系统的地基,我前前后后改了三版才最终定下来。核心模型就四张主表加几张关联表:

首先是用户表。Django 自带的User模型可以继承扩展,我给它加了一个Profile的扩展模型,存手机号、收货地址、头像、信誉分等字段。登录认证用 Django 内置的auth模块,不需要自己造轮子,但需要加密签发的 token 传给前端,这点在下一节说。

其次是图书表Book,字段大致包括:标题、作者、出版社、ISBN、原价、售价、成色、分类(用Category外键)、封面图、描述、库存数量、上架时间、上架状态(IS_ACTIVE)、浏览次数、卖家外键。其中一个值得注意的细节是:二手书通常只有一件库存,但我也设计了库存数量字段,因为有的卖家可能有多本同款书,后续扩展时不需要改表。

然后是订单表Order,字段包括订单号、买家外键、卖家外键(从图书的卖家冗余过来)、总金额、收货地址、联系电话、订单状态、创建时间、支付时间、发货时间、完成时间。为什么订单表里同时存买家 id 和卖家 id?因为一个订单必须能独立表达买卖双方信息,不能只靠关联图书表去反查卖家,否则订单列表会非常难查。

评论表Review关联订单、图书和评价用户,包括评分、内容、创建时间。评论必须绑定订单,这样才能防止没买过书的人乱打分。

除了这些,还设计了购物车表CartItem,关联用户和图书,记录加入时间和数量。购物车其实可以只存在前端 localStorage 里,但我为了服务端能同步多端,选择了落库,这样用户换浏览器购物车数据也不丢。

2.2 基于 Django REST Framework 的接口规范

后端我全部用 DRF 的ModelViewSet写了一组标准 RESTful 接口。接口路径设计如下:

POST /api/auth/register/ 注册 POST /api/auth/login/ 登录,返回 token GET /api/books/ 图书列表,支持 keyword、category、min_price、max_price 参数 POST /api/books/ 发布图书(需登录) GET /api/books/{id}/ 图书详情 PUT /api/books/{id}/ 编辑图书(仅卖家) DELETE /api/books/{id}/ 下架图书(仅卖家) POST /api/orders/ 创建订单 GET /api/orders/mypurchases/ 我买到的 GET /api/orders/mysales/ 我卖出的 POST /api/orders/{id}/ship/ 卖家发货 POST /api/orders/{id}/finish/ 买家确认收货

DRF 的ModelViewSet加上router注册以后,一个 ViewSet 能自动生成 list、retrieve、create、update、destroy 五个默认动作。但注意,像mypurchases、mysales、ship这类自定义动作,需要用@action装饰器标记。我举一个简单例子:

from rest_framework.decorators import action from rest_framework.response import Response class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() serializer_class = OrderSerializer @action(detail=False, methods=['get']) def mypurchases(self, request): orders = Order.objects.filter(buyer=request.user).order_by('-created_at') serializer = self.get_serializer(orders, many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def ship(self, request, pk=None): order = self.get_object() # 只有卖家才能发货 if order.seller != request.user: return Response({'detail': '无权操作'}, status=403) order.status = 'SHIPPED' order.shipped_at = timezone.now() order.save() return Response({'detail': '发货成功'})

这里要注意,认证权限一定要控制好。DRF 的权限类我用了IsAuthenticated,并且对于图书详情接口,未登录用户也允许查看,所以单独对retrieve方法覆盖了权限。代码里的判断不能只写在 ViewSet 里,还需要在序列化器里做合法性校验,双保险。

2.3 核心业务逻辑:上架、下单、库存扣减

图书上架逻辑相对简单,但下单这步是业务核心,也是最容易出 bug 的地方。我的下单流程是:

  1. 前端把[{book_id, quantity}, ...]列表发给后端。
  2. 后端遍历商品列表,检查每本图书是否处于上架状态、库存是否足够。
  3. 计算订单总金额:每件商品的售价乘以数量求和,我这里没有做优惠系统,所以没有折扣字段。
  4. 生成一个唯一订单号,包含时间戳+用户 id+随机数。
  5. 创建订单记录,同时创建订单明细表(我专门建了一个OrderItem表存订单和图书的对应关系)。
  6. 扣减库存:Book.objects.filter(pk=book.id, stock__gte=quantity).update(stock=F('stock') - quantity)。

第 6 步是关键,也是很多初学会写错的地方。如果你直接这样写:

book = Book.objects.get(pk=book_id) if book.stock >= quantity: book.stock -= quantity book.save()

在高并发下会出现超卖问题:两个请求同时读到 stock=2,都判断库存足够,然后都执行减一,最后数据库里 stock=0,但已经卖出去两单了。正确的做法是使用 Django 的F表达式配合filter条件做原子更新,这样数据库行锁会保证同一时间只有一个事务能修改库存。

from django.db.models import F updated = Book.objects.filter(pk=book.id, stock__gte=quantity).update(stock=F('stock') - quantity) if updated == 0: raise ValidationError('图书库存不足或已下架')

这才是可靠的方案。数据库层面的原子操作,永远不会出现你查出来有货、下一秒被别人抢走的问题。当然更严谨可以加select_for_update()行锁,但上面的写法已经能解决 90% 的场景了。

3. Vue3 前端搭建与核心交互

3.1 初始化 Vue3 项目与工程化配置

前端我用 Vite 创建项目,一条命令就能启动骨架:

npm create vite@latest second-hand-book-frontend -- --template vue cd second-hand-book-frontend npm install npm install axios element-plus pinia vue-router

这里我额外安装了sass,因为 Element Plus 的定制主题和项目样式都用到 scss。安装后我在vite.config.js里配置了开发服务器代理,解决跨域问题:

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

这个代理配置可以说是前后端联调最重要的一步。开发时前端跑在 5173 端口,后端跑在 8000 端口,如果直接发请求,浏览器会因为跨域限制而拦截。配置完代理之后,前端所有以/api开头的请求都会被 Vite 转发到 Django,浏览器感知的是同源请求,跨域问题就绕过去了。生产环境部署时不需要这个代理,我会在第四部分讲。

页面结构我分成了几个大模块:views/Home.vue负责图书列表和搜索,views/BookDetail.vue负责图书详情,views/Cart.vue负责购物车,views/Checkout.vue负责下单支付模拟,views/UserCenter.vue统一承载个人信息、我买到的、我卖出的、发布图书等子页面。路由用 Vue Router 的懒加载方式,组件通过() => import()引入。

3.2 后端联调与请求封装

前端所有请求我都通过 Axios 封装到src/utils/request.js里,统一处理请求头、token、错误提示:

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Token ${token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response) { if (error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error(error.response.data.detail || '请求失败') } } return Promise.reject(error) } ) export default request

后端登录接口我返回的是一个 token 字符串。需要说明的是,Django REST Framework 自带的 TokenAuthentication 表里的 token 是明文的,生产环境建议用 JWT 或者带有效期的 token。我这个项目是课程设计,所以用了 DRF 自带的 TokenAuthentication 图省事,但有一个细节必须做:登录时如果该用户已经存在 token,则直接返回已有 token,否则创建新 token。我需要保证同一个用户在每个浏览器端登录后 token 不冲突,所以后端登录逻辑里:

token, created = Token.objects.get_or_create(user=user) return Response({'token': token.key, 'username': user.username, 'id': user.id})

前端在每个请求的 Header 里带上Authorization: Token <token>,Django 侧确认用户身份。

3.3 核心页面实现:图书列表、详情、购物车、个人中心

图书列表页我用了 Element Plus 的el-card网格布局,每张卡片展示封面、书名、售价、成色。顶部放搜索框和分类选择器,搜索参数变化时通过watch重新调用列表接口。Vue3 的 Composition API 写起来非常顺手:

<template> <div class="book-grid"> <el-card v-for="book in books" :key="book.id"> <img :src="book.cover" alt="cover" /> <h3>{{ book.title }}</h3> <p class="price">¥{{ book.price }}</p> </el-card> </div> </template> <script setup> import { ref, watch } from 'vue' import { getBooks } from '@/api/book' const books = ref([]) const keyword = ref('') const categoryId = ref('') async function loadBooks() { books.value = await getBooks({ keyword: keyword.value, category: categoryId.value }) } watch([keyword, categoryId], loadBooks) </script>

这里我注意到了一个细节:Element Plus 的el-select在绑定空字符串时,如果后端要求不传参数,应该把空值转成undefined或者直接删除请求参数,否则后端filter会按空字符串去匹配,很可能查不出数据。我在请求封装里做了一个参数清理函数,把所有为''、null、undefined的键都删掉,这个小坑踩过一次印象很深。

图书详情页展示完整信息,包括成色、浏览人数、卖家信息。购买按钮点击后有两种方式:加入购物车和立即购买。加入购物车就是调POST /api/cart/items/,立即购买则跳转结算页。

购物车页面就是一个表格,可以调整数量、删除条目,底部显示合计金额。注意购物车数量不能超过图书库存,我每次校验并用el-input-number的max属性限制。

个人中心页用el-tabs分成“我发布的”“我买到的”“我卖出的”“个人信息”几个标签页。每个标签页对应不同的接口和组件。这里比较麻烦的是订单状态的操作按钮,比如买家看到待发货状态的订单,可以点“提醒发货”;卖家看到待发货订单,可以点“发货”。这些按钮的显隐要根据当前用户和订单状态来动态判断。我把订单状态和操作按钮的关系做成了一张映射表,代码可读性提升很多。

4. 关键难点与问题排查实录

4.1 Django 跨域与 token 认证问题

前后端分离项目第一个坑永远是跨域。虽然开发环境可以用 Vite 代理绕开,但如果你想让 Django 允许跨域,需要安装django-cors-headers:

pip install django-cors-headers

在settings.py中配置:

INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', ]

但这里我强烈建议生产环境不要开这个插件,而是让 Nginx 把前端静态文件和后端接口放在同一个域名下,用路径区分/和/api,这样浏览器根本不会产生跨域请求。开发环境你爱怎么开都行,生产环境尽量少开一个入口就少一份风险。

Token 认证的坑在于:DRF 的TokenAuthentication默认只接收Authorization: Token xxx这个格式,前端如果写成了Bearer xxx会直接 401。另外,如果你在多个地方(比如 Postman、浏览器、小程序)登录同一个账号,用get_or_create的做法会导致旧 token 被顶掉或者大家共用同一个 token,一旦有用户退出,所有人都会跳出登录。更合理的做法是每个端对应一个 token,但这就接近 JWT 了。如果是课程设计,get_or_create够了,自己心里要清楚这个限制。

4.2 图书图片上传与静态资源处理

图片上传是二手图书系统的刚需。Django 处理上传文件其实很简单,在 settings 里配置MEDIA_ROOT和MEDIA_URL,然后在模型里给cover字段加上upload_to='book_covers/'。但是有一个特别容易踩的坑:前端在提交 FormData 时,不要去手动设置Content-Type: multipart/form-data,因为浏览器会自动生成带 boundary 的请求头。如果你手动硬编码Content-Type,会导致后端解析失败,上传接口永远报 500。

正确的写法是让 Axios 自动处理:

const formData = new FormData() formData.append('cover', file) formData.append('title', form.title) request.post('/books/', formData)

后端序列化器里的cover字段用ImageField处理,序列化时返回完整 URL。开发环境中,我在settings.py里写:

MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'

然后在项目 urls.py 里加:

from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

这样开发时图片就能正常访问了。但生产环境直接用 Django 伺服媒体文件性能很差,需要交给 Nginx,这个在部署部分说。

4.3 并发下单超卖问题的处理

这个我在前面数据库设计一节已经详细讲过了,但这里还要补充一个情况:即使你在后端用F()表达式做了原子更新,前端还是可能出现一种“用户看到库存有货,提交时库存没了”的情况。这时候前端的体验很重要,应该在提交订单后检测到后端返回“库存不足”的校验错误时,把对应书籍从购物车移除,并提示用户稍后再试。

另外,如果要做更严格的防超卖,可以在事务里加select_for_update():

from django.db import transaction with transaction.atomic(): book = Book.objects.select_for_update().get(pk=book_id) if book.stock >= quantity: book.stock -= quantity book.save() else: raise ...

这种方式会锁行直到事务结束,在高并发下性能会下降,但可以保证绝对正确。对于二手图书这种低并发场景,F()表达式已经足够,不必上锁。

4.4 常见问题速查表

我把自己调试过程中遇到的高频问题整理成了一个表,如果你是新手,可以直接对照排查。

现象可能原因解决办法
前端请求返回 404后端接口路径没写对,或urlpatterns没有包含对应路由检查router.urls是否在urlpatterns里
登录后刷新页面就失效token 没有存到 localStorage登录后localStorage.setItem('token', token),并设置 Axios 默认请求头
图片上传返回 415手动设置了Content-Type删除手动设置,交给 Axios
跨域报错 CORS代理没配置,或corsheaders没装没配参考 4.1 节
订单创建后库存没减少下单逻辑没有调用原子更新用F()表达式
Vue3 启动报错Module not found依赖没装完删除node_modules,重新npm install
Django 启动报错django.db.utils.ProgrammingError数据库连接配置错误,表没迁移检查 MySQL 账号密码,运行python manage.py migrate

另外还有一个很隐蔽的坑:Django 的时间字段时区问题。如果你的USE_TZ = True,MySQL 里存的时间是 UTC 时间,前端拿到的数据会比北京时间慢 8 小时。这不是 bug,是时区设置问题。解决方案是在启动项目后就确定时区:

TIME_ZONE = 'Asia/Shanghai' USE_TZ = True

但USE_TZ = True时 Django 只保证内部统一使用 UTC,对外输出时会根据TIME_ZONE转换。如果发现还是不对,可以在序列化器的时间字段上加format='%Y-%m-%d %H:%M:%S'强制格式化输出。

最后一次部署与收尾心得

这个项目做到最后,我把它部署到了一台云服务器上。部署流程是:后端用gunicorn启动,前端npm run build产物放到 Nginx 的静态目录,Nginx 里将/api路径反向代理到 Django 的 8000 端口,/media路径直接指向媒体文件夹,其它所有路径返回前端首页。这套方案是前后端分离项目最常见的部署方式,不需要折腾 Docker 也能跑得很稳。

我个人在几个星期的开发和调试中最深的体会是:写代码时一定要把“数据是如何流转的”这个主线时刻放在脑子里。从 Vue 页面里的一个按钮,点击后怎样携带 token 发给 Django,Django 怎样校验身份和权限,怎样修改数据库,又怎样把结果返回给 Vue,每一环如果都想清楚了,出 bug 时定位就会很快。很多同学一报错就懵,其实只要你顺着这条数据流一点一点查,90% 的问题都能在十分钟内找到原因。

还想补充一个经验:Django 自带的 Admin 后台对这类管理系统是极好的调试工具。不要把 Admin 当成鸡肋,开发阶段我经常直接在 Admin 里造数据、查看订单状态、模拟各种业务场景。它不需要你写一行代码就能让你直观看到数据库里所有信息,对排查订单状态流转问题帮助巨大。尤其是项目中期,每次前端页面出问题,我先去 Admin 里确认后端数据是不是符合预期,就省去了很多无意义的“前端背锅”。

如果你也准备做类似系统,我的建议是先把数据库表设计清楚、把状态字段的枚举值定好,再写任何一个接口。这个基础打牢了,后面无论是 Django 还是 Vue3 都只是填砖头。项目做完之后回头再看,你会发现真正让你成长的不是用了多新的技术,而是你在设计、调试、踩坑、修复的过程中建立起来的系统思维。这份经验比代码本身值钱得多。

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

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

立即咨询