ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue+MyBatis+MySQL二手车交易系统实战

SpringBoot+Vue+MyBatis+MySQL二手车交易系统实战 干我们这行的接到二手车交易系统这种项目一点不稀奇但真正要把SpringBoot、Vue、MyBatis、MySQL这一整套技术栈落地做到能用、好用、能上线里面门道其实不少。我这段时间正好完整梳理了一遍这个系统从需求建模到表结构设计从后端权限控制到前端交互落地踩了不少坑也总结了一套很顺手的实现方案。这篇东西就是把我实际动手的过程摊开讲把那些代码里看不到的决策逻辑、配置细节和排查经验都写清楚给接下来要碰这类“管理系统”的朋友一个能直接参考的底稿。1. 项目整体设计与思路拆解1.1 需求建模二手车交易系统的核心链路是什么做管理系统之前先别急着写代码得把业务方的话翻译成技术能落地的模型。二手车交易系统表面上看起来是“发布车辆、浏览车辆、联系车主”但真正扒开需求核心链路其实是这么一条车辆信息录入 → 车辆审核/展示 → 买家咨询/预约看车 → 达成交易意向 → 订单生成 → 车辆下架。别小看这条链路它决定了你数据库表怎么设计、状态机怎么流转、前后端接口怎么划分。我见过不少新手一上来就搞了一张巨大的car表把所有字段都塞进去后面做订单、做审核、做统计的时候直接抓瞎。实际上这条链路拆开来看是三个相对独立的域车辆域车辆的基础信息、图片、配置参数、价格、上架/下架状态。这块是系统最核心的数据资产二手车的关键在于“一车一况”所以车辆描述、检测报告、实拍图比新车系统重要得多。交易域用户从看到一台车到最终成交的过程包含咨询记录、预约看车、订单、支付定金或全款、过户状态。交易域是管理系统的“钱袋子”必须保证状态记录完整、流转路径清晰。用户域买家、卖家车商或个人、管理员三类角色的权限边界。系统里最容易被忽视的就是这个域但几乎所有的权限漏洞和越权操作都出在这里。我实际做的时候把这三个域完全分开设计表之间只通过外键关联业务逻辑放在Service层做编排而不是写在一张超大表里。这样后面接权限控制、接统计报表、接消息通知都非常顺手。1.2 技术选型为什么是SpringBoot Vue MyBatis MySQL这套组合现在几乎成了中小型管理系统的事实标准不是因为它“最新”而是因为它“最稳”。分开说先说后端SpringBoot。选它不是因为它性能多强而是因为它把大量重复的配置工作给省掉了。你只要引入依赖写好application.yml一个能跑的Web服务就起来了。对于二手车交易系统这种IO密集、业务逻辑偏CRUD的项目SpringBoot完全够用。而且它生态好后面你要接Redis做缓存、接MQ做消息通知、接ES做全文搜索都有非常成熟的整合方案。再说持久层MyBatis。我知道现在JPA也很多人用但MyBatis在像二手车这种查询条件动态变化、结果集需要灵活映射的场景里优势太明显了。你想想二手车首页那个筛选条件——品牌、车系、价格区间、上牌年份、里程数、变速箱类型、排放标准这七个条件任意组合你用JPA写动态查询得拼Specification拼到怀疑人生。MyBatis直接在XML里写动态SQLif标签一摆所有组合情况都搞定了。而且二手车系统里SQL经常要优化MyBatis的SQL是显式的DBA看着也舒服。Vue这边更不用纠结它组件化的开发方式特别适合后台管理系统。车辆列表是一个组件、车辆详情是一个组件、订单管理是一个组件组件之间通过props和事件通信页面复用性很高。再加上现在Vue3的组合式API代码组织比之前的Options API舒服了一个量级。MySQL则是这个量级项目的最优解。数据量撑死几十万条车辆记录事务要求不算极端用MySQL既能满足业务需求又不会引入太多运维成本。至于那些一上来就建议上分库分表、上分布式事务的我只能说先把单机性能吃透再说。2. 数据库设计二手车系统的“地基”2.1 表结构拆解从用户到订单一共需要多少张表我建表的思路是“按域建模宁拆勿合”。二手车交易系统的核心表大概这么几张表名作用域核心字段说明sys_user用户域登录账号、密码BCrypt加密、手机号、用户类型买家/卖家/管理员、状态car_info车辆域车辆标题、品牌、车系、车型、上牌日期、排放标准、里程数、变速箱、排量、价格、车主ID、审核状态、上下架状态car_image车辆域车辆图片URL列表一车多图单独建表避免字段冗余car_inspection车辆域检测报告如外观漆面、内饰磨损、发动机状态等一车一条consult_record交易域买家咨询记录包含咨询内容、回复内容、咨询时间、处理状态appointment_order交易域预约看车订单包含车辆ID、买家ID、预约时间、看车地点、状态deal_order交易域成交订单包含车辆ID、买卖双方ID、成交价格、定金、尾款支付状态、过户状态sys_log用户域操作日志记录谁在什么时间做了什么操作追踪问题用这里有个比较容易忽略的点车辆图片必须单独建表。二手车一辆车少说五六张图多则几十张你要是用逗号分隔或者JSON存在car_info一个字段里后面做图片管理、做列表懒加载、做缩略图生成的时候就等着哭吧。单独建表之后你还可以很方便地给图片加排序字段、加封面标记列表页只需要查第一张封面图详情页再加载全部性能好很多。车辆检测报告表也是我后来加上的。最开始偷懒把检测信息散落在car_info的备注字段里结果运营想按“无事故”“原版原漆”筛选车辆的时候SQL根本写不出来。后来单独建了一张car_inspection表把关键检测项拆成独立字段查询统计一下子就灵活了。2.2 状态机设计车是怎么从“草稿”变成“已成交”的二手车交易系统的难点不在表多而在状态流转。一台车从录入到最终成交中间要经过好几个环节草稿 → 待审核 → 已上架 → 已下架/已预约/已成交。这里我强烈建议把状态字段做成tinyint再用常量类去定义而不是直接存字符串。我常用的设计是这样的0草稿卖家录入但未提交审核1待审核卖家已提交管理员还没审2已上架审核通过前台可见3已下架管理员主动下架或卖家撤回4已预约有买家预约看车且预约信息有效5已成交订单生成并完成交易这里有个细节很多人会踩坑状态不能只存一个字段关键动作必须记录时间点。比如你只存了status4但什么时候被预约的哪个买家预约的只凭状态根本查不出来。我的做法是状态字段只管“当前在哪一步”时间线信息全部落到操作记录表里或者直接在deal_order、appointment_order里带create_time需要看流程的时候按时间线拼起来。还有一个更隐蔽的问题状态流转必须做校验不能让车从“已成交”直接跳回“草稿”。这块业务规则要写在Service层里而不是靠前端按钮的显隐控制。前端只能控制用户看不看得到按钮后端才是守卫流程的关卡。比如管理员重置车辆状态的时候一定要先判断当前状态是否允许变更否则就可能出现“车都卖掉过户了系统里又变成在售”的严重事故。2.3 索引与查询优化筛选条件多了怎么不卡二手车列表页的筛选条件非常多品牌、价格区间、年份、里程、排量随便组合这直接考验索引设计。我的经验是给高频筛选字段分别建单列索引再给最常用的组合建一两个联合索引。最常用的组合是brand price因为大多数用户都是先选品牌再看价格。我建了一个idx_brand_price(brand_id, price)的联合索引。其次车的上架时间list_time也建了索引因为列表页默认按上架时间排序没索引的话数据一旦上万条ORDER BY list_time DESC就能把数据库拖垮。还有一个很重要的优化点列表页别SELECT *。车图、检测报告这些大字段在列表页根本用不到我通常是写一个selectCarList()方法只查列表需要的十几个字段。有些新手直接在Mapper里select * from car_info图片多的车光传输列表就得几百KB页面能不慢吗详情页再单独查全量字段。3. 后端核心实现权限、车辆发布与订单流转3.1 基于RBAC的权限模型三类角色怎么管二手车交易系统里头用户类型决定了能干什么。买家只能看车、咨询、预约卖家能发布车辆、管理自己名下的车管理员能审核车辆、管理用户、看系统日志。我用的就是经典的RBAC模型表结构简化成三张sys_user、sys_role、sys_user_role。有朋友可能会问就这么三类角色还需要角色表吗直接用户表里加个user_type字段不就行了。对于纯管理系统来说确实可以但如果哪天运营说“我要加一个‘高级卖家’角色审核更快、能发更多车”没有角色表你就得改表结构改代码。用RBAC模型以后新增角色只是在数据字典里加一条记录再给用户分配一下权限代码完全不用动。权限控制的落地点我用的是SpringBoot的拦截器。定义一个AuthInterceptor在preHandle里校验用户是否登录以及当前请求路径是否在该角色的权限范围内。路径规则我是这样设计的/api/car/**登录用户都能访问但POST操作只允许卖家角色/api/admin/**只允许管理员访问/api/order/**登录用户可访问但操作时校验数据归属这里最重要的一条经验是接口层必须校验数据归属。什么意思比如买家A想删除一笔预约记录请求路径是DELETE /api/appointment/{id}你光检查登录还不够还要查出这条预约记录的buyer_id是不是当前登录用户。这个校验写在Service层里在操作前先查一次数据权限不符直接抛异常。这是一个很多人忽略的漏洞点尤其在用Shiro或Spring Security的时候他们光做RBAC权限认证数据级权限还是要自己在代码里控制。3.2 车辆发布与图片上传事务文件存储怎么配合车辆发布是整个系统里最容易出问题的接口。为什么因为它是跨表操作主表car_info插入一条记录子表car_image批量插入图片检测报告表car_inspection插入一条。任何一个环节失败都会导致数据不一致。所以这个接口必须加Transactional事务。这里有个细节值得说一下图片文件上传和数据库写入的顺序。我的做法是先调文件存储服务把图片传上去拿到URL列表再开始写数据库。如果数据库写入失败事务回滚那些已经上传的图片就变成了“孤儿文件”。处理办法有两个一是在Service层加补偿逻辑事务回滚时把已上传的图片删除二是用定时任务定期清理几天前的孤儿文件。我项目里用的是定时任务方案逻辑简单、不阻塞主流程每天凌晨跑一次清理car_image表里不存在对应记录的图片文件就行。文件存储这块小项目用本地磁盘就够了。配置一个upload.path目录通过SpringBoot的静态资源映射暴露访问路径。但要注意本地存储一定要做文件名校验防止用户上传恶意文件。我通常只允许jpg、png、webp格式图片大小限制在5MB以内文件名一律重命名为UUID避免中文文件名和路径穿越问题。3.3 订单流程的并发控制一台车别卖两次二手车交易系统最常见的并发问题就是同一台车被两个买家同时下单。如果不做控制两个订单都能生成车子就“一女二嫁”了。这个问题的解决方案其实很简单数据库锁定。第一种方式是乐观锁。在car_info表加一个version字段下单时UPDATE car_info SET status5, versionversion1 WHERE id? AND status2 AND version?。影响行数为0说明状态不对或版本变了订单创建失败。这种方式性能好适合并发量不高的场景。第二种方式是悲观锁。SELECT ... FOR UPDATE把车辆记录锁住然后检查状态、创建订单、更新状态最后提交事务释放锁。这种方式最稳妥但要小心死锁一定要控制好锁的顺序。我的实际选择是乐观锁因为二手车交易的并发量真没那么高一台热门车同一时间被10个人抢着下单已经算极限了。加一个version字段配合状态校验代码写起来简单也不容易出现死锁问题。订单创建的方法里我加了Transactional里面先尝试更新车辆状态更新失败直接抛BizException(车辆已被预订或已售出)前端弹个提示就行。3.4 MyBatis动态SQL筛选条件多就用它既然选了MyBatis就得把它动态SQL的威力用出来。二手车列表查询是我整个系统里最复杂的查询前端传过来的参数可能有十来个而且每个都可选。这种场景用where标签处理简直完美select idselectCarList resultTypecom.example.entity.CarVO SELECT id, title, brand_id, series_id, price, mileage, list_time, cover_url, status FROM car_info where if testbrandId ! null AND brand_id #{brandId} /if if testseriesId ! null AND series_id #{seriesId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testminMileage ! null AND mileage gt; #{minMileage} /if if testmaxMileage ! null AND mileage lt; #{maxMileage} /if if teststatus ! null AND status #{status} /if /where ORDER BY list_time DESC LIMIT #{offset}, #{pageSize} /select几个容易踩的坑说一下小于号在XML里必须转义成lt;不然XML解析直接报错offset pageSize分页适合数据量不大的场景但一旦数据量超十万性能会明显下降。到时候要换成基于游标的分页方式动态SQL拼接如果条件多了记得EXPLAIN看执行计划确认走的是索引另外讲一下MyBatis二级缓存的事。我项目里没有开二级缓存。原因很简单二手车信息的查询条件组合太多了任何一条车辆数据的更新比如状态变化、价格调整都会导致缓存失效缓存命中率低不说还可能查到脏数据。MyBatis的一级缓存默认是开启的同一个SqlSession内重复查询会命中缓存这已经覆盖了大部分场景。真要并发高的话应该上Redis而不是依赖MyBatis的内部缓存。4. 前端关键页面与交互落地4.1 Vue3 Element Plus后台管理系统的搭建节奏前端我选的是Vue3 Vite Element Plus Vue Router Pinia。为什么不是Vue2因为新项目没有理由再回头看旧生态Vite的冷启动速度比Webpack快了几倍组件库的坑也少。Element Plus的表格、表单、分页、弹窗组件都很成熟特别适合后台管理系统省去自己造轮子的时间。工程的目录结构我习惯这样分src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── layouts/ # 布局组件 ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── views/ │ ├── car/ # 车辆管理页面 │ ├── order/ # 订单管理页面 │ ├── user/ # 用户管理页面 │ └── dashboard/ # 数据看板路由这块有个很实用的做法面包屑导航配置在路由的meta里。像二手车系统这种页面层级很分明的系统用户经常要来回跳面包屑能有效减少用户迷路的感觉。在router的meta里加title和icon字段然后用一个循环组件统一渲染面包屑所有页面只要配置好路由面包屑自动生成不用每个页面单独写死。4.2 车辆列表页联动筛选加懒加载车辆列表页是整个系统最复杂的页面因为筛选条件多、数据量大、交互频繁。先说筛选区品牌、车系是两个联动下拉框选完品牌后车系列表要动态请求。这个联动逻辑我放在一个computed属性里监听brandId的变化触发getSeriesList()请求。列表表格里几个关键处理图片列用Element Plus的el-image组件加lazy属性配合preview-src-list功能点击小图能弹出大图预览用户体验好很多价格列按区间着色比如低价车绿色、高价车红色让用户扫一眼就能区分状态列用el-tag组件不同状态显示不同颜色比纯文本醒目得多分页这个地方有个细节Element Plus的el-pagination组件页码变化会触发current-change事件但要注意这里的事件参数是页码不是偏移量。我做了一个handlePageChange(page)方法重新计算offset然后请求新数据。还要注意翻页后重置勾选状态不然用户选了一条数据翻页再回来勾选状态不清会有很大的隐患提交批量操作的时候可能出现“操作了看不见的数据”。4.3 车辆发布表单动态字段校验与图片上传预览卖家发布车辆的表单是整个系统里字段最多的表单总共得有二十多个字段。如果全部放在一个页面里用户看着就头大。我的做法是分步骤表单用el-steps组件分成三步基本信息标题、品牌、车系、上牌日期等、车辆详情里程数、变速箱、排放标准、价格等、图片上传与检测信息。分步的好处不只是用户体验好校验也分步进行每步只校验当前步骤的字段比一次性校验二十多个字段的体验好得多。Element Plus的el-form提供了validateField方法可以在点击“下一步”按钮时只校验当前步骤的prop列表。图片上传是一个操作频率很高的功能核心点在于上传过程中不能重复提交。我用了Element Plus的el-upload组件limit设为20张on-exceed钩子里做提示。更关键的是before-upload钩子里做前端格式和大小校验不符合的直接拒绝不用等后端返回错误。on-success回调里拿到后端返回的图片URL存到表单的图片数组里。提交表单的时候这个数组就是要传给后端的carImageList字段。踩过一个小坑el-upload的file-list绑定的数据和实际表单数据是两个东西你光改file-list并不会自动改carImageList。我后来干脆不用file-list只维护一个imageUrlList数组上传成功的URL推进这个数组删除的时候从这个数组里过滤掉对应项渲染就靠这个数组清晰很多。4.4 权限控制在前端怎么配合后端做了接口权限控制前端也要配合做页面按钮的显隐控制。登录成功后后端返回当前用户的角色信息前端存到Pinia里。在路由配置里路由的meta字段加上roles数组比如管理员的路由页面roles: [ADMIN]卖家的路由roles: [SELLER]。路由守卫里做这么一件事router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.isLogin) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.meta.roles !to.meta.roles.includes(userStore.role)) { next({ path: /403 }) return } next() })还有一个细节按钮级别的权限比如卖家页面的“下架”按钮只对本人可见这个就得用自定义指令像v-permission[SELLER]指令内部判断角色不匹配就直接移除DOM元素。这种做法的最大好处是代码里能看到按钮意图不满足权限时也不会渲染出来。5. 实战中必踩的坑与排查记录5.1 跨域问题前端请求后端接口直接403前后端分离开发时最典型的问题就是跨域。前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器会拦截跨域请求。常规解法是后端加个CORS配置类允许指定来源访问Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(InterceptorRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }注意两点一是allowedOrigins换成了allowedOriginPatterns因为SpringBoot新版本对allowedOrigins(*)加了一些限制特别是配合allowCredentials(true)的时候会报错用Patterns写法绕过二是allowedMethods里必须包含OPTIONS因为浏览器预检请求发的是OPTIONS不加的话一样被拦截。这个问题非常典型配置完成前任何一个前端请求发过来响应都可能是403。5.2 密码加密与登录状态别把明文密码存进数据库用户密码存储这件事我反复强调绝对不能明文存储。我用的是Spring Security自带的BCryptPasswordEncoder对密码做加盐哈希处理同一个密码每次加密出来的结果都不同即使数据库泄露反推出明文密码的难度也非常大。登录之后怎么维持状态我用了JWT。登录成功后后端签发一个Token有效期为两小时前端把它存在localStorage里每次请求在axios拦截器里加到Authorization请求头。后端用一个拦截器统一解析Token解析成功就把用户ID塞进ThreadLocal方便Controller层获取当前登录用户。这里有个团队里容易犯的错审批逻辑里混用JWT的过期时间和用户状态。如果用户被管理员禁用但Token还没过期那么已经登录的用户仍然可以操作。这个坑要在拦截器里查一次用户表判断状态是否为禁用。虽然多一次查询但安全第一。5.3 MyBatis的N1查询列表页为什么会慢到秒级刚写完列表接口的时候查询二手车列表需要3秒被测试一顿吐槽。排查之后定位到是N1查询问题列表本身查了20条车辆然后每条车辆又关联查图片、查车主信息二十条车就是二十次额外查询性能能不炸吗。解决办法是用resultMap做关联映射或者干脆在SQL里面用LEFT JOIN。我最后选择的是写一个专门的查询方法用JOIN把车辆信息和封面图一次性查出来select idselectCarListWithCover resultTypecom.example.entity.CarListVO SELECT c.id, c.title, c.price, c.mileage, c.list_time, i.url AS cover_url FROM car_info c LEFT JOIN car_image i ON i.car_id c.id AND i.is_cover 1 where ... /where ORDER BY c.list_time DESC LIMIT #{offset}, #{pageSize} /select注意LEFT JOIN这里有个陷阱一辆车如果有多张封面图JOIN结果会重复。所以is_cover字段必须保证一辆车只有一条我用的是“插入时先清除该车原有封面标记再设置新封面标记”的方式保证数据唯一性。如果是用MyBatis-Plus的玩家QueryWrapper虽然方便但涉及关联查询时还是要老老实实自己写XML。省心是省在单表操作的自动化复杂查询还得自己把握。5.4 前后端联调时的数据格式不一致前后端联调最容易出的问题就是格式对不上。后端返回的时间是2025-01-01 12:00:00前端希望的是时间戳结果表格里显示了一串看不懂的字符串。我的方案是后端所有时间字段统一返回yyyy-MM-dd HH:mm:ss格式字符串前端在表格里直接用字符串展示不需要再做格式化。如果要做相对时间比如“3天前发布”那就在前端再转换。还有一个格式问题是数字精度。二手车价格动辄几十万如果后端用BigDecimal返回前端JS处理浮点数容易出现精度丢失。我的建议是价格字段后端统一返回“以万元为单位、保留一位小数”的字符串前端直接展示不做运算。真要做价格区间筛选那也是后端SQL这边做前端不要自己算。5.5 数据库连接池配置与慢SQL监控系统上线后偶尔会有操作卡顿的情况。后面排查发现原来没有配置连接池参数用的是默认值连接数不够用导致请求排队。我的建议是在application.yml里显式配置HikariCP连接池spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000这些参数值我是参考了机器规格来配的四核八G的机器20个连接够用了。注意connection-timeout不要太短不然并发高峰时拿不到连接直接报错。另外建议打开MySQL的slow_query_log把超过1秒的SQL都记录下来每周检查一次日志把慢SQL逐条优化掉。这种细节虽然啰嗦但真的到业务量上来的时候才知道有多重要。6. 从开发到上线的最后一步我个人的几点体会项目走到能跑、能测、能部署这个阶段回头看最有价值的其实不是写了多少行代码而是做对了一些关键决策把状态机前置设计清楚把数据权限控制放在后端Service层把车辆图片单独拆表把订单并发用乐观锁拦下来。这些不复杂也不算特别高大上但它们决定了项目能不能经得起真实业务的打磨。真在二手车市场那样一天几百条车源录入、几十个买家咨询预约的节奏下系统还能稳定不犯错靠的就是这些“笨功夫”打底。另外一个体会是二手车交易这种传统行业的信息化系统业务逻辑本身不复杂真正的复杂度都来自“状态”“权限”“并发”这些底层模型。从架构第一天起就重视数据建模和流程校验后面开发效率会高的多。不然就是前两个月疯狂写页面最后一个月疯狂改Bug改到上线前一天还在调数据。如果你打算照着这套思路自己搭一遍我最后的建议是别贪多先把车辆发布、列表筛选、订单生成这三条主链路跑通再去补那些花哨功能。核心链路稳了这个系统就已经值回票价了。剩下那些报表、消息中心、数据看板全都可以后面慢慢加。
返回列表