ARTICLE DETAIL

资讯详情

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

Django+Vue3全栈实战:从零开发校园二手书交易平台

Django+Vue3全栈实战:从零开发校园二手书交易平台 先说个真实背景。我去年年底动手做这个二手图书交易系统起因很朴素——学校课程群里每天都有学长学姐刷屏卖考研资料和教材几十条消息翻下来不是被一句出了截胡就是根本分不清谁在卖哪本。图书这种低频买卖用聊天流去做效率实在太低。于是我想自己做一个能搜、能筛、能下单的校园二手书平台后端接口用 Django 写前端页面用 Vue3 搭前后端一分离很多东西就顺了。这个项目做完之后我最大的感受是它特别适合作为 Django Vue3 全栈实战的入门项目。原因有三一是业务流程完整从注册登录、图书发布、列表搜索到订单状态流转横跨了 Web 开发最典型的功能面二是前后端边界清楚Django 只出接口、Vue3 只做交互每一层都能独立测试三是坑足够典型跨域、认证、图片上传、token 过期这些实战问题你都会挨个踩一遍。如果你正在学 Django 或 Vue3想找一个能完整复现的练手项目这篇应该能省你不少调试时间。1. 项目定位与架构拆解为什么是 Django Vue3 的组合1.1 校园二手书交易的核心业务流程动手写代码之前我先把业务流程画了一遍。二手书交易和普通电商有差异书有具体版本、新旧程度、笔记量这些属性交易双方通常都是同一个校园的人买家更在意这书是不是我要的那版而不是物流几天能到。所以核心流程落在五个环节上用户注册登录区分买家和卖家身份但一个账号可以既是买家也是卖家图书发布卖家上传书名、作者、ISBN、原价、售价、新旧程度、实物照片图书浏览与搜索买家按书名、作者、ISBN 搜索也可以按分类筛选下单交易买家对某本书下单订单状态从待处理流转到已成交或已取消收藏管理买家把感兴趣的书先收藏方便回头再看。这个流程不高大上但每一个环节都对应数据库里的实体关系。我把表设计成五张核心表用户表、图书表、订单表、收藏表、分类表。后面编码的所有逻辑都是围绕这五张表的增删改查展开。1.2 前后端分离架构的选型理由有人可能会问校园内部项目用 Django 模板加 Bootstrap 一把梭不更简单吗为什么非要拆成前后端两个工程我一开始也犹豫过但实际对比后发现前后端分离对这个项目的收益更大。第一Vue3 的组件化确实提升开发效率。图书列表、图书卡片、分页器、状态标签这些是可以复用的界面单元组件化之后改一处样式全站生效。如果全用 Django 模板每个页面都要单独写一份 HTML后期调整很费劲。第二前后端分离给扩展留了后路。这个项目做完之后如果想让用户通过小程序下单前端只需要用 uni-app 重新写一套界面后端 API 几乎不用动。一旦你打算做多端产品接口层和后端逻辑的复用价值会指数级上升。第三分开部署之后职责更清晰。Django 处理业务逻辑和数据持久化Vue3 只管渲染和交互出了问题排查范围明显收窄。日志一翻就能知道是接口挂了还是页面逻辑挂了。1.3 技术栈版本选择版本选择上我用了当时相对稳定的一套组合后端Python 3.11 Django 4.2 Django REST Framework 3.14前端Node.js 18 Vue3.4 Vite 5 Element Plus Pinia Vue Router数据库SQLite开发阶段 / PostgreSQL生产环境认证方案Django REST Framework SimpleJWTJSON Web Token这里有个经验供参考Django 版本不必追新4.2 是 LTS官方支持时间长生态兼容性也更好。Vue3 这边则建议直接用最新的稳定版因为 Vue3 生态迭代很快新项目没理由用旧版本。2. 后端 Django 起步从环境搭建到数据模型设计的实操细节2.1 虚拟环境与依赖安装后端的第一个坑就在环境上。我用python -m venv venv创建虚拟环境激活后安装依赖。顺序很重要先把虚拟环境激活再安装不然包会装进全局环境后续部署会乱。安装依赖就四个核心包pip install django djangorestframework djangorestframework-simplejwt django-cors-headers Pillow我说一下为什么是这几个包djangorestframework是提供 API 能力的核心库序列化、路由、视图集都是它给的djangorestframework-simplejwt负责 JWT 登录认证这样前端拿到 token 后放在请求头里即可django-cors-headers解决前后端分离开发时的跨域问题Pillow是 Django 处理图片上传的依赖图书封面图必须有它。如果下载慢就换国内镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名这个不是必须的选项但能省很多等待时间。2.2 项目与应用结构我通常用django-admin startproject secondhand_book创建项目再用python manage.py startapp books、startapp users、startapp orders创建三个应用。应用划分的逻辑是按业务域切分books 管图书、收藏、分类orders 管订单users 管用户相关逻辑。虽然默认的 User 模型可以满足基础需求但我自定义了用户表加上了phone、avatar等字段方便扩展。应用创建完必须去settings.py的INSTALLED_APPS里注册这一步漏掉的话makemigrations时模型根本不生效。常见新手错误就是创建了 app 但忘了注册然后一脸茫然地来问为什么数据库没有表。2.3 核心模型设计字段怎么定才合理图书表是整个系统最核心的模型。我设计了这些字段class Book(models.Model): CONDITION_CHOICES [ (new, 全新), (like_new, 近全新), (good, 良好), (acceptable, 有笔记划线), ] 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_length20, blankTrue, verbose_nameISBN) price models.DecimalField(max_digits7, decimal_places2, verbose_name售价) original_price models.DecimalField(max_digits7, decimal_places2, blankTrue, nullTrue, verbose_name原价) condition models.CharField(max_length20, choicesCONDITION_CHOICES, defaultgood, verbose_name新旧程度) description models.TextField(blankTrue, verbose_name描述) cover models.ImageField(upload_tocovers/%Y/%m/, blankTrue, verbose_name封面图) seller models.ForeignKey(users.User, on_deletemodels.CASCADE, related_namebooks, verbose_name卖家) status models.CharField(max_length20, choices[(on_sale, 在售), (sold, 已售出), (off_shelf, 下架)], defaulton_sale) created_at models.DateTimeField(auto_now_addTrue)字段设计有几个关键判断价格用DecimalField而不是FloatField因为浮点数在计算机里是二进制的会出现 0.1 0.2 不等于 0.3 的问题涉及钱必须用定点数。on_deletemodels.CASCADE表示卖家账号删除时他发布的图书也一起删除。但在订单表里我用了PROTECT因为订单属于交易凭证不能因为买家或卖家删号就悄悄消失。related_namebooks是给反向查询用的设置后可以用seller.books.all()查某人发布的全部图书不用再写Book.objects.filter(sellerxxx)省事很多。订单表里我加了一个order_no字段用 UUID 生成唯一订单号。这个字段在人工对账的时候特别好用因为自增主键很容易暴露订单量而且不直观。2.4 ORM 查询与删除对象几个容易翻车的细节关于django 执行查询-删除对象网上一搜能出来一堆问答但我想分享几个自己真实踩过的坑。第一个坑是get和filter的混用。Book.objects.get(idxxx)在记录不存在时会抛DoesNotExist异常如果不在视图里捕获前端就会收到 500。更稳妥的写法是book Book.objects.filter(idbook_id).first() if not book: return Response({detail: 图书不存在}, statusstatus.HTTP_404_NOT_FOUND)filter().first()找不到时返回None不会抛异常适合找不到就返回 404的场景。而当你确定记录一定存在、且数据库有唯一约束时用get更直接但外面的try/except不能省。第二个坑是delete()的级联行为。Django 的CASCADE是很方便的但也容易误伤。比如你删掉一本书所有跟这本书关联的收藏记录、订单记录会一并删除。如果订单表的外键用的也是CASCADE那意味着卖家下架图书和删除图书是两个完全不同的操作——前者只是改status后者是物理删除且连带清空交易记录。所以我在订单表里用PROTECT来挡住误删只要有订单引用了这本书删除操作就会被拦截并抛ProtectedError。第三个坑是delete()的返回值。很多人写book.delete()之后就走了没想过确认是否真的删干净了。其实delete()会返回一个元组(被删除的总数, {app_label.Model: 数量})。调试时把返回值打出来能清晰看到级联删了哪些表的数据强烈建议在删除敏感数据时看一眼。数据迁移方面python manage.py makemigrations和python manage.py migrate是每天的固定动作。要注意一点迁移文件生成后不要急着删改特别是在多人协作时迁移文件就是数据库版本的提交记录随便删掉会造成各环境之间无法同步。3. 用 DRF 把业务闭环变成可用的 API3.1 序列化器设计嵌套展示与写入校验模型建好之后接下来是用 Django REST Framework 把它变成 API。我理解的 DRF 核心价值其实就一句话把模型数据转换成 JSON 给前端用同时把前端传回的 JSON 校验后写进数据库。图书序列化器我这样写的class BookSerializer(serializers.ModelSerializer): seller_name serializers.CharField(sourceseller.username, read_onlyTrue) condition_display serializers.CharField(sourceget_condition_display, read_onlyTrue) class Meta: model Book fields [id, title, author, publisher, isbn, price, original_price, condition, condition_display, description, cover, seller, seller_name, status, created_at] read_only_fields [seller, status, created_at]两个关键点一是seller_name。图书列表页需要展示卖家的用户名如果只返回seller这个外键 id前端还得再请求一次用户接口才能拿到名字。用sourceseller.username直接在序列化的时候取关联字段前端拿到的数据就是完整的。二是read_only_fields。seller字段不能在创建时由前端随意指定否则用户可以伪造卖家身份必须由后端从当前登录用户中自动填入。status也不该由用户传上架默认是on_sale。创建图书时我在 ViewSet 的perform_create里写def perform_create(self, serializer): serializer.save(sellerself.request.user)这样卖家身份始终来自 token而不是来自请求体。3.2 ViewSet 与路由用最少的代码覆盖最多的接口DRF 的ModelViewSet是效率利器。一套完整的图书增删改查接口用普通视图至少要写 8 个函数用 ViewSet 只需要继承出来然后注册即可from rest_framework.viewsets import ModelViewSet class BookViewSet(ModelViewSet): queryset Book.objects.filter(statuson_sale).select_related(seller) serializer_class BookSerializer def get_queryset(self): if self.request.user.is_staff: return Book.objects.all() return self.queryset路由注册也很简单router DefaultRouter() router.register(rbooks, BookViewSet, basenamebook) urlpatterns router.urls然后GET /api/books/、GET /api/books/1/、POST /api/books/、PUT /api/books/1/、DELETE /api/books/1/这些接口就全部自动可用了。但这里有个隐藏问题默认的get_queryset直接写在类属性上普通用户访问的时候会把已售出的书也查出来。所以get_queryset里做了区分——非管理员只能看到在售状态的图书管理员可以看全部。这个逻辑如果漏掉就会出现大量已成交的书挂在列表上界面很乱。3.3 认证与权限JWT 方案在 Web 项目里的实际做法登录认证我选了 JWT。相比 Django SessionJWT 的好处是无状态后端不需要存 session 记录前端把 token 放在Authorization: Bearer xxx头里即可。这个项目后续如果要接小程序JWT 可以无缝复用。配置分三步第一步在settings.py里加REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticatedOrReadOnly, ], DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 12, }第二步路由注册登录接口from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns [ path(api/token/, TokenObtainPairView.as_view(), nametoken_obtain_pair), path(api/token/refresh/, TokenRefreshView.as_view(), nametoken_refresh), ]第三步在需要登录才能操作的接口上加权限。比如发布图书的 ViewSet我在get_permissions里做了区分列表和详情允许匿名访问创建、修改、删除必须登录def get_permissions(self): if self.action in [create, update, partial_update, destroy]: return [IsAuthenticated()] return [AllowAny()]这样做的好处是游客可以逛书城但想卖书就得登录。逻辑用起来很顺手前端也只需要在axios拦截器里统一带上 token不需要为每个接口单独处理。3.4 搜索、过滤与自定义动作接口如何贴近页面需求图书列表页最需要的三个能力我用 DRF 的过滤组件实现了from rest_framework import filters class BookViewSet(ModelViewSet): filter_backends [filters.SearchFilter, filters.OrderingFilter] search_fields [title, author, isbn] ordering_fields [price, created_at]search_fields一配前端请求GET /api/books/?search考研数学Django 就会在书名、作者、ISBN 三个字段里做模糊匹配完全不用自己写Q查询。除了基础的 CRUD业务里还有几个特殊动作比如我发布的图书和我收藏的图书。这两个接口用action装饰器在 ViewSet 里扩展比单独写视图更内聚from rest_framework.decorators import action from rest_framework.response import Response class BookViewSet(ModelViewSet): action(detailFalse, methods[get], permission_classes[IsAuthenticated]) def mine(self, request): books Book.objects.filter(sellerrequest.user) page self.paginate_queryset(books) serializer BookSerializer(page, manyTrue) return self.get_paginated_response(serializer.data) action(detailTrue, methods[get]) def similar(self, request, pkNone): book self.get_object() similar_books Book.objects.filter(categorybook.category, statuson_sale).exclude(idbook.id)[:6] serializer BookSerializer(similar_books, manyTrue) return Response(serializer.data)这样一来前端要的数据接口就齐了。我还配了一个收藏功能前端只需要调收藏列表接口和切换收藏状态的接口即可详情页右上角的星标按钮就靠它工作。4. Vue3 前端从创建项目到核心页面实现4.1 Composition API 和 Options API 怎么选后端接口齐了之后我开始写 Vue3 前端。这时第一个绕不开的问题就是 Composition API 和 Options API 选哪个。热搜词里vue3 composition api 和 option api排在前面是有原因的这是每个 Vue3 初学者都要面对的第一个选择题。我的结论是新项目直接用 Composition API尤其是script setup语法。为什么因为这个项目的页面逻辑并不简单——图书列表页需要请求数据、处理分页、处理搜索详情页需要请求图书详情、判断登录状态、切换收藏订单中心需要按状态切换列表。如果用 Options API每个逻辑单元的数据、方法、生命周期钩子会被拆到不同的代码块里页面一复杂跳来跳去很难受。用 Composition API 的script setup同一段业务逻辑可以聚在一起script setup import { ref, onMounted } from vue import { getBookDetail } from /api/book const book ref(null) const loading ref(false) const fetchDetail async () { loading.value true try { book.value await getBookDetail(props.id) } finally { loading.value false } } onMounted(fetchDetail) /script数据、加载状态、请求逻辑都在同一块阅读和修改的成本都低。而且数据刷新逻辑可以自然定义为fetchDetail函数需要重新加载时直接调用一次即可不需要像 Options API 那样绕方法名。4.2 ref 和 reactive 的正确使用姿势写 Vue3 时ref 万能对象这个说法很流行但我实际用下来发现它在引用场景下确实很方便——ref内部本身就是用reactive实现的你传一个对象进去它还是会被转换成响应式代理对象。不过这不代表ref可以无脑替代reactive。我的选择标准很简单基础类型、需要模板里用v-model绑定的值、需要整个替换的值用ref。深层嵌套的对象、一组相互关联的字段用reactive。比如图书详情页一份图书数据有书名、价格、作者、描述等十几个字段我会直接写script setup import { reactive } from vue const form reactive({ title: , author: , publisher: , price: 0, original_price: 0, condition: good, description: })这样在模板里写form.title、form.author数据结构跟后端字段一一对应语义清晰。如果这里用ref模板里写form.title尚且可以但一旦你写form.value.title代码就会很啰嗦还容易漏掉.value。列表页的分页参数则用refconst page ref(1) const total ref(0) const bookList ref([])翻页时直接给page.value赋值简洁且不会出错。用reactive的话会出现state.page区别不大但多包一层总是更绕。一个容易踩的小坑用reactive赋值整个对象时Object.assign会丢失响应性吗不会但要小心直接替换引用比如state newState那会丢掉响应式代理。正确写法是展开合并Object.assign(state, newState)4.3 接口层封装与 axios 拦截器Vue 页面里不要到处请求接口。我习惯建一个src/api/目录每个业务模块一个文件比如book.js、order.js、user.js。这样页面只调函数不直接碰 axios管理起来非常清楚。axios 实例的封装这点很重要我贴一下核心代码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 }) // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(access_token) router.push(/login) } ElMessage.error(error.response?.data?.detail || 请求失败) return Promise.reject(error) } )baseURL: /api配合 Vite 代理开发环境请求/api/books会被自动转发到 Django 的 8000 端口避免跨域问题的同时还可以在请求头里开心地带上 cookie。生产环境部署时再由 Nginx 做同样的事。401 拦截的逻辑很关键token 过期后后端的响应状态码是 401axios 拦截器统一清理本地 token 并跳回登录页。这样前端不用在每个请求方法里写重复的过期处理。4.4 核心页面拆解列表、详情、发布、订单中心图书列表页是这个项目的前端核心页面。结构上顶部一个搜索框下面一列图书卡片底部一个分页器。搜索逻辑要防抖不能每次敲一个字母就发一次请求。我用了一个简单的 300ms 防抖函数import { ref, watch } from vue const searchText ref() const bookList ref([]) watch(searchText, debounce(() { fetchBooks() }, 300))这个防抖很必要不然用户输入考研数学四个字的过程中会触发四次不必要的请求。详情页的数据加载要注意竞态用户快速在列表页点击进入详情页后接口还没返回又返回了列表页这时响应回来可能已经污染了页面。我用了AbortController或者简单地用详情 id 是否改变来判断是否应用这次响应从根本上避免旧请求覆盖新页面。发布图书页的重点是表单校验。价格输入我用InputNumber限制最小值为 0.01封面图上传用el-upload上传前设置auto-upload: false等用户点提交时再把文件放进FormData一起 POST。这样用户可以先预览填好的信息不会因为选一张图就立刻上传省了很多传完后悔的流量浪费。订单中心用的则是 Tab 切换方式按状态分待处理、已成交、已取消三个页签。每个 Tab 对应一个订单列表接口切换时重新拉数据。这里要注意订单列表接口要同时展示买家身份或卖家身份的相关字段所以我前端的订单卡片上同时显示了书名、价格、交易对象用户名、下单时间方便买卖双方确认。4.5 Element Plus 按需引入Element Plus 是 Vue3 生态里最好上手的组件库但我一开始全量引入打包体积直接超过 1MB首屏加载很吃力。后来改成按需引入用官方推荐的unplugin-vue-components和unplugin-auto-import自动按需加载配置是在vite.config.js里加插件然后组件就可以在模板里直接写不用手动 import体积也降到了 400KB 左右。这个优化对提升用户体验很明显建议做正式项目时直接用按需引入。5. 前后端联调中的实战问题跨域、图片、token 过期与部署检查5.1 CORS 配置开发环境的真实误区前后端分离开发时跨域是第一个绕不开的问题。很多新手直接给django-cors-headers全放行CORS_ALLOW_ALL_ORIGINS True然后发现确实能通了就放着不管。但这样配置在生产环境等于裸奔任何人都可以用其他域名来请求你的接口。我的做法是开发环境明确指定 Vite 的地址生产环境指定线上域名CORS_ALLOWED_ORIGINS [ http://localhost:5173, http://127.0.0.1:5173, https://yourdomain.com, ]另外CORS_ALLOW_CREDENTIALS True这个配置要看情况开。如果你用 JWT 存 localStorage其实不需要启用 cookies 跨域这个开关可以关掉。开了它反而会遇到不允许携带凭据的跨域请求之类的困扰。还有一个小坑django-cors-headers必须在MIDDLEWARE里放在尽量靠前的位置而且要放在CommonMiddleware前面否则某些请求头根本不会被处理到。我的MIDDLEWARE里把corsheaders.middleware.CorsMiddleware放到了最顶上。5.2 图片上传与静态文件开发与生产两条路图片上传在后端需要两处配置MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media然后在项目的urls.py里开发阶段用 Django 自带的静态服务from django.conf import settings from django.conf.urls.static import static if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)这个配置只适合 DEBUG 模式。生产环境我用了 Nginx 直接处理/media/路径下的文件Django 只负责接收上传、写入磁盘、把 URL 存数据库Nginx 负责把图片文件吐给浏览器。这样做的原因是Django 处理静态资源的能力本来就很弱并发一高就卡没必要让它干这个活。前台上传图片时还有个费时间的坑如果用 axios 默认的Content-Type: application/json后端收到的request.FILES一定是空的。必须自己组装FormData并且不要手动设置 Content-Type让浏览器自动带上 boundaryconst formData new FormData() formData.append(cover, file) formData.append(title, form.title) // 其他字段 const response await request.post(/books/, formData)如果手动设置Content-Type: application/json文件流就废了。5.3 路由守卫与登录态前端如何配合 JWT订单中心、发布图书页这些需要登录才能访问。我在 Vue Router 的meta里加了requiresAuth标记然后在全局前置守卫里统一判断router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (to.matched.some(record record.meta.requiresAuth) !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })登录成功之后跳回redirect参数指定的地址用户不会因为登录就丢失原本想去的页面这个细节体验提升很大。还要注意的一点是localStorage.getItem(access_token)不能作为判断登录的唯一依据因为 token 可能在过期后还躺在本地。所以需要在前端拦截器里配合 401 处理逻辑请求返回 401 时清掉 token 并跳转登录页这样即使用户本地有 token但已经失效了也会被正确引导到重新登录。5.4 上线部署前的检查清单项目全部做完后部署上线前我踩着坑整理了一份检查清单在这里直接分享DEBUG必须改成False不然错误信息会直接暴露给访问者里面可能包含服务器路径和数据库信息执行python manage.py collectstatic把静态文件收集到指定目录否则 Django 管理后台样式会丢失数据库备份python manage.py dumpdata或者直接用 PostgreSQL 的pg_dump上线前备份一次防止中途出问题跨域配置改成生产域名CORS_ALLOW_ALL_ORIGINS一定要关用gunicorn跑 Django 服务而不是runserverrunserver是开发服务器性能和并发处理能力都不够Nginx 反向代理到 gunicorn同时处理/media/静态文件前端npm run build打包后把dist目录放到 Nginx 静态目录下并配置proxy_pass把/api/请求转发到 gunicorn。这份清单在我后面的好几个项目里都还在用每次部署前过一遍能少踩很多不必要的坑。回到最开始的问题为什么我说这个项目值得完整做一遍因为二手图书交易系统本质上是一套业务 认证 静态资源 部署的全链路闭环。你做的虽然是图书但登录、发布、订单、状态流转这些逻辑换一个电商场景依然适用。而且 Django 和 Vue3 都是现在 Web 开发里需求很大的技术栈做完一个能跑通的项目比刷一百道题都有用。最后分享一个小技巧开发这种前后端分离项目时前端先把所有接口注释掉用 mock 数据把页面和交互调通再接真接口。这样你能快速分清一个 bug 到底是前端问题还是后端问题联调阶段会轻松非常多。我在这个项目里就是用这个方式把联调时间压缩到了两天。如果你的项目时间紧强烈建议也试试这个办法。
返回列表