ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue电动车租赁管理系统:从表结构到前后端联调实战

SpringBoot+Vue电动车租赁管理系统:从表结构到前后端联调实战 如果你开过电动车租赁店或者在学校做项目时接触过这类需求应该知道手动记租车、还车、押金那套流程有多痛苦。我刚好用SpringBootVue做了一个电动车租赁管理系统把车辆库存、订单计费、会员管理全部收到一个后台里。这篇文章就唠唠这个系统怎么设计、怎么落地从表结构到核心流程再到前后端联调时踩过的坑都一起端出来适合正好在写类似系统的人参考。这套系统本质上干的事情就三件管车、管订单、管钱。车辆有哪些、现在在谁手里、什么时候该还、押金收了多少钱、租金怎么算全都在线化。后端用SpringBoot处理业务和接口前端用Vue做页面前后端分离后改页面不影响接口加个移动端也能复用同一套API。下面按我实际开发的顺序来聊。1. 需求拆解与系统整体设计1.1 电动车租赁业务的真实痛点先说业务场景。一家门店可能有几十台电动车有铅酸电池的有锂电池的日租金从三四十到上百不等。以前常见的操作是纸笔登记租车人留个电话、身份证号交现金押金还车时再人工算租金。听着简单真做起来问题一堆车被谁租走了、什么时候还全靠脑子记一不小心就租重了。租金计算规则不统一按天算还是按小时算逾期怎么加价全是口头约定。押金和租金混在一起退款的时候说不清楚。到了月底想统计哪款车跑得最快哪个时段订单最多无从下手。所以管理系统最核心要求不是花哨而是“状态准确”和“账目清晰”。车辆状态必须实时可查订单必须能追溯计费必须自动完成。1.2 系统功能模块划分我按用户角色和业务流程把系统拆成这样几个大块模块主要功能说明系统管理登录、用户管理、角色权限区分管理员和普通用户/操作员车辆管理车辆信息维护、上架下架、状态查询维护车辆品牌、型号、车牌、日租金、押金租赁订单下单、取车、还车、取消、逾期处理核心业务流转计费管理租金计算、逾期费用、押金退还涉及金额操作必须严谨客户管理租客信息、租赁历史身份证、电话号码、信用备注统计报表车辆使用率、营收汇总、订单趋势用图表展示辅助决策前端的页面基本围绕这些模块来设计。管理员端用侧边栏导航加上顶部状态栏列表页放查询条件和表格编辑用弹窗这种结构做后台管理最顺手。1.3 为什么选SpringBootVue这套组合这个选型在现在的开发环境里属于“稳妥且常见”的组合。SpringBoot把SSM那套繁琐的配置收敛了内置Tomcat一个Application类就能启动非常符合中小型系统的后端要求。Vue则解决了一个很实际的问题数据驱动界面车辆状态一变页面上的按钮和文字马上就跟着变不用像JSP那样频繁刷新。更重要的是前后端分离带来的好处。后端只负责输出JSON前端只用管渲染两边可以并行开发。我这边后端接口还没写完前端可以用Mock数据先画页面前端页面有问题也不影响后端接口。后期如果想加个微信小程序端直接复用后端的Controller接口工作量非常小。2. 数据库设计与核心表结构2.1 核心实体关系梳理设计数据库时我画了很久的实体关系图最后敲定四张核心表车辆表、用户表、租赁订单表、费用流水表。业务逻辑全部围着租赁订单转。一个用户可以有多个订单一辆车在不同时间也可以被不同用户租用所以订单表是关键。订单表通过外键关联vehicle_id和user_id但注意不要真的建数据库外键约束因为后期分库分表或删除数据时会很别扭我一般在应用层保证数据一致性。费用流水单独一张表而不是把押金退还记录塞在订单表里是因为一笔订单可能产生多条资金操作租车时收押金、还车时收租金、最后退押金以及可能的逾期补扣。用流水表记录每一笔资金变动查账时一目了然。2.2 关键表SQL示例下面是精简后的核心建表语句实际开发可以根据需要加字段比如车辆图片URL、备注等。CREATE TABLE vehicle ( id bigint NOT NULL AUTO_INCREMENT, plate_no varchar(20) NOT NULL COMMENT 车牌号, brand varchar(50) DEFAULT NULL COMMENT 品牌, model varchar(50) DEFAULT NULL COMMENT 型号, status tinyint NOT NULL DEFAULT 0 COMMENT 状态:0空闲,1已租,2维护,3下架, daily_price decimal(10,2) NOT NULL COMMENT 日租金, deposit decimal(10,2) NOT NULL COMMENT 押金, battery_type tinyint DEFAULT 1 COMMENT 电池类型:1铅酸,2锂电, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_plate_no (plate_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE rent_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(30) NOT NULL COMMENT 订单编号, user_id bigint NOT NULL COMMENT 租客ID, vehicle_id bigint NOT NULL COMMENT 车辆ID, start_time datetime NOT NULL COMMENT 取车时间, plan_return_time datetime NOT NULL COMMENT 预计还车时间, actual_return_time datetime DEFAULT NULL COMMENT 实际还车时间, rental_days decimal(5,1) DEFAULT NULL COMMENT 租用天数, total_amount decimal(10,2) DEFAULT NULL COMMENT 应收总额, deposit decimal(10,2) DEFAULT NULL COMMENT 押金, status tinyint NOT NULL DEFAULT 0 COMMENT 状态:0待取车,1使用中,2已归还,3已取消,4已逾期, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_vehicle_id (vehicle_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表里我加了一个rental_days字段用来冗余存租用天数。因为租金计算结果可能被报表或者历史记录复用如果每次都要重新根据时间计算会很麻烦。但要注意这个字段必须根据实际还车时间和取车时间算出后写入不能靠前端传防止被篡改。费用流水表如下CREATE TABLE payment_record ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(30) NOT NULL, type tinyint NOT NULL COMMENT 1押金,2租金,3退款,4违约金, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 支付状态:0未支付,1已支付,2已退款, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 状态机设计租赁单状态流转租赁订单的状态是整个系统的灵魂。我把它定义为五个状态待取车、使用中、已归还、已取消、已逾期。要注意逾期不是一个终态而是“使用中”加上了一个标志但如果不单独设状态列表里很难一眼看出哪些单子超时了。所以我在状态上用5表示逾期并在业务代码中提供定时任务将“使用中且已超过计划还车时间”的订单改成逾期状态。状态流转规则下单支付押金后待取车。用户在门店扫车取车使用中。还车完成并结算已归还。取车前用户取消已取消。使用中超过计划还车时间逾期。逾期后再还车已归还同时计算违约金。这套流转用Java枚举维护每个状态定义允许的操作比如“待取车”可以执行“取车”“取消”“使用中”只能执行“还车”。在代码里加了一个状态机校验避免非法跳转。3. 后端SpringBoot实现要点3.1 工程结构与统一响应封装后端我习惯按分包划分controller、service、mapper、entity、config、common。先定义统一返回对象这样前后端联调时不用每个接口讨论返回格式。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.setCode(500); r.setMessage(msg); return r; } }统一响应不是花架子后面遇到异常、权限拦截、参数校验全都能复用。配合全局异常处理器前端只需要判断code是不是200其他情况统一弹错误提示就行。3.2 JWT登录认证与权限控制一开始我用Session但发现前后端分离后Session的跨域问题很烦要配SameSite还要维护会话不如JWT方便。JWT把用户信息编码在Token里后端不用存状态适合接口放给多个客户端用。依赖就加一个jjwt登录接口用用户名密码换取TokenString token Jwts.builder() .setSubject(userId.toString()) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();后端写一个拦截器对所有/api/**接口校验Token解析失败直接返回401。同时拿到当前用户信息放入ThreadLocalService层就能通过工具类拿到操作人。这里要说一个我踩过的坑JWT的secret千万别写死在代码里否则别人反编译jar包就能伪造Token。我后来放在配置文件里部署时用环境变量注入。3.3 租赁核心流程下单、还车、计费下单车这个操作代码看似简单其实并发问题严重。两个操作员同时看到一辆“空闲”的车都点了租出如果不做控制订单会重复。我在下单时使用数据库原子更新来占车Transactional public OrderVO createOrder(Long vehicleId, Long userId, Date planReturnTime) { // 先查车辆信息 Vehicle vehicle vehicleMapper.selectById(vehicleId); if (vehicle null || vehicle.getStatus() ! 0) { throw new BizException(车辆不可租); } // 原子更新车辆状态 int update vehicleMapper.updateStatus(vehicleId, 0, 1); if (update 0) { throw new BizException(手慢了一点车辆已被租走); } RentOrder order new RentOrder(); order.setOrderNo(generateOrderNo()); // ... 设置其他字段 rentOrderMapper.insert(order); return toVO(order); }update语句是UPDATE vehicle SET status 1 WHERE id ? AND status 0这一步在数据库层面就避免了超卖。千万不要先查状态再更新并发下必然出问题。还车结算的逻辑稍微多点根据订单id把状态从“使用中”改成“已归还”。计算实际租用天数规则是不足24小时按一天算超过24小时按整天加半天取整。这个规则要写在后端前端只展示。如果还车时间晚于计划还车时间额外计算违约金。更新订单金额写入费用流水。核心计费片段long hours (actualReturnTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); BigDecimal rentalDays BigDecimal.valueOf(Math.ceil(hours / 24.0)); BigDecimal rentAmount vehicle.getDailyPrice().multiply(rentalDays);这里要注意BigDecimal不能用double算钱否则分以下的精度会出问题。所有金额字段在Java中使用BigDecimal数据库用decimal(10,2)。3.4 定时任务订单超时与逾期催还逾期处理不能全靠人工看系统要能自动识别。我用Spring自带的Scheduled做定时扫描每隔10分钟跑一次Scheduled(fixedDelay 600000) public void checkOverdueOrders() { ListRentOrder overdueOrders rentOrderMapper.selectOverdueOrders(new Date()); for (RentOrder order : overdueOrders) { order.setStatus(4); rentOrderMapper.updateById(order); // 可以发短信或站内信这里留接口 } }定时任务在单机环境下很好用。如果后期要部署多实例就要考虑分布式锁了否则两个实例会重复处理同一批订单。我在第一版就遇到过重复发送催还消息的情况后来加了Redis的分布式锁才解决。4. 前端Vue页面与交互实现4.1 项目初始化和路由设计前端我用的Vue3 ViteUI库用的Element Plus。Vite比Vue CLI快不少开发体验好。初始化命令npm create vitelatest rent-app -- --template vue cd rent-app npm install element-plus axios vue-router4 pinia路由在设计时分成两块登录页和后台主框架。主框架里用嵌套路由侧边栏菜单对应/dashboard仪表盘、/order/list、/vehicle/list、/customer/list、/report等。权限这块前端不能用路由守卫硬撑着真正的权限要在后端接口控制。前端路由守卫只做一件事没有Token就跳转到登录页有Token就放行。角色对应的菜单渲染用v-if控制管理员的菜单多一个“用户管理”。4.2 Axios封装与接口联调联调时最烦的是每个接口都要写header、处理错误。我用Axios实例统一封装import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )这里加了一个关键点把res.data直接返回业务页面拿到的就是业务数据不需要每次都写.data。如果后端返回分页对象页面直接用{records,total}。4.3 核心页面租车下单与车辆状态展示车辆列表页面我用了卡片布局每辆车显示图片、车型、日租金、状态标签。空闲车辆显示“立即租”按钮已租车辆显示“还车”按钮。这里的核心是状态与按钮的联动。下单弹窗里用户输入计划还车时间前端根据日租金实时计算预估金额。计划还车时间用el-date-picker设置禁用过去的日期。预估金额只是一个展示最终金额以后端计算为准。车辆状态变化后比如还车成功前端不能光靠刷新页面而是在接口调用成功后直接修改当前数组里对应项的状态值。这个体验比刷新整个列表好很多数据是响应式的按钮状态立刻变化。4.4 组件化与状态管理页面多了以后我发现车辆卡片和订单表格有不少重复逻辑于是抽了两个组件VehicleCard和OrderTable。组件接收一个对象通过defineProps声明类型内部使用computed计算显示状态。状态管理用Pinia主要存当前登录用户信息和权限点。我没有把所有接口数据都放进Pinia因为订单、车辆这类动态数据更适合用页面级状态管理避免长期占内存。Pinia只承担用户信息这种跨页需求。前端有一个让我印象很深的细节后端返回的时间字段是yyyy-MM-dd HH:mm:ss但Element Plus的日期组件默认绑定Date对象提交时要先格式化。我写了一个工具函数export function formatDateTime(date) { if (!date) return null const d new Date(date) const pad (n) String(n).padStart(2, 0) return ${d.getFullYear()}-${pad(d.getMonth() 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} }前端所有日期字段统一走这个函数避免了“2024-08-01T10:30:00.000Z”这种格式传到后端被解析失败的问题。5. 联调部署与常见问题排查5.1 前后端联调环境配置开发阶段最烦的是跨域。我同时启动后端8080和前端5173前端请求/api开发环境用Vite的代理转发到后端// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端代码里就不会写死后端地址生产环境部署后Nginx再把/api转发到后端前端代码都不用重新改。后端除了允许跨域还需要注意一个SpringBoot版本问题。Spring Boot 3.x和2.x在处理跨域配置时略有差异但核心都是一个CorsFilter。我用的是指定来源加白名单的方式不建议直接允许所有来源否则线上不安全。5.2 常见报错与解决思路我把开发中碰到的高频问题整理成了表大多数问题都能在五分钟内定位。现象原因解决方案前端拿到订单ID精度丢失后端雪花ID是Long类型超过JS安全整数在Jackson配置中把Long序列化为String日期传过来解析失败前端传ISO格式后端配置的pattern不匹配统一使用spring.jackson.date-formatyyyy-MM-dd HH:mm:ss或前端格式化后再传跨域请求被浏览器拦截后端没配置CORS或配置了不允许的Header检查Access-Control-Allow-Origin和Authorization登录接口一直401前端Token拼接少了“Bearer ”前缀统一在拦截器里拼好部署后静态资源404Vue Router用的history模式刷新时找不到路由Nginx配置try_files到index.html定时任务重复执行部署了两份应用实例引入Redis分布式锁Long转String这个坑很多人要踩一下才懂。雪花ID是一个19位数字前端JS的Number类型只能安全表示到2^53所以超过这个范围就会变成不精确的科学计数法。我在后端全局配置Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }改完全局Long转String后所有接口里的ID字段都以字符串返回前端操作就没有精度问题。5.3 部署上线注意事项部署分成两部分后端jar包和前端dist文件夹。后端我用Maven打jar包mvn clean package -DskipTests java -jar rent-server.jar --spring.profiles.activeprod前端构建后生成dist目录npm run build然后把dist放到Nginx的html目录下配置如下server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里try_files $uri $uri/ /index.html是为了解决Vue Router的history模式刷新404问题第一次部署忘了加这行页面刷新一下整个白屏。数据库连接配置里时区一定要加serverTimezoneAsia/Shanghai否则MySQL连接报一大堆错。另外生产环境的JWT密钥、数据库密码不要写在yml里用环境变量引用Nginx配置里也别忘了开启HTTPS的跳转现在线上要求越来越严格毕竟涉及用户手机号、身份证号这类敏感信息。6. 做这套系统踩过的坑与后续扩展6.1 设计和编码阶段的坑第一个坑是押金和租金混在一个字段里。刚开始我没想清楚租车订单里只放了total_amount还车时又把押金和租金加加减减结果流水对不上。后来拆分成两个概念押金是冻结金额租金是消费金额退款时只退押金租金单独计算。前期数据模型没理清后期接口越写越别扭。第二个坑是“还车”这个操作没有做防重复。两个操作员同时点还车如果没有事务和状态判断订单会被结算两次。我在还车方法上加了状态条件更新int count rentOrderMapper.finishOrder(id, 1, completedStatus, now); if (count 0) { throw new BizException(该订单当前状态不允许还车); }这个和抢车是同一个思路把判断和更新放在一条SQL里用数据库的行级锁保证并发安全。第三个坑是车辆维护状态被忽略。线下场景中车不是能一直骑的有的车会送修。我在最初版本里只有“空闲”和“已租”两个状态结果一车子坏了还挂在列表里被下单。后来加上“维护”状态并且维护中的车不能下单列表页用灰色标签显示。6.2 后续可以加的功能这套系统做下来基础租赁闭环已经通了但离真正的商业运营还有很长一段路。我自己列了几个后续扩展方向用户端小程序让用户在手机上预约下单到店取车。后端接口已经够用小程序直接调同一个API。押金在线支付接入微信/支付宝支付押金在线冻结还车后自动原路退回。这个改动主要增加支付回调逻辑订单流程不变。车辆定位与轨迹给电动车装上GPS模块后台实时查看车辆位置和轨迹回放这是租赁场景里非常实用的功能。优惠券与会员卡按用户消费金额分成等级租车打折扣可以提升复购率。报表导出把统计结果导出成Excel店长不需要每天登录系统看直接看邮件报表就行。这些扩展里GPS轨迹的对接难度最大因为要处理设备协议和地图服务但架构上并不冲突仍然是在订单和车辆上追加位置记录表。最后再分享一个小技巧如果你也在做这类管理系统不要一开始就纠结“大而全”。先把“车辆状态切换”和“订单计费”这两个核心场景跑通再把用户和报表加上去。系统做出来不是用来看的而是要让实际操作的人觉得比纸笔记录轻松。我现在这家店已经用这套系统跑了大半年最直接的感受就是月底对账从半天缩短到十分钟车辆丢失和逾期也有人自动盯着了。技术不是越高越好能真正把业务捋顺才算值得。
返回列表