ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

校园二手图书交易系统开发实战:Django+Vue3踩坑全记录

校园二手图书交易系统开发实战:Django+Vue3踩坑全记录 做校园二手图书交易系统时我踩过的那些坑一次性全写出来每年开学季都有大量教材被转手可真要淘到一本价格合适、成色可用的二手书往往要翻遍好几个群、跑好几栋宿舍楼。所以当我决定自己动手做一套二手图书交易系统时第一反应就是这玩意虽然看着像一个普通商城但涉及用户登录、商品上架、图片上传、购物车、订单流转甚至还有状态变更通知前前后后牵扯的技术点一点不比企业级后台少。我把技术栈定为 Python Django 做后端Vue3 做前端一套前后端分离的项目结构既能交给社团运营也能作为练手项目反复打磨。这篇我会把整个项目从零开始的设计思路、数据建模、接口实现、前端页面联调一直讲到部署和常见坑位尽量把每一步为什么这么选、实际跑起来会遇到什么都说清楚。如果你正打算做一个类似的交易平台或者刚入门前后端分离项目想找个完整案例这篇应该能帮你少走不少弯路。1. 项目整体设计与架构选型1.1 为什么选 Django Vue3 这个组合二手图书交易系统的核心需求并不复杂卖家发布图书信息买家浏览搜索、加入购物车、下单支付后台管理用户与订单。但越是这种需求“看起来简单”的项目越要警惕技术选型被带偏。我当年第一次尝试时直接用了 Django 自带模板把前端页面放在 templates 里后端用 render 返回 HTML。结果做到购物车动态更新时AJAX 和模板混在一起越写越难受。后来重新设计时果断改成前后端分离后端只提供 JSON 接口前端用 Vue3 管理页面状态双方用 HTTP 接口通信。这样项目的可扩展性明显提升后期要加小程序端或者移动端后端接口几乎不用动。选择 Django 而不是 Flask 或 FastAPI主要看中三点Django ORM 自带 Admin 后台可以快速管理图书和用户数据Django 生态里 DRFDjango REST Framework做接口非常成熟用户认证、Session、CSRF 防护等安全机制是内置的对于交易系统这种涉及资金的场景安全底子好很重要。Vue3 这边我用的组合式 APIComposition API加上 script setup 语法比 Vue2 的 Options API 写业务逻辑更紧凑。配合 Pinia 做状态管理Element Plus 提供表格、表单、对话框等常用组件前端开发效率能提上一个档次。1.2 前后端分离的项目结构规划项目采用 monorepo 式目录后端和前端分两个子目录secondhand-book/ ├── backend/ │ ├── manage.py │ ├── config/ # Django 项目配置目录 │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── apps/ │ │ ├── users/ # 用户模块 │ │ ├── books/ # 图书模块 │ │ ├── orders/ # 订单模块 │ │ └── common/ # 公共配置与工具 │ ├── media/ # 用户上传的图书图片 │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── api/ # 封装 axios 请求 │ │ ├── components/ # 通用组件 │ │ ├── views/ # 页面 │ │ ├── stores/ # Pinia 状态 │ │ ├── router/ # Vue Router │ │ └── utils/ # 工具函数 │ ├── package.json │ └── vite.config.js后端不搞复杂的微服务一个 Django 工程下挂多个 app每个 app 负责一个业务域。这样结构清晰也方便后期拆分成独立服务。前端用 Vite 构建Vue3 Vue Router Pinia这已经是当前的主流组合。之所以不用 Vue CLI是因为 Vite 的开发服务器启动速度快热更新也更敏捷交付体验好很多。2. 后端核心实现Django 数据建模与接口开发2.1 图书和用户模型的设计思路一个交易系统的数据模型是地基模型设计得不好后面接口越写越痛苦。我的核心模型分四块用户模型直接扩展 Django 自带的 AbstractUser额外加手机号和学号字段如果是校园场景这样不需要从零实现密码加密和登录验证。# apps/users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): phone models.CharField(max_length11, blankTrue) student_id models.CharField(max_length20, blankTrue) avatar models.ImageField(upload_toavatars/, nullTrue, blankTrue) class Meta: verbose_name 用户图书模型是整个系统的核心要包含书名、作者、出版社、ISBN、原价、售价、成色、封面图、图书描述、上架状态、卖家等字段。这里有个容易漏的设计成色condition建议用 IntegerField 加 choices而不是用字符串因为后续搜索筛选时数字区间过滤比字符串匹配效率高得多。class Book(models.Model): CONDITION_CHOICES [ (1, 全新), (2, 九成新), (3, 八成新), (4, 七成新及以下), ] title models.CharField(max_length200, verbose_name书名) author models.CharField(max_length100, verbose_name作者) publisher models.CharField(max_length200, blankTrue, verbose_name出版社) isbn models.CharField(max_length13, blankTrue, verbose_nameISBN) original_price models.DecimalField(max_digits6, decimal_places2, verbose_name原价) price models.DecimalField(max_digits6, decimal_places2, verbose_name售价) condition models.IntegerField(choicesCONDITION_CHOICES, default3) cover models.ImageField(upload_tocovers/, blankTrue, nullTrue) description models.TextField(blankTrue, verbose_name描述) status models.CharField(max_length10, defaulton_sale, choices[ (on_sale, 在售), (sold, 已售), (off_shelf, 已下架), ]) seller models.ForeignKey(User, on_deletemodels.CASCADE, related_namebooks) created_at models.DateTimeField(auto_now_addTrue)这里有个需要叮嘱的重点外键关系一定要设置related_name否则在序列化时你会在seller和user之间绕来绕去。另外价格字段用 DecimalField 而不是 FloatField涉及到钱的东西千万别用浮点数否则出现 19.999999 这种精度问题用户绝对会找上门。订单模型包含订单号、买家、卖家、图书、数量二手书一般一本预留数量字段、总价、状态、创建时间。为了减少库存操作的并发问题我选择在每次创建订单时直接锁定图书的卖家并通过数据库事务保证订单创建和图书状态更新的原子性。2.2 Django 执行查询列表、筛选与删除对象Django ORM 的查询能力很强但很多人会对objects.filter()和objects.get()的用法搞混。在列表接口里我习惯用 filter 返回 QuerySet然后用select_related预取外键数据避免 N1 查询。比如获取在售图书列表def get_queryset(self): return Book.objects.filter(statuson_sale).select_related(seller)关于删除对象Django 默认的方法是objects.delete()但删除一个正在被订单引用的图书会直接导致订单表里的外键报错。我在项目里选择了软删除思路给图书模型加了一个is_active字段下架操作就是把这个字段置为 False。这样既能保证历史订单数据完整也能避免用户误删商品后无法恢复。如果你确实需要物理删除记得在删除前检查是否存在关联订单from django.db.models import ProtectedError try: book.delete() except ProtectedError: # 该图书存在关联订单改为软删除 book.status off_shelf book.save()这就是很多教程里不会写到的边界情况。做交易系统数据完整性永远是第一优先级的。2.3 DRF 序列化器与视图集接口层面我用 DRF 的 ModelSerializer 来序列化模型。有一个经典难题是发布图书时前端传 book 数据但 seller 字段不能让用户自己传必须从当前登录用户提取。解决的姿势是在序列化器里把 seller 设为只读创建时在 perform_create 里赋值class BookSerializer(serializers.ModelSerializer): seller_name serializers.CharField(sourceseller.username, read_onlyTrue) class Meta: model Book fields __all__ read_only_fields [seller, status, created_at] def perform_create(self, validated_data): validated_data[seller] self.request.user return super().perform_create(validated_data)视图集用ViewSet配合 Router 注册比写函数视图要省一半代码。只需要设置 queryset、serializer_class再按需重写 get_queryset 实现权限控制。需要注意权限控制普通用户只能对自己发布的图书做修改和删除买家只能查看在售图书。这个逻辑用 DRF 的IsAuthenticatedOrReadOnly加上自定义的IsOwnerOrReadOnly权限类来解决。2.4 用户认证与 Token 方案交易系统必须有登录态否则购物车和订单无从谈起。我在项目中用了 JWTJSON Web Token方案前端保存 token每次请求的 Authorization 头带上Bearer token。Django 后端安装djangorestframework-simplejwt在 settings.py 里配置REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticated, ], }这里有个常见坑前端拿到 JWT 后会存在 localStorage 里但如果你不做任何拦截器处理刷新页面后 Vuex 或者 Pinia 中的用户信息会重置。所以在 Vue3 前端我写了一个 axios 拦截器请求发出前从 localStorage 读取 token 并加到 header响应返回 401 时自动跳转登录页。同时还要在 Django 的 JWT 配置里设置过期时间和允许的算法比如ACCESS_TOKEN_LIFETIME设为两小时。过期时间是用户最容易感知的痛点设太短用户要频繁登录设太长有安全风险推荐 2 小时起步加一个刷新接口配合前端定时刷新。3. 前端核心Vue3 商城形态与页面拆解3.1 Vue3 项目初始化与环境准备前端用 Vite 初始化项目最省事npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router pinia axios element-plusElement Plus 在使用时可以做按需导入避免整个组件库打包进产物导致体积过大。Vite 配置里加 unplugin-vue-components 和 unplugin-auto-import 就能自动按需引入。如果项目中用到了 scss还要单独安装sass和sass-loader的现代版本。Vue3 Vite 对 sass 的支持是内置的只要npm install -D sass然后在 style 标签里写style langscss就可以了。这个项目规模不算大所以前端页面我用 6 个核心视图来组织首页图书列表 筛选栏图书详情页发布图书页购物车页订单结算页个人中心我的发布、我的订单路由配置就按这 6 个页面来加上登录页和注册页。路由懒加载要用() import(...)这样首屏只加载首页组件文件体验会好很多。3.2 通过状态管理串联购物车和全局登录态购物车的状态如果只放在某个组件里页面一刷新就没了所以要用 Pinia 做全局状态。我定义了useCartStore、useUserStore两个 store。userStore 里保存 token 和用户基本信息登录成功后将 token 写入 localStorage同时调用一次用户信息接口。cartStore 保存购物车列表提供 addItem、removeItem、clear 三个 action。有一个容易被忽视的细节购物车后端要不要保存如果只是前端 localStorage用户换设备购物车就丢了。正规交易系统还是要在后端保存购物车记录但这样每个操作都要请求接口响应速度会受一点影响。我做的是折中方案前端状态为主后端在用户下单时才一次性同步确认。3.3 前端搜索与筛选交互的设计首页的筛选栏是二手书网站的核心交互包括关键词搜索、按学科分类、按成色筛选、价格排序。我用了组合式 API 中的ref和computed来管理筛选条件筛选条件变化后重新请求后端接口const filters ref({ keyword: , category: , condition: null, ordering: -created_at, }); const fetchBooks async () { loading.value true; try { const response await axios.get(/api/books/, { params: filters.value }); books.value response.data.results; } finally { loading.value false; } };这里注意 DRF 的分页返回格式是{ count, next, previous, results }所以在 axios 调用后要取response.data.results很多新手会在这一步卡住半小时。3.4 图片上传与前端回显发布图书需要上传封面图。我用 Element Plus 的el-upload组件上传地址指到 Django 后端的/api/upload/后端接收文件后保存到 media 目录并返回可访问的 URL。前端拿到 URL 后放到表单的 cover 字段里提交图书信息时一起传给后端。有一个需要提前处理的细节开发环境下文件上传返回的 URL 可能是相对路径比如/media/covers/xxx.jpg。前端要把它完整拼成可访问的绝对地址const getFileUrl (path) { if (path.startsWith(http)) return path; return ${import.meta.env.VITE_API_BASE_URL}${path}; };还需要在 Django settings 里配置MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media并且在项目根 urls.py 里用static()函数把 media 目录挂到开发服务器上。4. 订单流程与前后端联调的关键环节4.1 购物车到订单的转换逻辑购物车在用户确认后要生成订单这中间要做两件事一是核对图书是否还在售二是计算订单总金额。后端接口我用了一个create_order的自定义 action而不是默认的 ModelViewSet create因为创建订单和创建普通资源有本质区别需要同时处理多个购物车项。action(detailFalse, methods[post]) def create_order(self, request): cart_items request.data.get(cart_items, []) lines [] total 0 with transaction.atomic(): for item in cart_items: book Book.objects.select_for_update().get(iditem[book_id]) if book.status ! on_sale: raise ValidationError({error: f{book.title} 已下架}) # 更新图书状态 book.status sold book.save() line OrderLine.objects.create( bookbook, pricebook.price, buyerrequest.user ) lines.append(line) total book.price order Order.objects.create( buyerrequest.user, sellerbooks[0].seller, total_amounttotal ) order.lines.set(lines) return Response(OrderSerializer(order).data)这里用到了select_for_update()配合事务主要为了处理并发下单时同一本书被两个人同时抢到的问题。虽然二手书交易并发量没有电商那么大但养成这种严谨习惯以后做其他项目直接就能复用。4.2 Django WebSocket 与前端实时推送在二手书交易场景里用户下单后卖家很想知道“我的书被拍下了”如果用传统的轮询接口延迟高且浪费资源。我引入了 Django Channels 来支持 WebSocket实现后台有订单数据变化时主动向前端推送消息。后端实现思路# consumers.py class OrderConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name fuser_{self.scope[user].id} await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def receive(self, text_data): pass # 下单成功时调用 classmethod async def notify_order(cls, user_id, message): await channel_layer.group_send( fuser_{user_id}, {type: order.message, message: message}, )前端在 Vue3 里用原生 WebSocket 封装一个 composable在用户登录后建立连接收到订单推送事件时弹一个 Element Plus 的通知框同时刷新订单列表。这个功能虽然看起来只是锦上添花但演示项目时效果特别好能直观体现“实时”二字。4.3 前后端跨域与代理配置开发环境下Vue3 在 5173 端口运行Django 在 8000 端口运行跨域是必然的。我在 Vite 配置中使用 proxy把/api开头的请求代理到后端// vite.config.js server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, }, /media: { target: http://127.0.0.1:8000, changeOrigin: true, }, } }有这样一个代理后前端里所有的请求直接写/api/books/这种相对路径就好不需要写全 URL。但如果不做代理而是直接跨域请求那就在 Django 里安装django-cors-headers添加CORS_ALLOWED_ORIGINS配置。注意Vite 的 proxy 只作用于开发服务器生产环境要由 Nginx 统一转发这块到部署篇再展开。4.4 接口鉴权与权限校验的细节很多接口需要区分“本人”和“非本人”。比如查看订单列表时用户只能看到自己买或卖的订单。我在视图的 get_queryset 里做过滤def get_queryset(self): if self.request.user.is_staff: return Order.objects.all() return Order.objects.filter( Q(buyerself.request.user) | Q(sellerself.request.user) )这样隔离数据最直接也最有效。不要指望前端隐藏按钮来实现权限前端只是用户体验层后端必须兜底。有一个实际场景用户提交了订单后点击取消订单这个操作应当校验当前登录用户和订单买家是否一致。我用了一个自定义权限类来简化复用class IsOrderBuyerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): return obj.buyer request.user5. 部署与售后从开发环境到可对外访问5.1 部署环境准备与依赖管理项目开发完成后需要把它部署到一台服务器上我用的是腾讯云轻量服务器系统是 Ubuntu 22.04。部署前先把后端依赖导出pip freeze requirements.txt但是这里有个坑pip freeze会把所有环境里无关的包也导出来。更好的做法是手动维护 requirements.txt只写项目真实依赖Django4.2 djangorestframework djangorestframework-simplejwt django-cors-headers channels Pillow gunicorn前端构建前先把.env.production文件写好设置 API 地址为线上域名。npm run build构建产生的 dist 目录稍后由 Nginx 托管。5.2 Nginx Gunicorn 部署 Django 和 Vue3前后端分离项目部署有几个关键步骤。后端用 Gunicorn 启动gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3注意不要用 Django 自带的 runserver 跑生产环境性能差距很大。Nginx 配置同时承担静态文件服务和反向代理功能server { listen 80; server_name yourdomain.com; # 前端静态文件 location / { root /var/www/frontend/dist; index index.html; try_files $uri $uri/ /index.html; } # 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; } # 媒体文件 location /media/ { alias /path/to/backend/media/; } }Vue3 的路由是 history 模式刷新页面时如果 Nginx 没有配置try_files会出现 404。上面配置中try_files $uri $uri/ /index.html就是解决这个问题的关键。5.3 常见部署问题速查问题现象可能原因解决方式前端请求 API 报 404Nginx 没把 /api 转到后端检查 location /api 的 proxy_pass 配置上传图片后访问 404media 目录权限不对chmod -R 755 media确认 Nginx alias 路径页面刷新 404路由 history 模式Nginx 加 try_files 配置接口响应 500Django DEBUGFalse 但有异常查看 gunicorn 日志或 journalctlAdmin 后台样式丢失Django 静态文件未收集python manage.py collectstatic这些坑我是在部署第几次后才逐渐总结出来的特别是 media 目录权限因为静态文件路径配错最容易出问题但报错信息却不直观。6. 常见问题与经验复盘6.1 前后端联调时的数据格式问题前后端分离项目大部分摸不着头脑的问题都出在数据格式上。Django 的 DecimalField 序列化出来的是字符串比如price: 19.90前端直接拿去做数字加法会得到字符串拼接的结果。保险做法是前端统一处理const toNumber (value) Number(value || 0);还有日期格式Django 默认输出 ISO 字符串前端直接用new Date(value)解析即可。如果传回后端的是空字符串DRF 直接反序列化会报错所以前端提交前必须把空字段转换成 null。6.2 图书搜索功能的索引优化搜索是二手书交易系统最常用的功能。一开始我直接用icontains做模糊查询Book.objects.filter(title__icontainskeyword)数据量到了几百条时还流畅但到了几千条或者上万条这种查询会全表扫描响应时间直线上升。我后来做了两件事一是给常用搜索字段加db_indexTrue二是引入 Django 的搜索方案辅助过滤。如果数据量再大可以接入 ElasticSearch 或者先用 PostgreSQL 的全文检索但校园场景下 PostgreSQL 的SearchVector就够用了。6.3 Cookie 与 Token 的取舍在标题的热搜词里看到有人问 Django 设置 Cookie 搭配 Token这里也展开说下。Django 传统的 Session 方案依赖 Cookie而前后端分离 JWT 则不需要 Cookie而是把 Token 放在请求头里。如果你选择 Cookie 方式还要处理 CSRF 问题选择 JWT 方案则不用太担心 CSRF但要注意 Token 泄露的风险。我的建议是校园二手书交易这种场景安全性要求不是军工级别JWT 存 localStorage 再加上后端设置合适的过期时间已经够用了如果想更安全把 Token 放到 HttpOnly Cookie 里前端通过接口读取代价是每次都要带凭证。6.4 二手书成色评估与商品描述的经验这个不是技术问题但比技术问题更影响产品的可用性。二手书交易最大的不确定性是书的成色。我加了一个“成色标准说明”的页面把不同成色的定义写清楚同时要求卖家上传书籍实拍图包括封面、扉页、笔记内页。这样买家在详情页能基本判断实际情况减少后续纠纷。另外考虑到二手书价格低订单金额小如果走真实支付通道手续费甚至比利润还高。所以实际项目中我用了模拟支付而非真实支付的模式或者只做订单状态流转整个流程跑通就好。如果真要对接微信支付/支付宝建议可以参考官方文档把支付回调处理好但复杂度会上升不少适合后续迭代。6.5 对新手最有用的几个小技巧如果看到这里你准备动手开发我再送你几个由实际操作总结出来的经验Django 创建 app 时不要图省事把所有功能写在 models.py 的一个文件里。大项目可以按models/包拆分成多个模块虽然本项目不大但习惯要养好。Vue3 的script setup里不要忘记导入computed别问我是怎么知道的。在本地开发时图片上传到 media 目录后刷新浏览器还不显示先检查 Django 是否配置了 static 服务。前后端字段命名不一致是最累的建议从一开始就约定好 JSON 字段统一使用 snake_case。接口文档可以用 DRF 自带的 schema 或者直接写一个 API.md团队协作时能省掉大量沟通成本。我个人在开发这套系统的过程中最深刻的体会不是某个框架的高深技巧而是“交易系统”和“展示型网站”在数据一致性、状态流转、边界条件上完全不是一个量级。下单时要考虑并发、取消时要考虑退款、图书下架时还要考虑待发货订单……这些场景单靠教程里的 CRUD demo 根本接触不到。好在踩过这些坑之后再去看电商开源项目很多原本看不懂的代码突然就有了逻辑。如果你只是练手可以按我这个流程先去实现基础版本再逐步加入搜索、评价、消息推送这些进阶功能。如果真要上线运营一定记得把真实支付和售后流程设计好那才是系统的胜负手。
返回列表