先说个真实背景。我去年年底动手做这个二手图书交易系统,起因很朴素——学校课程群里每天都有学长学姐刷屏卖考研资料和教材,几十条消息翻下来,不是被一句"出了"截胡,就是根本分不清谁在卖哪本。图书这种低频买卖,用聊天流去做效率实在太低。于是我想自己做一个能搜、能筛、能下单的校园二手书平台,后端接口用 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 SimpleJWT(JSON 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_length=200, verbose_name='书名') author = models.CharField(max_length=100, verbose_name='作者') publisher = models.CharField(max_length=200, blank=True, verbose_name='出版社') isbn = models.CharField(max_length=20, blank=True, verbose_name='ISBN') price = models.DecimalField(max_digits=7, decimal_places=2, verbose_name='售价') original_price = models.DecimalField(max_digits=7, decimal_places=2, blank=True, null=True, verbose_name='原价') condition = models.CharField(max_length=20, choices=CONDITION_CHOICES, default='good', verbose_name='新旧程度') description = models.TextField(blank=True, verbose_name='描述') cover = models.ImageField(upload_to='covers/%Y/%m/', blank=True, verbose_name='封面图') seller = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='books', verbose_name='卖家') status = models.CharField(max_length=20, choices=[('on_sale', '在售'), ('sold', '已售出'), ('off_shelf', '下架')], default='on_sale') created_at = models.DateTimeField(auto_now_add=True)字段设计有几个关键判断:
价格用DecimalField而不是FloatField,因为浮点数在计算机里是二进制的,会出现 0.1 + 0.2 不等于 0.3 的问题,涉及钱必须用定点数。
on_delete=models.CASCADE表示卖家账号删除时,他发布的图书也一起删除。但在订单表里我用了PROTECT,因为订单属于交易凭证,不能因为买家或卖家删号就悄悄消失。
related_name='books'是给反向查询用的,设置后可以用seller.books.all()查某人发布的全部图书,不用再写Book.objects.filter(seller=xxx),省事很多。
订单表里我加了一个order_no字段,用 UUID 生成唯一订单号。这个字段在人工对账的时候特别好用,因为自增主键很容易暴露订单量,而且不直观。
2.4 ORM 查询与删除对象:几个容易翻车的细节
关于"django 执行查询-删除对象",网上一搜能出来一堆问答,但我想分享几个自己真实踩过的坑。
第一个坑是get和filter的混用。Book.objects.get(id=xxx)在记录不存在时会抛DoesNotExist异常,如果不在视图里捕获,前端就会收到 500。更稳妥的写法是:
book = Book.objects.filter(id=book_id).first() if not book: return Response({'detail': '图书不存在'}, status=status.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 把业务闭环变成可用的 API
3.1 序列化器设计:嵌套展示与写入校验
模型建好之后,接下来是用 Django REST Framework 把它变成 API。我理解的 DRF 核心价值其实就一句话:把模型数据转换成 JSON 给前端用,同时把前端传回的 JSON 校验后写进数据库。
图书序列化器我这样写的:
class BookSerializer(serializers.ModelSerializer): seller_name = serializers.CharField(source='seller.username', read_only=True) condition_display = serializers.CharField(source='get_condition_display', read_only=True) 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,前端还得再请求一次用户接口才能拿到名字。用source='seller.username'直接在序列化的时候取关联字段,前端拿到的数据就是完整的。
二是read_only_fields。seller字段不能在创建时由前端随意指定,否则用户可以伪造卖家身份,必须由后端从当前登录用户中自动填入。status也不该由用户传,上架默认是'on_sale'。
创建图书时,我在 ViewSet 的perform_create里写:
def perform_create(self, serializer): serializer.save(seller=self.request.user)这样卖家身份始终来自 token,而不是来自请求体。
3.2 ViewSet 与路由:用最少的代码覆盖最多的接口
DRF 的ModelViewSet是效率利器。一套完整的图书增删改查接口,用普通视图至少要写 8 个函数,用 ViewSet 只需要继承出来然后注册即可:
from rest_framework.viewsets import ModelViewSet class BookViewSet(ModelViewSet): queryset = Book.objects.filter(status='on_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(r'books', BookViewSet, basename='book') 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 Session,JWT 的好处是无状态,后端不需要存 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(), name='token_obtain_pair'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_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(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) def mine(self, request): books = Book.objects.filter(seller=request.user) page = self.paginate_queryset(books) serializer = BookSerializer(page, many=True) return self.get_paginated_response(serializer.data) @action(detail=True, methods=['get']) def similar(self, request, pk=None): book = self.get_object() similar_books = Book.objects.filter(category=book.category, status='on_sale').exclude(id=book.id)[:6] serializer = BookSerializer(similar_books, many=True) 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。
列表页的分页参数则用ref:
const 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 过期后,后端的响应状态码是 401,axios 拦截器统一清理本地 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_root=settings.MEDIA_ROOT)这个配置只适合 DEBUG 模式。生产环境我用了 Nginx 直接处理/media/路径下的文件,Django 只负责接收上传、写入磁盘、把 URL 存数据库,Nginx 负责把图片文件吐给浏览器。这样做的原因是,Django 处理静态资源的能力本来就很弱,并发一高就卡,没必要让它干这个活。
前台上传图片时还有个费时间的坑:如果用 axios 默认的Content-Type: application/json,后端收到的request.FILES一定是空的。必须自己组装FormData,并且不要手动设置 Content-Type,让浏览器自动带上 boundary:
const 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 服务而不是runserver,runserver是开发服务器,性能和并发处理能力都不够;Nginx 反向代理到 gunicorn,同时处理/media/静态文件; - 前端
npm run build打包后,把dist目录放到 Nginx 静态目录下,并配置proxy_pass把/api/请求转发到 gunicorn。
这份清单在我后面的好几个项目里都还在用,每次部署前过一遍,能少踩很多不必要的坑。
回到最开始的问题:为什么我说这个项目值得完整做一遍?因为二手图书交易系统本质上是一套"业务 + 认证 + 静态资源 + 部署"的全链路闭环。你做的虽然是图书,但登录、发布、订单、状态流转这些逻辑,换一个电商场景依然适用。而且 Django 和 Vue3 都是现在 Web 开发里需求很大的技术栈,做完一个能跑通的项目,比刷一百道题都有用。
最后分享一个小技巧:开发这种前后端分离项目时,前端先把所有接口注释掉,用 mock 数据把页面和交互调通,再接真接口。这样你能快速分清一个 bug 到底是前端问题还是后端问题,联调阶段会轻松非常多。我在这个项目里就是用这个方式,把联调时间压缩到了两天。如果你的项目时间紧,强烈建议也试试这个办法。