
做“靓车汽车销售网站”这个项目第一反应可能是“不就是个普通的管理系统嘛”但真正从零做过一遍之后你会发现它其实是把 Java 后端、前端工程化、数据库建模和部署运维全部串起来的一个好样例。这个项目我选择用 SpringBoot Vue3 MyBatis MySQL 来落地后端提供接口前端独立工程数据库负责核心业务数据整套代码可以跑通从“客户看车 → 预约试驾 → 下单购买 → 后台管理”的完整链路。如果你正在找毕业设计、准备 Java 开发岗位面试或者单纯想有个能写进简历的全栈项目这篇内容都值得往下看。我不只讲最终效果还会把当时设计数据库、写接口、联调页面、部署上线时踩过的坑一并还原出来。1. 项目整体设计与技术选型思路1.1 汽车销售业务的角色和流程先想清楚再动手动手写代码之前最忌讳的是拿到“做一个汽车销售系统”这种需求就开始建表。我习惯先画一条业务主线谁在用这个系统他们分别要做什么。汽车销售网站至少有两种角色一种是买车的客户一种是店里做管理的销售人员或管理员。客户的路径很直接浏览首页的车系列表点进详情页看参数和价格觉得合适就发起预约试驾试驾满意后下订单支付定金后续等待门店交车。管理端的路径则是登录后台维护车辆信息新增车型、改价格、上下架、接收客户预约、处理订单状态、查看统计报表。把这两条路径理顺之后表结构和接口自然就浮出来了。这里有个很容易犯的错一开始就想着把功能做全加上积分、优惠券、秒杀、社区帖子。结果项目做完主流程反而没夯实。我这次刻意控制了范围核心就围绕车、客户、订单三个实体展开外加一个后台员工账号体系和一张资讯表用来支撑网站内容。功能少一点没关系关键是每一条链路都要通面试或展示的时候能讲清楚数据是怎么流动的。前后端分离在这个项目里不是刻意炫技而是业务形态决定的。客户端页面要追求展示效果整页轮播、筛选、车型配置对比管理端更偏表单和表格。两边的迭代节奏不一样拆成两个工程各自部署改动互不干扰这在真实公司里也是主流做法。1.2 技术栈选型的真实理由不是越新越好这套技术栈不是市面上唯一的选择但对于一个希望快速落地、同时兼顾“面试含金量”的项目来说确实很稳。后端用 SpringBoot核心好处是起步快。它把 Spring 家族里复杂的 Bean 配置、WebMvc 配置、数据源配置都做了自动装配我只需要引入一个spring-boot-starter-web就能把 Controller 跑起来。项目里还要用数据库操作再引入mybatis-spring-boot-starter和mysql-connector-j这比早期 SSM 框架手动维护一堆 XML 配置要省心得多。持久层选 MyBatis 而不是 Hibernate/JPA原因在于这个系统的查询条件太容易变了。比如车辆列表要支持品牌、价格区间、座位数、能源类型等多条件筛选JPA 的 Specification 虽然也能做但写起来比较绕MyBatis 的 XML 里写动态 SQL 就很直观if标签一拼条件控制得明明白白。再加上很多 Java 面试题都会问 MyBatis 的缓存机制和动态 SQL 原理你用熟了面试时反而有真实素材可以讲。前端为什么选 Vue3一方面组件化和响应式开发效率确实高另一方面 Vue3 目前在国内中小公司里的普及率很高选它不愁没人看懂。具体到工程实现我用 Vite 做构建工具冷启动比 Webpack 快不少用 Pinia 做状态管理用来存登录信息比 Vue2 时代的 Vuex 更轻量路由用 Vue Router 4。页面用了 Element Plus 组件库后台管理界面的表格、表单、弹窗都靠它省掉大量无意义的 UI 手写时间。数据库用 MySQL这个不需要太多解释免费、稳定、社区资料多。不过在配置上有些细节需要注意后面我会单独说。1.3 工程结构怎么拆分前后端各管一摊后端工程我按常见的分包方式组织controller 负责接收 HTTP 请求service 处理业务逻辑mapper 负责和数据库交互entity 对应数据表common 放统一返回体和异常处理config 放跨域、拦截器、MyBatis 等配置。这样分层的好处是各层职责单一排查问题的时候能按层定位接口报错先看 controller业务逻辑不对看 serviceSQL 问题直接去 mapper XML 里找。前端工程内部也分了模块views放页面组件components放复用组件api放每个业务模块的请求函数store放全局状态router配置路由和守卫。前后端之间只通过 JSON 格式的接口通信约定好返回结构后两边可以并行开发。比如后端先把接口文档定义出来前端就可以用 mock 数据开发页面不用等人。2. 核心业务与数据库设计基础不牢后面全是坑2.1 数据表设计要覆盖哪些核心实体字段怎么定这套系统里最重要的四张表是车辆表、客户表、订单表、员工表另外配一张资讯表做内容展示。车辆表car我建议包含这些核心字段id、品牌 brand、车系 series、车型型号 model、能源类型 energy_type燃油/纯电/混动、车身类型 body_type轿车/SUV/MPV、指导价 price、库存数量 stock、车辆状态 status在售/下架/售罄、主图地址 image_url、车辆参数 engine_param、上架时间 create_time。价格字段一定要用 decimal不能用 float 或 double否则金额会出现精度问题状态用 tinyint 存0 下架、1 在售查询时也走索引。客户表customer除了常规的 id、手机号、密码、昵称之外建议加一个账户状态字段防止恶意注册。密码不要明文存储至少要用 BCrypt 做哈希。手机号加唯一索引因为登录、订单核销都会靠它查客户。订单表order_info是整个系统的核心。字段要包含id、订单编号 order_no、客户 id、车辆 id、成交价、定金或者全款金额、订单状态 status、预约试驾时间 test_drive_time、业务员 id可选、支付渠道 pay_channel、创建时间。订单编号需要全局唯一我一般生成规则是“时间戳 随机数”不用数据库自增主键对外暴露避免被人枚举订单量。为什么订单表不和客户、车辆直接耦合在一个接口里因为订单状态的变更频次很高从“待联系”“已预约”“待支付”“已支付”“已交车”到“已取消”每个状态都需要保存流转时间。如果拆得不够细后面改状态就知道痛苦了。这里我额外加了一张订单状态日志表用来记录每次变更操作方便后台追溯。2.2 库存和并发的处理这是项目里最能打的亮点汽车这种高客单价商品库存变化频率没有电商秒杀那么夸张但依然要考虑并发问题。比如同一个车型最后一台车两个客户同时下单库存只有 1如果只在代码里先查库存再扣减大概率会出现超卖。推荐的做法是数据库层面的条件更新执行update car set stock stock - 1 where id ? and stock 0受影响行数是 1 才说明扣减成功然后创建订单如果受影响行数是 0说明库存不足直接给前端返回“该车型库存不足”。这个方案简单可靠不需要引入 Redis 锁对这个项目来说已经足够了。如果你想把并发控制做得更漂亮可以结合 Redis 预扣库存但那就得处理预扣超时回补的补偿逻辑复杂度会上升。我在项目里用数据库条件更新为主Redis 只用来做缓存和登录 token控制在一个合理的复杂度范围内。2.3 为什么用 MyBatis 而不迷信第三方增强工具动态 SQL 怎么用项目标题里写的是 MyBatis所以我坚持用原生 MyBatis 的方式写 mapper XML而不是无脑上 MyBatis-Plus。虽然 MyBatis-Plus 的代码生成器和 BaseMapper 能省很多 CRUD但对于多表查询、复杂筛选最终还是需要手写 SQL。把基础打牢自己会写 mapper 和 XML再去用增强工具只是提效而不是替代。车辆列表的多条件查询就是典型场景。前端传 brand、minPrice、maxPrice、energyType、pageNum、pageSize 过来如果每个参数都写一个 SQL 方法那维护成本爆炸。用 MyBatis 的动态 SQL一个方法就够了select idselectCarPage resultTypecom.demo.sales.entity.Car select * from car where if testbrand ! null and brand ! and brand #{brand} /if if testminPrice ! null and price gt; #{minPrice} /if if testmaxPrice ! null and price lt; #{maxPrice} /if if testenergyType ! null and energyType ! and energy_type #{energyType} /if /where order by create_time desc /selectwhere标签会自动去掉第一个多余的 and比手动写where 11干净多了。注意价格比较要用转义字符gt;和lt;在 XML 里直接写和解析会出问题这也是新手经常踩的第一个 MyBatis“坑”。缓存也是面试和高频使用点。MyBatis 一级缓存默认开启作用范围是一个 SqlSession同一个 session 内重复查询会直接命中缓存二级缓存默认不开启需要配置cache标签并让实体类实现 Serializable。但二级缓存对多表查询会有脏读风险因为别人更新了关联表你的缓存不会自动失效。我的建议是这个系统里保持默认的一级缓存就好查询量大的列表接口走 Redis 缓存而不是盲目依赖 MyBatis 二级缓存。2.4 权限控制与登录鉴权简单但必须做管理端的员工登录不能省。这套系统我用了基于 JWT 的登录方案用户输入账号密码查询数据库校验通过后生成 token 返回给前端前端把 token 存到 localStorageAxios 请求拦截器自动附加上后端用一个拦截器拦截/api/admin/**路径从请求头里取 token 验签验不过直接返回 401。比起传统的 Session 方案JWT 的好处是天然适合前后端分离和接口复用。后端不需要关心 Session 存储水平扩容时也不会因为 Session 不一致导致用户掉线。不过 JWT 的缺点也要知道签发之后无法主动失效所以 token 有效期要设短一些比如 2 小时刷新用 refresh token或者干脆把 token 存到 Redis用 Redis 的过期时间做强制的注销控制。项目演示的话先把有效期内简单的方案跑通即可。3. 后端接口开发的关键实现把每一层拆开看3.1 统一返回体和全局异常前端才能少做判断后端接口如果每个方法都返回不同类型前端就需要到处 try/catch 判断。我在项目开始前就定义了一个通用返回体 Resultpublic class ResultT { private Integer code; private String message; private T data; // 成功、失败静态方法 }所有 Controller 方法都返回 Result成功 code 为 200失败 code 为 500 或业务自定义码。前端 Axios 响应拦截器里统一判断 code如果 code 不是 200就弹出 message 提示。业务异常直接在 service 里抛自定义异常由全局异常处理器转换成统一的 ResultController 里就不用每个方法都包 try/catch 了。这里要特别说明一下全局异常处理不要只处理业务异常还要兜底处理参数校验异常、SQL 异常、空指针异常。不然某个异常漏出去前端收到的就是一个默认错误页或乱七八糟的堆栈。我参考了 Spring 的RestControllerAdviceExceptionHandler把常见的异常都映射成友好的提示文案。这个设计虽然简单但在项目复盘时非常加分。3.2 车辆列表接口的完整实现流程以列表接口为例走一遍完整链路。前端请求GET /api/car/list?pageNum1pageSize10brand...Controller 层用RestController接收请求参数封装成一个 QueryDto。要注意接口参数建议用 DTO 接收而不是用 Map否则参数多了以后没法做校验代码可读性也差。Service 层负责组装分页逻辑。原生 MyBatis 需要手动拼接 limit我后来引入了 PageHelper 插件一行PageHelper.startPage(pageNum, pageSize)就能让后面的查询自动带分页。但 PageHelper 有个大坑startPage只对紧跟的下一条 SQL 生效如果你在它和查询之间做了别的查询分页条件就会串到别的 SQL 上。所以项目里我把分页查询统一封了一个规范所有 mapper 方法先查 list 再 count避免 PageHelper 的自动 count 在某些复杂 SQL 下产生性能问题。分页返回结构我用的是 PageResult包含 total、list、pageNum、pageSize 四个字段。前端表格组件拿到这个结构就能直接渲染页码切换逻辑也顺理成章。3.3 文件上传图片资源路径设计汽车网站最怕车图失真所以车型图片肯定不能只存一个 URL还要考虑上传新图、删除旧图、后台批量处理。我在项目里做了一个简单的文件上传接口前端用 Element Plus 的 Upload 组件选完图后 base64 或 FormData 上传到后端后端保存到服务器本地目录并返回访问路径。SpringBoot 默认有单次上传大小限制只有 1MB稍微大一点的汽车高清图就会报错所以必须在配置里放开spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB图片保存路径不建议直接写在 Controller 里而是放到配置文件里用Value注入。生产环境一般用 Nginx 挂一个/images/路径映射到服务器的物理目录既不用经过 Tomcat 响应静态资源又方便后续做 CDN。注意文件名不要用用户上传的原始名称容易碰到中文乱码和路径穿越问题我会用 UUID 重命名扩展名保留原后缀。3.4 日志与参数校验这些细节决定项目能不能拿得出手有朋友会说“接口能跑就行管什么日志”但实际上做出来的项目能不能通过评审细节占了很大权重。后端每个操作订单的接口我都加了基于 Slf4j 的日志输出包括请求入参、处理结果、耗时。在排查线上问题时没有日志就只能靠肉眼猜。参数校验也是必备项。SpringBoot 的Validated配合NotNull、NotBlank、Min注解能解决大部分校验需求。比如下单接口车辆 id 不能为空、数量必须大于 0、客户 id 不能为空如果没有校验数据库字段 even 设置非空还好没设置的话就会产生一堆脏数据。校验不通过时异常处理器会统一返回字段错误信息前端可以把 message 直接提示给用户。4. 前端 Vue3 实现与前后端联调页面和数据不能各玩各的4.1 Vue3 工程化搭建按步骤走不会乱我用 Vite 从零搭建的前端工程。命令很简单npm create vitelatest car-sales-web -- --template vue然后再安装依赖npm install vue-router4 pinia axios element-plus。如果你用的 Node 版本过旧Vite 可能启动报错建议 Node 保持 18。这一步网上有很多教程但版本差异容易踩坑我的建议是看官方文档而不是某篇写于三年前的博客。代码组织上我用了 Vue3 的script setup语法逻辑和模板离得更近。每个页面对应一个 views 文件比如Home.vue、CarDetail.vue、OrderConfirm.vue、AdminCarList.vue。组件内部只负责渲染和交互数据请求统一抽到 api 模块。比如api/car.js里定义export const getCarList (params) axios.get(/api/car/list, { params })页面里通过onMounted调用配合ref和reactive管理响应式数据。这里要特别注意 Vue3 的解构问题reactive对象如果直接解构响应式特性会丢失需要用toRefs或者干脆用ref包裹整个查询表单。路由守卫也是一个必须过的关卡。管理端页面比如/admin系列路径在全局前置守卫里检查本地有没有 token没有就跳回登录页。这里前后端要配合好否则用户直接改 URL 就能进入后台页面那安全就白做了。4.2 页面拆解客户要好看管理员要好用首页的设计逻辑不是堆大图而是按购车决策顺序来顶部导航放品牌分类中间轮播放主推车型下面按“热门车型”“最新上架”“新能源专区”几个分组展示车辆卡片。每个卡片展示价格、车型、能源类型点击进入详情页。详情页是促成转化的关键。我放置了左侧图片预览、右侧价格和参数、下方预约试驾按钮。预约试驾弹窗可以直接提交手机号和期望时间后台生成一条试驾记录。这个功能长度不大但很能体现业务流程完整度比单纯一个商品展示页面强很多。管理端我用 Element Plus 的 Layout 布局左侧菜单右侧主区域。车辆管理用表格展示所有车型支持模糊搜索、价格区间筛选表单用 Dialog 抽屉做新增和编辑订单管理则用状态标签展示流转状态管理员可以直接“标记已联系”“完成交车”。表格和表单是后台系统的日常把这些做熟练比写花哨动画有价值一百倍。4.3 Axios 封装、跨域和接口联调前后端分离的三大坑前端不能直接裸用 axios我封装了一个request.js设置baseURL请求拦截器里读取 localStorage 的 token 并加入 header响应拦截器里如果返回的 code 不是 200统一用 Element Plus 的 Message 组件提示。这样做之后页面上几乎看不到散落的错误处理代码。联调阶段最烦的就是跨域。开发环境我直接用 Vite 的 proxy 配置把/api代理到http://localhost:8080浏览器不会出现跨域问题。生产环境如果用 Nginx反向代理同样能解决。后端跨域配置CrossOrigin或全局 CorsFilter只建议在特殊场景使用比如临时给外部联调否则开发、生产环境各搞一套反而容易出问题。我特别提醒一点接口联调不要只在 Chrome 的 Network 里“看有没有响应”要把报错信息复制出来前端和后端各自对照。很多“接口报错”的问题其实根因都是字段名对不上比如后端返回energyType前端写成energy_typeJSON 解析出来永远是 undefined。4.4 Vue 打包之后怎么放进 SpringBoot两种方案讲清楚这个点经常有同行问。如果是小项目演示最简单的方式是把前端执行npm run build生成的dist目录复制到 SpringBoot 的src/main/resources/static下然后重新打包 jar。访问http://ip:8080就能直接打开前端页面。但是这种方式有一个致命问题如果你用 Vue Router 的 history 模式直接访问http://ip:8080/car/1会 404因为后端没有这个路由只有index.html这一个入口。所以在生产环境我更推荐用 Nginx 托管前端静态文件并配置反向代理。Nginx 配置如下server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;就是解决 history 路由刷新 404 的关键。location /api/把前端请求反代到 Java 服务。把前端 dist 部署到 NginxSpringBoot 只暴露 8080 内网端口这种结构也更接近真实生产环境面试聊起来有底气。5. 数据库配置、部署实录与常见问题排查5.1 数据库连接的三个经典配置连接 MySQL 的第一个坑是时区。MySQL 8.x 默认时区是 UTC如果连接串里没指定 serverTimezoneJava 侧的时间和数据库时间会差 8 小时订单创建时间看起来就别扭。我用的连接串是url: jdbc:mysql://localhost:3306/car_sales?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse字符集对所有中文内容的读写也很关键。我在建库时直接用CREATE DATABASE car_sales DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;避免后期出现中文乱码。表结构和连接串的字符集也要统一不然就算代码没问题查出来也可能是一堆问号。第二个是驱动版本。SpringBoot 2.7 的内置 MySQL 驱动管理比较老了如果你用的是高版本 SpringBoot 3.x记得手动引入新版com.mysql:mysql-connector-j否则会报奇怪的连接类找不到错误。SpringBoot 3 还需要 JDK17 才能运行这个问题也常碰到。第三个是连接池。我习惯用 HikariCP它在 SpringBoot 里是默认配置性能很好。初始化连接数、最大连接数、连接超时时间都可以在配置里调比如spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000不要上来就把最大连接数调得很高数据库服务本身有并发上限连接池只是排队机制数调太大反而会拖垮 MySQL。5.2 从零部署一套前后端分离项目的完整步骤部署过程我记录一下照着做基本不会错先准备服务器环境。安装 JDK17 和 MySQL8把数据库初始化脚本执行完。创建后端目录把打包好的 jar 上传上去用nohup java -jar car-sales.jar --server.port8080 app.log 21 方式启动。启动后先用curl http://127.0.0.1:8080/api/car/list测试一下接口通不通。再安装 Nginx把前端npm run build生成的 dist 目录里的内容上传到/usr/share/nginx/html。把上面那套 Nginx 配置写好nginx -t检查配置然后nginx -s reload。最后就是测试访问。直接用域名或 IP 打开首页看页面能不能加载打开一个详情页刷新一下确认不 404在页面里完成一次预约试驾观察后端日志确认请求真的打到了 Java 服务。到这里整套系统就算跑通了。这里面最容易出问题的环节是“网络层面看上去通了但接口 404/500”。比如 Nginx 代理配置有误导致/api请求没转发到 8080或者 jar 启动失败端口根本没监听。这种时候先在服务器上用 curl 打接口如果直接访问 8080 是通的就说明问题一定在 Nginx 代理那一段。5.3 典型问题速查表直接对照着查症状可能原因解决办法前端请求接口跨域开发环境未配代理或生产 Nginx 未反代配置 Vite proxy 或 Nginx location /api/刷新页面 404history 路由模式没有 fallbackNginxtry_files $uri $uri/ /index.html;MyBatis 报 binding exceptionmapper 接口和 XML namespace 不对应检查 namespace、接口方法名、参数类型中文插入数据库后乱码数据库连接和表字符集不一致统一使用 utf8mb4连接串加 characterEncodingPageHelper 分页串到其他查询startPage 和查询之间被插入无关查询让 startPage 紧跟目标查询上传图片提示文件过大Spring 默认限制 1MB配置 multipart max-file-size登录后请求 401token 过期或拦截器未放行登录接口检查拦截器配置和前端请求头这张表看着简单但每一行都是从真实操作里沉淀下来的。尤其是 MyBatis 的 binding exception新手几乎必踩因为很多人会忘记给 mapper 接口加Mapper注解或者 XML 文件没有放到 mapper 接口同包路径下导致 Spring 启动时找不到实现类。解决办法就是在启动类上扫描MapperScan(com.demo.sales.mapper)同时检查 mapper XML 的namespace完全等于接口全限定名。5.4 个人避坑清单和几个建议最后说几个我觉得特别有价值的小细节。第一所有对外接口都定义好统一返回体这点不要只在项目里做一次要形成习惯。它能省掉前端大量的判断逻辑也让其他同事接你的接口时思路清爽。第二不要把敏感信息写死在配置里。数据库密码、JWT 密钥这些要放到application-prod.yml或环境变量里演示项目虽然无所谓但好习惯提前养成。第三业务代码里一定有清晰的注释尤其是订单状态流转和库存扣减这两块。这里逻辑复杂过了一个月回头再看你自己可能都想不起来当初为什么这样写。第四如果时间允许给项目补充一个统计接口。比如按品牌统计销量、按月份统计订单收入前端用 ECharts 画两张图整个项目的演示效果会立刻上一个档次。我这次是把统计放在后面做的发现对梳理业务很有效。这个汽车销售网站系统技术栈虽然不算新但每层都有足够的深度可以挖掘。SpringBoot 的自动配置和拦截器、MyBatis 的动态 SQL 与缓存、Vue3 的响应式原理、MySQL 的事务与索引这些都是面试官喜欢细问的点。你把它真正跑通把每一步为什么这么做讲清楚收获会比看几十个教程都大。我个人做下来的体会是全栈项目最锻炼人的其实不是某个框架的 API而是把数据从数据库一路送到页面的那套流转逻辑这套逻辑想明白了再换什么技术栈都只是工具层面的替换而已。