十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Vue3+Django实现前后端分离二手车销售平台开发全记录

Vue3+Django实现前后端分离二手车销售平台开发全记录 最近帮朋友把一个二手汽车销售平台的毕设项目完整跑通从技术选型、数据库建表到前后端页面再到部署上线整条链路走下来踩了不少坑。这篇就把基于VuePython的二手汽车销售平台从0到1的实现过程拆开讲清楚——后端用Django REST Framework写API前端用Vue 3做界面包括用户登录、车辆发布、搜索筛选、收藏咨询、订单流程这些核心模块也聊聊部署时遇到的问题。正在做类似系统设计、或者想了解前后端分离项目完整闭环的开发者都可以拿这篇做参考。1. 技术栈选型VuePython组合在这个项目里赢在哪很多人在动手之前会纠结一个问题做这种平台类系统到底该用传统的服务端渲染还是上前后端分离我的建议很明确这种包含多角色、多页面、多交互状态的业务直接上前后端分离。二手车销售平台天然适合这个架构——浏览车辆、筛选条件、收藏咨询、发布管理都是典型的页面密集交互场景前端要频繁更新局部视图如果每次操作都走一次完整的页面刷新体验会很差。前后端分离之后前端Vue负责视图层和交互逻辑后端Python只对外提供JSON接口两边独立开发、独立调试、独立部署。后端不用关心页面长什么样前端也不用关心数据从哪张表来。项目做到后期如果还要加一个小程序端或者App端后端API可以直接复用不需要再写一套接口这个优势是服务端渲染给不了的。1.1 为什么这套架构适合这个业务从业务模型看二手汽车销售平台涉及三类角色普通用户买车、车主卖车、平台管理员审核和管理。这个多角色模型决定了后台管理页面审核车辆、统计订单、管理用户和前台展示页面车辆浏览、搜索、详情、下单在视觉和交互上完全不同频。用前后端分离等于把这两个端彻底隔离后台给管理员用前台给C端用户用彼此不互相影响。从Vue的定位看Vue适合做表单密集型应用二手车发布页面包含几十个字段品牌、车型、年份、里程、价格、车况描述、多图上传这类表单用Vue的双向绑定和表单校验写起来非常顺手。如果用jQuery操作DOM字段一多代码就疯了。从Python后端的定位看Python的框架生态里Django自带Admin后台和ORM做管理端几乎不用写代码。一个二手汽车平台的管理员端无非是看看车辆有没有违规信息、用户有没有异常操作Django Admin开箱即用省掉大量重复的增删改查页面开发时间。1.2 Django还是Flask这次为什么选Django后端Python框架社区争论最多的就是Django和Flask。其实两者不冲突但做这个项目我明确推荐Django。原因很实在Django自带Admin、ORM、认证体系、迁移工具四件套对一个平台类项目来说这四样每天都在用。Flask的优势是轻量灵活适合API服务或微服务但开发一个完整的销售平台你要自己集成ORM通常选SQLAlchemy、自己写Session或JWT认证、自己管理数据库迁移这些集成工作本身就是十几天的工期。而且在电商/交易类这种对数据一致性有要求的场景Django的ORM和事务管理做得很成熟比Flask体系下东拼西凑的方案稳定得多。Django 3.2是长期支持版到2024年都有官方维护我的建议是稳定优先选择Django 3.2 LTS或者更新版本的Django 4.x。搭配Django REST Framework写API的速度非常快——视图集、序列化器、分页、认证都是现成的模块接口代码量比手写Flask路由少一半。1.3 前端工程化Vue 3 Vite Element Plus的组合前端这边的选型直接赶新不赶旧Vue 3 Vite Element Plus Pinia Vue Router。Vite比Webpack的启动速度和热更新体验好一个数量级开发阶段几乎不用等编译。Element Plus是Element UI的Vue 3版本后台管理类界面直接套组件花不了多少时间。状态管理这里Vue 3项目热词里经常有人问Pinia还是Vuex我的结论是直接用Pinia。Pinia的API更简洁TS支持更好而且Vue官方已经明确Pinia是下一代状态管理方案。这个项目里Pinia主要存用户登录信息和全局状态代码量不大但用Pinia写起来明显比Vuex清爽。还有一个很多人忽略的细节Node版本。Vite 4要求Node 14.18Vite 5要求Node 18如果你在安装依赖时一直报错大概率是Node版本太老。建议直接用nvm管理Node版本把Node切到18或20再用。技术栈定下来了整体架构和分工也明确了下面从数据库开始讲实现细节。2. 数据库表结构设计5张核心表把平台跑起来很多初学同学拿到项目第一件事就是写页面写到一半发现数据没法串起来回过头再改表结构这样特别浪费时间。我这次先花半天时间把表结构设计好后面接口和页面基本是一路顺风。二手车销售平台的表结构比普通CMS要复杂一些因为涉及交易状态和多方关联但核心就是5张表用户表、车辆表、收藏表、留言表、订单表。2.1 用户表和车辆表主体信息怎么落库用户表不要直接改Django自带的User表而是建一张独立用户表用一对一外键关联Django自带的User做认证扩展这样既保留Django认证体系的稳定性又能加手机号、头像、角色这些业务字段。车辆表是这个平台的核心表字段要一次想清楚。我在做的时候把车辆信息拆成基础字段和描述字段基础字段包括标题、品牌、车型、上牌年份、行驶里程、价格、颜色、变速箱类型、燃油类型描述字段就是详细的图文车况说明。这样列表页展示和详情页展示用的是不同粒度的数据列表页只查基础字段详情页再加载完整描述。车辆状态这个字段很重要我用了IntegerField存四种状态0审核中、1在售、2已售、3已下架。为什么不直接用BooleanField存上架/下架因为车主发布车辆后管理员要先审核通过后才是在售状态交易完成后是已售车主自己可以主动下架四态流转在业务上缺一不可。用整数存状态还有一层好处以后加新状态不需要改表结构。# models.py 车辆模型核心字段 class Car(models.Model): STATUS_CHOICES ( (0, 审核中), (1, 在售), (2, 已售), (3, 已下架), ) STATUS_CLOSED 2 # 交易闭环状态 GEARBOX_CHOICES ( (manual, 手动挡), (auto, 自动挡), ) FUEL_CHOICES ( (gasoline, 汽油), (diesel, 柴油), (electric, 纯电), (hybrid, 混动), ) title models.CharField(max_length100, verbose_name标题) brand models.CharField(max_length30, verbose_name品牌) model models.CharField(max_length50, verbose_name车型) year models.IntegerField(verbose_name上牌年份) mileage models.FloatField(verbose_name行驶里程(万公里)) price models.DecimalField(max_digits10, decimal_places2, verbose_name售价) color models.CharField(max_length20, verbose_name颜色) gearbox models.CharField(max_length10, choicesGEARBOX_CHOICES, verbose_name变速箱) fuel_type models.CharField(max_length10, choicesFUEL_CHOICES, verbose_name燃油类型) description models.TextField(verbose_name车况描述) cover_image models.CharField(max_length200, verbose_name封面图URL) images models.JSONField(defaultlist, verbose_name图集URL列表) seller models.ForeignKey(User, on_deletemodels.CASCADE, related_namecars, verbose_name卖家) status models.IntegerField(choicesSTATUS_CHOICES, default0, verbose_name状态) view_count models.IntegerField(default0, verbose_name浏览量) create_time models.DateTimeField(auto_now_addTrue, verbose_name发布时间)车辆图片这里我存的不是二进制而是图片URL列表。这个设计有讲究图片文件上传到服务器指定目录数据库只存路径。这样做的好处有三个数据库体量小、读性能好、图片可以单独挂CDN加速。列表页只需要封面图URL做卡片展示详情页才需要完整图集所以封面图和图集分开存储查询接口的返回体量差别很大。2.2 收藏、留言、订单关联表与状态流转收藏表最简单就是一个中间表存用户和车辆的多对多关系。需要注意加一个唯一约束防止同一用户重复收藏同一辆车。留言表用来做买家与卖家的沟通相当于轻量级站内信除了留言内容还要有个回复字段卖家看到留言后在前端直接回复不需要再跳到聊天窗口。订单表是交易闭环的关键。二手车的订单和普通电商不一样通常不走在线支付而是线上预约看车、线下成交。所以订单表里有几个状态已下单、已看车、已成交、已取消。状态变了之后车辆状态也要跟着变——订单成交后车辆自动变成已售这时候不能再接收新订单。关联表之间的外键关系我建议加related_name查询代码会省很多事。比如Car表里外键seller models.ForeignKey(User, related_namecars)这样查某个用户发布了哪些车直接user.cars.all()就行不用再写一堆filter。这个细节看似不起眼但查询代码写多了会发现真的省不少时间。这里给一个实用经验表设计时把查询非常频繁的字段加上db_indexTrue索引。车辆表的品牌、状态、价格这三个字段在列表筛选时几乎每次都会带上索引能明显提升查询速度。初期数据量小可能感觉不到等车辆数据量过万有没有索引的查询耗时差距能到10倍以上。3. 后端API实现DRF框架下的接口契约与核心逻辑表结构定好了后端接口基本就是围绕这5张表做增删改查。但写API不是简单地建一个序列化器就完事认证方式、权限控制、筛选分页、图片上传这些环节都要提前想清楚。DRF这套框架把大部分底层工作做了但业务逻辑还是得自己组织清楚。3.1 接口清单与认证方式先列一下这个项目的API清单大家在设计自己的系统时也可以对照这个粒度来衡量接口划分是否清晰模块方法路径说明权限认证POST/api/auth/register用户注册公开认证POST/api/auth/login登录获取Token公开车辆GET/api/cars车辆列表(筛选分页)公开车辆GET/api/cars/{id}车辆详情公开车辆POST/api/cars发布车辆登录用户车辆PUT/api/cars/{id}更新车辆本人/管理员车辆DELETE/api/cars/{id}下架删除车辆本人/管理员收藏GET/api/favorites我的收藏列表登录用户收藏POST/api/favorites添加收藏登录用户收藏DELETE/api/favorites/{id}取消收藏本人留言GET/api/messages?car_id指定车辆的留言登录用户留言POST/api/messages发表留言登录用户订单POST/api/orders创建订单登录用户订单GET/api/orders/mine我的订单(买/卖)登录用户认证方式我选的是DRF自带的TokenAuthentication原因是在前后端分离场景里CookieSession方案会遇到CSRF和跨域携带Cookie的麻烦而JWT虽然功能更多但对于二手交易平台这种单一前端、单一后端的项目来说有点过度设计。Token认证就好在简单直接登录成功后服务端返回一个Token前端存到localStorage每次请求头带上Authorization: Token xxxxx后端验证通过就放行。权限控制用DRF的permission类来搞。基础权限分三层游客只能看车和搜索登录用户可以收藏、留言、下订单发布车辆的用户和管理员才能改车。DRF的IsAuthenticatedOrReadOnly加上自定义的IsOwnerOrReadOnly就能覆盖绝大多数场景。3.2 车辆列表的搜索筛选与分页实现二手车平台最核心的用户体验就是筛选。用户通常会按品牌、价格区间、里程、变速箱、燃油类型这些条件组合搜索。这个接口我第一次做的时候把条件全部写死在视图里后来加了几个条件越来越难维护看到好一点的实现是专门建一个filter类来处理搜索逻辑。# views.py 车辆列表核心逻辑 class CarListView(generics.ListCreateAPIView): serializer_class CarListSerializer permission_classes [IsAuthenticatedOrReadOnly] pagination_class StandardResultsSetPagination def get_queryset(self): queryset Car.objects.filter(status1).select_related(seller) params self.request.query_params keyword params.get(keyword) if keyword: queryset queryset.filter( Q(title__icontainskeyword) | Q(description__icontainskeyword) | Q(brand__icontainskeyword) ) brand params.get(brand) if brand: queryset queryset.filter(brandbrand) min_price params.get(min_price) max_price params.get(max_price) if min_price: queryset queryset.filter(price__gtemin_price) if max_price: queryset queryset.filter(price__ltemax_price) min_mileage params.get(min_mileage) max_mileage params.get(max_mileage) if min_mileage: queryset queryset.filter(mileage__gtemin_mileage) if max_mileage: queryset queryset.filter(mileage__ltemax_mileage) sort params.get(sort, newest) if sort price_asc: queryset queryset.order_by(price) elif sort price_desc: queryset queryset.order_by(-price) elif sort mileage_asc: queryset queryset.order_by(mileage) else: queryset queryset.order_by(-create_time) return queryset分页配置这里不建议每页返回所有数据尤其车辆列表页数据量大。我在settings里统一配置了DRF的分页类每页默认12条前端拿到count、next、previous这个标准响应结构。列表页滚动加载或者分页按钮直接用这几个字段控制不需要额外约定。字段序列化的时候有个细节列表页不要返回大段的description文本这个字段动辄几百上千字列表页根本用不上返回了只会白白增加网络传输。列表序列化器只保留标题、封面图、品牌、价格、年份、里程这几个字段就行详情页用另一个序列化器去返回完整信息。3.3 图片上传的两种方案和处理细节车辆发布页面需要上传多张图片后台上传接口处理不好很容易踩坑。我试过两种方案第一种是直接把base64字符串放到JSON里提交好处是接口简单坏处是图片稍微大一点HTTP包就非常臃肿服务器处理也慢不推荐。第二种是标准的multipart/form-data文件上传用FormData把图片文件和业务字段一起提交或者先单独传图再提交表单我最终用的是先传图、后提交表单的方案。先传图的好处是图片上传状态可以单独在前端做进度条和预览提交表单失败的时候图片已经在服务器上了用户不需要重新传。坏处是会残留垃圾图片所以要在上传接口里做两层限制文件类型白名单jpg、png、webp等和文件大小限制单张不超过5MB另外定期清理一下上传目录里没有被车辆记录引用的孤儿图片。后端用DRF的APIView接收request.FILES注意文件名的处理不要用用户上传的原始文件名直接落盘那样既容易冲突又有安全隐患。我用时间戳加随机串重命名后缀保留原扩展名这样文件名基本不会重复。# uploads.py 图片上传接口核心片段 import time import uuid import os from django.core.files.storage import default_storage def handle_upload(file, subdircars): ext os.path.splitext(file.name)[-1].lower() if ext not in [.jpg, .jpeg, .png, .gif, .webp]: raise ValueError(不支持的图片格式) if file.size 5 * 1024 * 1024: raise ValueError(图片不能超过5MB) filename f{int(time.time())}_{uuid.uuid4().hex[:8]}{ext} path default_storage.save(f{subdir}/{filename}, file) return pathDjango开发环境里要确认MEDIA_URL和MEDIA_ROOT配置正确然后在项目的urls.py里加上一行re_path(r^media/(?Ppath.*)$, serve, {document_root: settings.MEDIA_ROOT})这样开发环境下图片才能通过URL访问到。生产环境这个任务交给Nginx后面部署章节会详细说。4. Vue前端落地从路由守卫到车辆筛选交互后端接口调通之后前端主要任务就是把接口串起来变成一个完整的用户界面。Vue这个项目的页面结构是登录注册页、首页、车辆列表页、车辆详情页、发布车辆页、个人中心页、我的收藏页、我的留言页。每个页面其实都不复杂但把路由、状态管理、请求封装这些基础设施搭好页面开发就很快。4.1 路由设计与登录守卫前端路由设计要跟后端接口的分层保持对应但不需要一一映射。我用的history模式路由分成公开路由和需要登录的路由通过meta.requiresAuth标记。访问需要登录的页面比如发布车辆、个人中心如果用户没有token前端直接跳去登录页。这里很多人会问为什么还要做前端路由守卫后端不已经有权限验证了吗答案是双保险和更好的用户体验——后端返回401用户已经做了一次操作前端提前拦截则直接在跳转前就阻止了体验上更顺滑。// router/index.js const routes [ { path: /, component: () import(/views/Home.vue) }, { path: /cars, component: () import(/views/CarList.vue) }, { path: /cars/:id, component: () import(/views/CarDetail.vue) }, { path: /login, component: () import(/views/Login.vue) }, { path: /register, component: () import(/views/Register.vue) }, { path: /publish, component: () import(/views/Publish.vue), meta: { requiresAuth: true } }, { path: /profile, component: () import(/views/Profile.vue), meta: { requiresAuth: true } }, { path: /favorites, component: () import(/views/Favorites.vue), meta: { requiresAuth: true } }, ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这里加了query: { redirect: to.fullPath }用户登录成功后会跳回之前想去的页面这个小细节体验提升很大。登录页登录成功之后从route.query.redirect取地址有就跳过去没有就跳首页。4.2 Axios封装与token管理前端的所有请求都走同一个Axios实例把baseURL、请求拦截器、响应拦截器统一管理。请求拦截器主要做一件事从localStorage取token存在就加到请求头的Authorization字段。响应拦截器做的事情多一些401跳登录页并清除本地token其他错误弹出提示并打印日志。baseURL这里有个坑要提前说开发环境和生产环境的API地址不一样不要把地址写死。我用Vite的环境变量开发环境通过VITE_API_BASE_URL配置为/api配合代理转发到本机8000端口生产环境配置为服务器API域名路径。环境变量文件分开放在.env.development和.env.production里打包的时候自动读取对应环境的配置。// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000, }) 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 error.response.status 401) { localStorage.removeItem(token) localStorage.removeItem(userInfo) router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) ElMessage.error(登录已过期请重新登录) } else { const msg error.response?.data?.detail || error.message || 请求失败 ElMessage.error(msg) } return Promise.reject(error) } )4.3 车辆列表页和详情页的交互细节车辆列表页是整个前端交互最重的一页。左侧放筛选栏包含品牌下拉框、价格区间、里程区间、变速箱类型、燃油类型顶部是关键词搜索框和排序下拉框。筛选值变化后前端重新调用车辆列表接口把筛选参数拼到query里再请求。这里要注意防抖用户拖动价格滑块时不要每次都发请求等用户松手后再请求关键词搜索输入框则是等300毫秒没有新输入再请求。分页交互上我建议是用加载更多而不是传统的分页按钮。原因很简单汽车列表页用户会持续滚动浏览多辆车加载更多比较自然传统分页按钮对移动端也不太友好。实现时记录当前页码点击加载更多时页码加1把返回的新数据拼接到现有列表后面。Vue路由参数跳转详情页的时候用router.push({ path:/cars/${id}})直接把车ID放进路由参数里这样详情页刷新后还能取到参数搜索结果也不会丢。如果有些页面状态比较多也可以用query传参但路径参数更符合详情页的语义。热门问题里专门有vue路由参数这个词主要就是区分query和params在刷新页面时的差异——params如果通过name方式传参刷新后会丢失写在路径上的params参数刷新后依然存在。车辆详情页的结构是左图右信息左侧用轮播图展示车辆图集右侧是标题、价格、核心参数、卖家信息、收藏按钮和预约看车按钮。页面下方是详细描述和留言区。进入详情页时调用详情接口接口返回后要同步把浏览量1这个加1的操作我用的是detail接口里直接view_count F(view_count) 1避免并发场景下两个请求同时读到旧值再写回导致计数丢失。4.4 车辆发布表单的校验与提交车辆发布表单是这个项目字段最多的表单包括品牌、车型、上牌年份、里程、价格、颜色、变速箱、燃油类型、封面图、图集、车况描述。Element Plus的el-form提供了校验规则我按照每个字段的约束条件配置好后用户提交时会有实时反馈比如年份必须在1950到今年之间、价格大于0但不能超过500万、里程不能为负数。图片上传组件这块用Element Plus的el-upload组件设置action为后台上传接口或者在http-request里自己写上传逻辑。上传成功后就拿到图片URL存到表单的images数组里。这里需要处理一个常见问题el-upload默认在文件选中后立刻上传所以上传成功回调里要把返回的URL收集起来最后跟其他字段一起提交给发布车辆接口。表单提交成功之后后端返回的车辆状态是审核中前端提示用户信息已提交审核通过后会展示在车辆列表并跳转到个人中心的我的发布页面。这个流程要给用户讲清楚否则用户以为自己发布失败了反复提交会产生一堆重复数据。5. 前后端联调与服务器部署本地跑通只是第一步项目在本机能跑起来只是开始真正能给别人用还得走完联调和部署这最后一段路。前后端分离项目的联调核心是解决跨域问题部署上线核心是让Nginx把前端静态页面、后端API、媒体文件三部分请求合理分流。这两个环节的坑不少我这次也是一步步趟过来的。5.1 开发环境Vite代理怎么解决跨域开发阶段最烦人的就是跨域。浏览器访问Vite的5173端口前端Ajax请求Django的8000端口两个端口不同就触发了浏览器的同源策略请求发出去但响应被浏览器拦截。解决这个问题有两条路后端配CORS中间件放行或者前端配Vite开发代理。我实际开发时两条路都配了。Vite的proxy配置让前端所有/api开头的请求都转发到8000端口但请求头里的Host保持为5173这样在开发环境下看起来就像同源请求。这个配置一天能省好几次开着F12看CORS报错的功夫。// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, }, /media: { target: http://127.0.0.1:8000, changeOrigin: true, } } } })生产环境也用同一套逻辑Nginx把/api开头的请求反代到后端的8000端口把/media开头的请求指向静态文件目录其余请求全部指向Vue打包后的dist目录。这样整个系统就一个域名不存在跨域问题也省掉配置CORS的麻烦。5.2 生产环境Nginx gunicorn的部署编排Django生产环境不能直接用runserver跑性能太差也不安全。我用gunicorn作为WSGI服务器Django项目静态文件收集到指定目录前端构建产物放在另一个目录然后统一由Nginx做反向代理。部署时最大的坑其实是两个路径配置前端打包后的base路径和后端静态文件的STATIC_ROOT。Vue项目默认是相对路径或者根路径如果部署在服务器域名的根路径下base不需要动但如果部署在子目录比如/admin/下就必须在vite.config.js里设置base: /子目录/否则打包后js和css的路径全是错的页面会白屏或者样式全丢。Nginx配置给出来大家可以直接参考主要关注三块location / 处理前端路由必须配try_files否则刷新页面会404、location /api/ 反代后端、location /media/ 处理用户上传的图片。server { listen 80; server_name your-domain.com; root /var/www/car_platform/dist; index index.html; # 前端路由history模式刷新页面不404 location / { 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 /var/www/car_platform/media/; } # 前端打包后的静态资源需要长缓存 location /assets/ { expires 30d; add_header Cache-Control public, no-transform; } }部署步骤整理一下先在服务器上装好Python环境、MySQL、Nginx、git把后端代码clone到服务器创建虚拟环境pip install -r requirements.txt执行数据库迁移和静态文件收集安装gunicorn用systemd托管启动前端代码构建好后把dist目录放到Nginx指定路径检查Nginx配置并reload。顺序不能乱先让后端接口能通再配前端静态页面。6. 踩坑实录图片上传、时区、N1查询这几个坑我记到现在这个项目做下来最耗时间的不是功能开发而是排坑。有些报错一眼就能看出来有些问题真的让我调试到半夜。这里挑四个最有代表性的问题把完整的排查过程和解决方案写出来希望能帮大家少走弯路。6.1 图片上传后接口报400Content-Type的坑前端用Axios传FormData的时候我一开始按照传统方式在请求配置里手动设置了headers: { Content-Type: multipart/form-data }结果后端一直返回400说找不到文件字段。排查了很久网络请求看下来Content-Type确实带了但少了一个关键的boundary参数。问题的根源是浏览器在发送multipart请求时会自动生成一个boundary字符串用来分隔文件内容这个boundary的格式是boundary----WebKitFormBoundaryxxx。如果自己手动设置Content-Type等于把浏览器的默认行为覆盖了boundary丢失服务端无法解析数据边界自然读不到文件。正确的做法是让Axios或者浏览器自动处理Content-Type不要在header里手动设置。axios判断数据是FormData类型时会自己带上正确的Content-Type和boundary。如果用了http-request自定义上传在请求方法里也一定不要动headers里的Content-Type字段。6.2 前端时间显示差了8小时Django时区问题车辆列表里显示发布时间前端拉到的数据总是比实际时间少8小时。查了数据库发现时间字段存进去的是UTC时间而本地是东八区。这个问题的根源在Django的USE_TZ配置。当USE_TZTrue时Django在数据库里存的是UTC时间取出时自动转成UTC返回Django模板渲染时会根据TIME_ZONE设置转成当前时区但DRF的JSON序列化没有走模板渲染直接序列化了UTC时间。解决方法是两处配合在settings.py里设置TIME_ZONE Asia/Shanghai和USE_TZ False直接让Django使用本地时间简单粗暴但也最稳妥。如果一定想用USE_TZTrue多区域部署场景才需要考虑那就在DRF的序列化器里用read_onlyTrue搭配DateTimeField(format%Y-%m-%d %H:%M:%S)做显式格式化或者在后端返回前统一转成字符串前端不要去做任何时间转换。6.3 接口越调越慢ORM的N1查询陷阱车辆列表接口刚写完时只有几条数据速度飞快。后来测试数据加到几百条接口响应时间明显变慢。排查发现每次查列表主查询只有一条SQL但循环返回车辆数据时每个车辆的卖家信息都要再去数据库查一次总共执行了几百条SQL这就是典型的N1查询问题。解决方法是Django ORM的select_related和prefetch_related。select_related适用于一对一、多对一这种正向外键关系会在主查询里用JOIN把关联表数据提前取出来prefetch_related适用于多对多、反向外键这种需要额外查询的情况。车辆列表接口的卖家查询是典型的多对一用select_related(seller)一条SQL就能解决。还有一个容易忽视的地方列表页如果收藏了该车辆前端要显示已收藏状态这个也容易触发N1。我当时的做法是先查出当前用户收藏的全部车辆ID列表然后通过favorited_ids set(favorites.values_list(car_id, flatTrue))传入序列化器序列化器里通过id in favorited_ids判断。一次查询拿到所有收藏ID而不是每辆车都查一次收藏表。6.4 vue打包后布局异常publicPath引发的样式问题本地npm run dev一切正常npm run build之后把dist放到服务器页面能打开但样式全乱了有的图片也加载不出来。打开控制台看网络请求发现请求的css和js路径不对——都在域名根路径下找但项目实际放在了子路径。这个问题的根源是Vite默认的base是/意思是打包后的静态资源路径写死为根路径。部署在域名根路径没问题但如果部署在/car/这个子路径下浏览器就会请求/assets/index.xxxx.css而不是/car/assets/index.xxxx.css自然就404了。解决方案vite.config.js里设置base: ./相对路径或者base: /car/绝对子路径。用相对路径的好处是无论部署在哪都能跑坏处是如果做了多级嵌套路由可能出问题用绝对子路径最稳定但要保证子路径和实际部署位置一致。这个配置改完后再打包样式和图片就正常了。7. 做完这个项目的几点体会整个二手汽车销售平台从拉框架到部署上线我最大的体会是这类系统的复杂度不在于某个技术点有多难而在于大量琐碎的边界情况要处理。用户可能输入奇怪的价格区间图片可能上传失败订单状态可能和车辆状态不一致这些都不是某个框架能自动解决的。我个人在实际操作中的建议是接这类项目先把数据库表结构和接口文档定好再动页面磨刀不误砍柴工。特别是涉及交易的平台状态流转要提前想清楚——订单成交后车辆不能继续出售车辆下架后未完成的订单怎么处理这些规则最好在写代码前用文字列出来越详细越好。这个项目如果后续要扩展有几个方向很值得做车辆位置用地图展示城市维度的分布统计很有意思、买家卖家之间用WebSocket做实时聊天、后台增加简单的报表统计看哪些品牌的车源最紧俏。技术上都基于现在这套前后端分离架构前端在Vue里接入地图组件或者WebSocket客户端后端加对应的API即可整体不需要推翻重来。希望这篇对正在做类似平台的朋友有参考价值。
返回列表