最近帮朋友收尾了一个汽车销售后台管理系统从需求梳理、数据库设计到前后端联调、部署上线前前后后折腾了一个多月。项目用的是 Spring Boot Vue 这套前后端分离的组合整体跑下来很稳也踩了不少文档里找不到的坑。这篇文章就把整个过程拆开聊聊包括技术选型、核心模块、关键代码、以及那些容易让人卡壳的细节问题。不管是准备做毕设还是公司内部要搭类似系统都可以直接借思路。这个系统的核心场景其实很明确一线销售录客户、开销售单、查库存管理员维护车辆档案、员工账号、报表数据老板看经营看板。说白了就是一个带权限的车商业务管理平台加上车辆图片、保单信息、售后记录这些附属数据。1. 项目整体拆解汽车销售后台到底该管哪些事1.1 系统边界与角色划分很多新手拿到这类需求第一反应就是“不就是增删改查嘛”真做起来才发现完全不是这么回事。汽车销售后台跟普通的商品管理后台差异很大车是高价低频商品业务流程长、参与角色多数据字段也比普通商品复杂得多。我当时梳理下来的角色大概分成四类超级管理员管全局和账号权限销售顾问负责写跟进记录、录入订单、上传合同和付款凭证财务人员审核订单、登记回款和开票信息仓库/交付专员维护车辆状态、办理出库和交车登记。这四类角色的数据权限和操作范围都不一样在设计表结构和权限模型的时候必须提前定清楚。如果等代码写了一半才想起来要加角色后面改起来会非常痛苦。1.2 核心业务流程业务主链路很清晰客户进店或线上留资后销售顾问录入客户信息并建立跟进记录之后客户看中某辆车销售创建“意向单”锁定车辆预付款财务确认收款后生成正式销售合同再流转到仓库做车辆出库、交付和售后建档。这里有一个非常关键的细节车辆状态机。车辆在一个生命周期里会经历在库、已预订、已售、交付完成这几个状态所有操作都必须围绕状态流转做校验。比如一台已经预订的车不能再被其他销售下单一台交付完成的车不能再参与库存盘点。状态机如果做得松散就会出现一车多卖这种严重问题。我建议在数据库层面把状态字段设计成枚举后端接口统一用状态校验逻辑不能只靠前端控制。2. 技术选型Spring Boot Vue 为什么是这样组合2.1 后端框架选型逻辑后端用 Spring Boot基本没什么争议。它对中小型管理系统的开发效率确实高内嵌 Tomcat启动方便Starter 机制让依赖管理变得简洁配合 Maven 构建项目大部分场景下不需要手工管理复杂的依赖版本。我建项目用的 Java 8 Spring Boot 2.7.x不会刻意追求新版原因后面避坑篇会说。MyBatis-Plus 是一个值得提前考虑的组件。这个项目里我用了它做数据访问层内置的单表 CRUD 方法、分页插件、逻辑删除功能极大减少了重复代码。举个例子车辆档案的分页查询带条件筛选直接用LambdaQueryWrapper就能写得很优雅不需要手写大量 XML 映射文件。不过要提醒一句MyBatis-Plus 只适合单表复杂度和简单关联多表 join 我还是写了原生 SQL。别什么都指望框架替你完成复杂的统计报表和跨表查询老老实实写 SQL 才是正道。2.2 前端框架与 UI 方案管理后台这种项目用 Vue 很合适因为它组件化开发效率高、生态成熟。项目里我用了 Vue 2 加 Element UI。为什么不是 Vue 3因为团队更熟 Vue 2而且 Element UI 生态稳定。如果是全新团队没有历史包袱选 Vue 3 加 Element Plus 也没问题差别主要体现在 Composition API 写法和组件引入方式上核心业务逻辑的设计思路完全一致。路由这块用的 Vue Router权限控制思路是前端路由守卫 后端菜单权限接口。用户在登录时拿到 token 和角色标识前端根据角色动态生成可访问的路由表没有权限的页面根本不注册进路由实例。有人习惯把全部路由写死只靠菜单隐藏来做权限这种方案安全性太弱懂点前端的用户直接改 URL 就能进入无权限页面。2.3 工程结构规划前后端分离的工程结构我用的是经典三层结构前端独立在car-sales-web目录后端在car-sales-server目录两者通过 RESTful 接口通信开发阶段利用 Vite 或 webpack-dev-server 的代理解决跨域。后端内部按模块分包controller层只做参数接收和响应封装service层处理业务逻辑mapper层负责数据库操作另外单独划分config、common、utils、entity、dto、vo这些包。我用了一段时间之后发现entity数据库实体和vo视图对象一定不要混在一起。数据库表里可能存敏感字段比如密码、内部备注直接序列化返回给前端风险很大。正确的做法是 controller 返回vo对象通过BeanUtils.copyProperties或者MapStruct转换。虽然多写几个类但安全性好很多后期维护也清晰。3. 数据库设计与核心表结构3.1 车辆档案与库存设计车辆档案是整个系统的地基。这块涉及的字段非常多除了基础的车系、车型、外观颜色、内饰颜色、指导价、成交价、库存数量外还有车架号(VIN)、发动机号、生产日期、到店时间、存放库位等仓储维度信息。我建表的时候把车辆基础信息、库存明细、车辆图片拆成了三张表car_info保存车系、品牌、型号、指导价这类相对静态的共性信息car_inventory保存每一台具体车辆以 VIN 码为唯一业务标识记录当前状态、库位、到店时间car_image保存车辆多张图片一张车可以有外观图、内饰图、细节图。为什么要拆库存表因为一个车系可能同时有十几台车在库每台车的 VIN、状态、库位各不相同。如果不拆车辆基础信息会被重复存储好几份后续维护车系价格时也要改很多条记录。拆开后更新指导价只需要改car_info库存表自然共享这个数据。3.2 客户与销售订单核心表客户表customer主要记录姓名、电话、微信、意向车型、客户来源、跟进状态。销售订单表sale_order与客户表是 多对一 的关系一个客户可以下多个订单但正常情况下只有一个有效订单所以我在订单表里加了一个字段order_status包括意向、已收定金、合同签订、已交付、已取消这些状态。关于定金建议单独建一张order_payment表记录每一笔收款。很多业务场景需要知道这单收了多少钱、还剩多少钱、有没有退款如果只在订单表里放一个总金额字段很难追踪多笔收款流水。付款方式也要记录比如现金、刷卡、转账、分期方便财务对账。我设计时还会在订单表里冗余保存“客户姓名”和“销售员姓名”虽然可以通过外键关联查出来但查询列表页的时候每次 join 两三个表会很麻烦。冗余字段会带来一致性问题所以更新客户或销售员信息时要记得同步更新订单表。这个取舍在实际开发中非常重要查询性能优先。3.3 权限模型设计权限用经典的 RBAC 模型用户表sys_user、角色表sys_role、菜单表sys_menu和两张关联表sys_user_role、sys_role_menu。不搞复杂的 ABAC 或者数据权限插件因为管理后台角色可控、功能边界清晰RBAC 已经足够覆盖。菜单表除了单纯的菜单项我还做了按钮级权限。比如“删除订单”按钮、需要order:delete权限标识前端根据用户权限集合决定是否渲染该按钮后端在接口层同时校验权限前端只是体验优化后端才是真正的安全防线。4. 后端关键实现鉴权、接口与上传4.1 基于 JWT 的登录鉴权登录鉴权用的 JWT交互方式很简单用户提交账号密码后端校验通过后生成一个 token前端存到 localStorage后续请求的 Authorization 头带上 token。后端用拦截器统一放行白名单比如/api/auth/login其余接口校验 token 有效性。生成 token 的代码大致是这样// 使用 jjwt 0.9.1 Jackson String token Jwts.builder() .setSubject(userId.toString()) .claim(username, user.getUsername()) .claim(roleCode, user.getRoleCode()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 8)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();这里我要强调JWT 里的敏感权限信息不要放太多通常放一个 userId 和一个 username 就够。因为 JWT 的 payload 只是 Base64 编码不是加密任何人拿到 token 都能解出来。接口需要判断角色的时候用 userId 去查 Redis 缓存或数据库拿最新权限信息比依赖 token 里写死的声明更安全。登录接口还有一个细节同一个账号重复登录的处理。项目里有两种方案一种是颁发新 token 同时把旧 token 拉黑强制单端登录另一种是允许多端在线。如果老板有平板和手机同时在线的需求就选第二种配合 Redis 存储 token 白名单。我的项目简单处理允许在线但将 token 的过期时间设为 8 小时。4.2 核心接口写法与参数设计车辆销售这类系统绝大多数接口都是列表查询 新增 修改 详情业务流程集中在订单状态流转。接口统一返回一个ResultT结构体包括 code、message、data 三个字段错误用业务码区分。例如车辆库存不足时返回1001前端拿到非零 code 后弹出对应的错误提示。订单创建接口的代码逻辑校验车辆状态为“在库”校验客户信息完整生成订单号修改车辆状态为“已预订”插入支付流水。这几步必须放在一个事务里任何一步失败都要回滚。否则可能出现订单创建了但车辆状态还是“在库”这种数据不一致。Transactional(rollbackFor Exception.class) public SaleOrderVO createOrder(CreateOrderDTO dto) { CarInventory car carInventoryMapper.selectById(dto.getCarId()); if (car null || car.getStatus() ! CarStatus.IN_STOCK.getCode()) { throw new BizException(车辆不存在或已被预订); } // 生成订单号例如 SALES20240612153000001 SaleOrder order new SaleOrder(); order.setOrderNo(generateOrderNo()); order.setCustomerId(dto.getCustomerId()); order.setCarId(dto.getCarId()); order.setAmount(dto.getAmount()); order.setOrderStatus(OrderStatus.DEPOSIT_PAID.getCode()); saleOrderMapper.insert(order); // 车辆状态改为已预订 car.setStatus(CarStatus.RESERVED.getCode()); carInventoryMapper.updateById(car); // 插入收款记录 orderPaymentMapper.insert(buildPayment(order.getId(), dto.getPaidAmount())); return convertToVO(order); }接口层面还应该做统一参数校验直接用Validated加自定义注解。别把校验逻辑写在业务代码里一堆if (xxx null)会严重影响代码可读性而且易漏。4.3 文件上传从本地目录到 MinIO车辆图片上传最初我打算直接存本地目录代码最简单后端接收 MultipartFile 后写到配置的目录中静态资源映射暴露访问路径。但在实际运行中发现本地上传有两个麻烦一是后端实例磁盘空间有限图片一多就吃紧二是如果后续要部署多个后端实例做负载均衡图片只存了某一台机器请求打到另一台就会 404。所以后来换成了MinIO。MinIO 是兼容 S3 协议的对象存储部署轻量只需要一个 docker 容器就能跑起来。相关热词里也总有人把 MinIO 加入到 Spring Boot 项目流程其实固定引入minioSDK配置 endpoint、accessKey、secretKey、bucket封装一个上传工具类。上传接口分发到 service 层调MinioClient.putObject返回文件 URL。一个容易踩坑的点bucket 的访问权限策略。如果希望图片 URL 能直接匿名访问要在 MinIO 控制台设置 bucket 的访问策略为 public或者在前端增加签名 URL 逻辑。我这边图纸都设成 public省去预签名 URL 的折腾内网系统问题不大。安全性要求高就设置私有读写生成预签名 URL 给前端。5. 前端关键实现路由、状态管理与页面搭建5.1 Vue 环境配置与基础依赖前端搭建从 Vue 环境开始一般是用 Vue CLI 或 Vite 初始化项目。我用的方式比较常规vue create car-sales-web npm install axios npm install element-ui npm install vue-router3 npm install vuex3 npm install sass sass-loader -D这里的版本号要根据 Vue 版本严格对齐比如 Vue 2 对应 Vue Router 3Vue 3 对应 Vue Router 4。很多新手在这上面被坑装错版本后路由跳转报各种奇怪错误。装完依赖后要配置代理转发。开发环境前端跑在 8080后端接口跑在 8081跨域是必然的。Vue CLI 项目里在vue.config.js中配置 devServer proxy 指向http://localhost:8081这样接口请求统一走/api前缀避免浏览器跨域拦截。5.2 登录页面与路由守卫登录页面的核心逻辑其实很模板化表单校验通过后调登录接口拿到 token 存起来拉取用户信息跳转到首页。我习惯把用户信息和 token 都存到 Vuex 里并且持久化到 localStorage刷新页面后从 localStorage 恢复。路由守卫参考代码如下router.beforeEach((to, from, next) { const token localStorage.getItem(admin_token); if (to.path /login) { next(); return; } if (!token) { next(/login); return; } const userInfo store.state.user.userInfo; if (!userInfo || to.path /) { store.dispatch(user/fetchUserInfo).then(() { next(); }).catch(() { store.commit(user/logout); next(/login); }); return; } next(); });这里还有一点经验动态路由的生成不要放在全局守卫里异步执行否则刷新页面时菜单闪烁或跳转白屏会比较明显。我建议登录后立刻调菜单接口生成路由把动态路由表存到 Vuex。刷新时在main.js入口提前 await 用户信息和动态路由恢复再进行 Vue 实例挂载体验会平稳很多。5.3 订单列表与车库存页的细节订单管理页面是整个前端的重头戏。列表页用 Element UI 的el-table展示数据上面放筛选条件订单状态、客户姓名、销售员、时间范围右侧一个“新增订单”按钮。表格列一般为订单号、客户、车型、成交价、订单状态、创建时间、操作按钮。关于状态显示数据库存储的是order_status这种数字编码前端显示必须翻译成中文。常规做法是维护一个常量映射如{ 1: ‘意向’, 2: ‘已收定金’, 3: ‘已签约’, 4: ‘已交付’ }然后通过el-tag绑定不同的type变色。我习惯把状态字典放到一个独立 js 文件里而不是散落在组件中这样以后加一个状态只要维护一处。车辆库存页面的核心是库存看板能力按车系分组显示库存数量、在途数量、已订数量表格每个车型可以展开查看 VIN 明细。Vue 做展开行很方便el-table-column typeexpand template slot-scopeprops el-table :dataprops.row.vinList sizemini !-- VIN、车架号、状态列 -- /el-table /template /el-table-column需要注意展开数据的加载有两种策略一是页面初始化时后端一次性返回每个车型的 VIN 列表适合数据量不大二是点击展开时懒加载。我的项目车辆总数几百台选择一次性返回逻辑简单且交互响应快。如果以后库存量大再改成懒加载也不难。6. 常见问题与排查实操记录6.1 后端侧高频问题数据库连接失败或数据库方言不兼容开发用的是 MySQL后来生产环境数据源切换部分 SQL 方言有差异。解决办法是把 SQL 集中到 mapper.xml避免在 Java 代码里拼字符串同时把数据库关键词order、desc等统一加反引号或改用实际表名字段名防止关键字冲突。Spring Boot 版本太高导致的兼容性问题这点特别想提醒看热词的老铁不要一上来就追最新版。比如 Spring Boot 3.x 要求 Java 17部分三方 starters 没有及时适配整合 MyBatis-Plus、MinIO SDK 时会遇到类加载或方法签名不一致。我的建议是团队环境统一就选一个稳定版本不要混用。Spring Boot 2.7.x Java 8 对绝大多数后台管理项目完全够用等明确需要新特性再升级。文件上传到 MinIO 后拿不到 URL排查顺序是先看 bucket 是否存在再看文件是否真的上传成功最后检查访问权限策略。很多人卡在“代码没报错但 URL 打开是 403”基本就是 bucket 权限问题。在 MinIO 控制台把策略设为download或者public模型后要先清理浏览器缓存因为浏览器会缓存 403 响应。6.2 前端侧高频问题Vue 路由跳转后页面不刷新最常见原因是路由配置里复用了同一个组件实例。比如从订单详情 A 跳转到订单详情 Bdetail 页面的参数变了但组件没有重新初始化。解决方法是 watch$route的变化重新拉数据或者给router-view加:keyroute.fullPath。跨域配置没生效代理配置要改动后重启 dev serverVue CLI 的代理是启动时读取的热更新不会重新挂载。我吃过这个亏配好代理后发现请求还是 502最后重启项目才正常。接口报错但页面没提示前端请求 axios 封装上要统一拦截错误码非 0 的 business code 必须弹Message.error否则用户以为按钮没反应体验很差。如果遇到接口返回状态码 401则自动跳转登录页并清空 localStorage。6.3 部署与运维问题Spring Boot 项目打包成 jar 后部署用 Docker 写个简单镜像就行。但有一个细节前端构建产物不要单独部署。推荐方案是把 Vue 打包后的 dist 文件夹直接拷贝到 Spring Boot 的static目录这样整个系统只需要一个后端服务端口部署简单也不存在 CORS 问题。如果公司有独立的 Nginx也可以把 dist 放到 Nginx反向代理后端接口这个方案适合前后端需要独立扩展的场景。热词里还有人问 Docker 部署 Spring Boot 项目失败多半是 Dockerfile 里基础镜像的问题。官方镜像下载经常比较慢建议使用国内镜像源并尽量精简单层构建减少不必要的 RUN 命令避免构建缓存规避导致的问题。收尾用的几点真实经验项目上线后这几个月我自己复盘了几条心得写出来可能对大家更有用第一状态机是整个系统的灵魂。车辆和订单的状态变化一定要用枚举串起来不要用简单 int 字段随意流转否则后期统计报表对不上账。前端页面可以允许任意操作按钮但后端必须有状态流转守卫这是系统稳定性的底线。第二前后端接口字段名一定要统一。开发中我老是遇到前端拿到的字段是car_name后端返回明明是carName联调起来浪费时间。最好定一下命名规范JSON 交互用驼峰数据库字段用下划线MyBatis-Plus 开map-underscore-to-camel-casetrue一次配置全部搞定。第三表单的“创建人”和“创建时间”别偷懒。所有业务表加上create_by、create_time、update_time这三个公共字段。销售系统日后面临最多的就是数据追溯问题比如“这台车的意向单是谁建的、什么时候建的”没有这些字段排错基本靠猜。MyBatis-Plus 的自动填充功能能帮你有无忧维护这些字段花十分钟配好后面省大量时间。汽车销售管理系统的覆盖面看着不大真要做得完整可靠涉及的工程细节一点都不少。希望这篇实操总结对你搭建类似系统有实际帮助至少让你少走我走过的那些弯路。