SpringBoot+Vue玩具租赁系统实战:订单状态机与库存锁定设计

发布时间:2026/9/9 14:20:04
SpringBoot+Vue玩具租赁系统实战:订单状态机与库存锁定设计 做玩具租赁系统前我以为这不过是个简化版电商商品上架、用户下单、后台发货完事。真正动手才发现玩具租赁和普通售卖有着本质区别——卖出去的货不用回来租出去的玩具必须回来而且还得保证“回来的时候还能用”。这个“必须回来”四个字把订单状态、库存锁定、押金流转全部改写了。这篇文章从零到一拆解一个基于 SpringBoot Vue MySQL MyBatis 的玩具租赁管理系统涵盖表结构设计、后端核心接口实现、前端页面落地、部署联调踩坑。适合正在做毕业设计、Java 方向就业项目或者想了解前后端分离项目完整流程的同学直接参考。我会把实际开发中绕过的坑、答辩时容易被追问的点一并写出来。1. 选型不是跟风这四件套到底各自解决什么问题网上这类毕设项目十个有八个是 SpringBoot Vue但很多人只是“大家都这么用所以我也这么用”。真到了要讲清楚“为什么这样选”的时候支支吾吾说不明白。先把技术选型的逻辑捋清楚因为这直接关系到你的项目在答辩或者面试时能不能经得住追问。1.1 SpringBoot把组装成本压到最低Java 后端框架里SSHStruts2 Spring Hibernate是上古时代的搭配SSMSpring SpringMVC MyBatis是上一代主流SpringBoot 则是把 SSM 的“手动组装”变成了“约定大于配置”。打个比方SSM 时代你要自己配置 web.xml、Spring 容器、事务管理器、视图解析器像自己拼一台台式机每个零件都要精挑细选SpringBoot 就像买品牌机常用组件已经装好你只需要通过配置文件告诉它“我要什么”。对于玩具租赁系统这种典型 CRUD 密集型业务绝大多数功能无非是“查商品、创建订单、改状态、算押金”。SpringBoot 的核心价值在于内嵌 Tomcat不用单独部署 war 包一个java -jar就能跑自动配置机制把数据源、MyBatis、事务等常用场景的配置项大幅简化生态成熟跟 Vue 前端联调时天然支持 RESTful API加一个RestController就能把数据吐给前端1.2 Vue为什么不用 JSP 也不搞前后端分离早期 JavaWeb 项目喜欢用 JSP 直接在服务端渲染页面但 JSP 有一个致命伤前后端耦合太深改一个按钮样式要重新部署后端。Vue 是渐进式框架你不需要一开始就用它做全家桶但对于玩具租赁这种页面交互较强的项目Vue 的组件化开发能把“商品列表、订单状态、购物车”等模块拆成独立组件各改各的互不干扰。这套系统选择 Vue 2 Element UI或 Vue 3 Element Plus做后台管理界面理由很实在组件丰富表格、表单、分页这些租赁后台高频使用的 UI 组件开箱即用数据绑定是响应式的订单状态变了页面上的按钮文案、颜色、可操作性自动跟着变前后端分离后后端接口用 Postman 测好前端只管调 API联调效率高1.3 MySQL MyBatis租赁业务的表关系决定了 DAO 层的选择玩具租赁系统的业务实体不少用户、玩具商品、玩具实例每一件实体玩具、订单、订单明细、押金记录、损耗记录。这些实体间的关联关系比较密集比如一个玩具商品对应多个玩具实例一个订单对应一个用户和多个玩具实例一个订单经历多次状态变更下单、发货、租赁中、归还、完成MyBatis 的半自动 ORM 特性在这种场景下比 JPA/Hibernate 更有优势——你可以手写 SQL精确控制多表关联查询、动态条件拼接、复杂统计报表。而 JPA 在简单 CRUD 上确实爽一旦涉及多表联查、临时字段聚合写 JPQL 的体验远不如直接写 SQL 来得痛快。MySQL 则没什么争议开源免费、稳定可靠、大学里都学过招聘市场上这是基本功。这套系统用 MySQL 5.7 或 8.0 都能跑通只要注意驱动和时区配置的差异。2. 玩具租赁业务的表设计最核心的坑在“库存锁定”先说一个我初期的错误设计。第一版我按普通电商的思路设计了一个toy表里面放一个stock字段用户下单时stock - 1。结果同事一句话点醒了我“客户归还的那件玩具还是原来那件吗”普通商品卖出去就不用管了但玩具租出去后客户归还回来的可能是同一件也可能是磨损严重的同一件甚至可能丢了一个零件。如果你只维护一个库存数字损耗记录、押金扣费都无从谈起。2.1 玩具实例表Toy Instance是业务的地基正确的做法是把“商品”和“实物”分开建模toy商品表品牌、名称、适合年龄、原价、租金单价元/天、押金、封面图、描述toy_instance玩具实例表商品 ID、唯一编号、状态0 空闲 / 1 租赁中 / 2 维修中 / 3 下架、购买日期、当前损耗程度为什么要单独搞一张实例表因为每件实体玩具都有自己的生命周期。客户下单租的是“某一辆遥控车”不是“一辆抽象的车”。归还时你要能明确知道编号 TC-001 这辆车回来了电池盖有点松需要扣 5 元维修费。如果没有实例表你根本无法记录这个颗粒度的信息。实例表的设计还能解决一个电商里常见的超卖问题。所有可租状态状态为 0的实例在用户下单选中的那一刻就要立即改为“租赁中”从数据库层面保证同一件玩具不会被两个人同时下单。这比在 Java 代码里加synchronized或分布式锁简单可靠得多。2.2 核心表结构与字段设计下面给出这套系统的核心表清单和关键字段字段类型和注释都直接贴出来方便你直接拿去建库。用户表t_userCREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT BCrypt加密, phone VARCHAR(20) COMMENT 手机号, role TINYINT DEFAULT 0 COMMENT 0-用户 1-管理员, balance DECIMAL(10,2) DEFAULT 0.00 COMMENT 账户余额, status TINYINT DEFAULT 1 COMMENT 1-正常 0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT用户表;玩具商品表t_toyCREATE TABLE t_toy ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 玩具名称, category VARCHAR(50) COMMENT 分类, detail TEXT COMMENT 详细介绍, cover VARCHAR(255) COMMENT 封面图URL, rent_price DECIMAL(10,2) NOT NULL COMMENT 日租金, deposit DECIMAL(10,2) NOT NULL COMMENT 押金, total_count INT DEFAULT 0 COMMENT 总实例数, rent_count INT DEFAULT 0 COMMENT 已租数量, sales_count INT DEFAULT 0 COMMENT 累计租赁次数, status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT玩具商品表;玩具实例表t_toy_instanceCREATE TABLE t_toy_instance ( id INT PRIMARY KEY AUTO_INCREMENT, toy_id INT NOT NULL COMMENT 所属商品ID, instance_no VARCHAR(50) NOT NULL UNIQUE COMMENT 实物唯一编号, status TINYINT DEFAULT 0 COMMENT 0-空闲 1-租赁中 2-维修中 3-下架, purchase_date DATE COMMENT 采购日期, wear_level VARCHAR(20) DEFAULT 全新 COMMENT 磨损程度, remark VARCHAR(255) COMMENT 备注, KEY idx_toy_id (toy_id) ) COMMENT玩具实例表;这里rent_count为什么要冗余在商品表里因为“累计租赁次数”这个指标在首页统计、商品排行、用户评分这些场景下高频使用每次都COUNT(*)扫订单表会很慢。冗余一个字段用定时任务或事务内更新读到就是常数时间。这是典型的空间换时间设计。订单表t_orderCREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, user_id INT NOT NULL COMMENT 用户ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 总租金, deposit DECIMAL(10,2) NOT NULL COMMENT 押金总额, actual_amount DECIMAL(10,2) COMMENT 实收金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态机详见代码, rent_start DATE COMMENT 租赁开始日, rent_end DATE COMMENT 预计归还日, actual_return DATE COMMENT 实际归还日, address VARCHAR(255) COMMENT 配送地址, contact_phone VARCHAR(20), remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_status (status) ) COMMENT订单表;订单明细表t_order_itemCREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, toy_id INT NOT NULL, instance_id INT NOT NULL COMMENT 锁定哪一件实例, toy_name VARCHAR(100) COMMENT 快照名称, rent_price DECIMAL(10,2) COMMENT 下单时租金快照, deposit DECIMAL(10,2) COMMENT 下单时押金快照, rent_days INT COMMENT 租赁天数, subtotal DECIMAL(10,2) COMMENT 小计, KEY idx_order_id (order_id) ) COMMENT订单明细表;注意明细表里存了toy_name、rent_price、deposit这些快照字段。为什么要冗余因为商品价格、名称后续可能会调整但历史订单必须保持“下单当时的价格”否则财务对账时会出现说不清的差异。2.3 订单状态机租赁业务的心脏订单状态的流转是这套系统最核心的逻辑我设计了一个 9 状态的状态机0-待付款 1-待发货已付款后台发货 2-租赁中用户已收货租期开始 3-待归还租期已到但用户还未归还提醒状态 4-待确认归还用户已寄回/送回等待后台验收 5-已完成验收通过押金退还 6-已取消用户付款前取消或超时未支付 7-退款中验收有损耗需要扣除部分押金后退款 8-已关闭异常终止如长时间不归还状态机的好处是每一个状态都有明确的入口和出口后端在做“状态流转”时必须校验当前状态是否允许跳转到目标状态。比如只有状态为 0 的订单能改成 1而已经进入 2 的订单不能直接取消必须先完成归还流程。这个设计在答辩时是一个非常重要的加分点——它展示了你对业务的理解不只是“增删改查”而是有状态建模的思维。2.4 押金逻辑不是简单“收一笔退一笔”押金是玩具租赁特有的资金流转。用户下单时除了支付租金还要冻结或支付一笔押金。押金不等于收入它在订单完成或取消时需要原路退回或扣除损耗后退回剩余部分。实现上我建议在订单表里单独维护deposit字段跟total_amount分开。这样在后台“订单列表”可以一眼看出每笔订单租金多少、押金多少而不是把两者混在一个金额里再靠备注区分。押金退还的触发点有两个用户取消订单且仓库未发货全额退还押金 租金用户归还且后台验收通过全额退还押金租金不退如果验收发现玩具损坏按损耗扣减押金订单进入“退款中”状态后台填退款金额审核通过后退剩余押金。3. 后端实现订单状态机、押金逻辑与 MyBatis 的实战细节3.1 后端目录结构与控制层设计代码包结构按照“表现层 - 业务层 - 数据层”经典分层但我额外加了一个common包专门放统一返回结果、全局异常处理器、状态码枚举。别小看这个包它能让代码干净很多。com.toyrent ├── controller # 控制层用户、玩具、订单、后台管理 ├── service # 业务层接口 │ └── impl # 业务层实现 ├── mapper # MyBatis 数据访问层 ├── entity # 数据库实体 ├── dto # 前端交互对象VO ├── common # 统一返回结果、异常、常量、状态机枚举 └── config # 配置类CORS、拦截器、WebMvc统一返回结果的封装非常重要。前端 Vue 里 Axios 拦截器只认一种数据结构后端所有接口统一返回{ code: 200, message: success, data: { } }有了这个约定前端处理异常就非常舒服code 401跳登录页code 500弹错误提示。不用每个方法都写try-catch去猜返回格式。3.2 下单接口的并发安全实例锁定怎么实现下单是并发压力最大的接口两个用户同时看中同一件玩具实例必须先到先得。这个在一开始就被我列为“必须从数据库层面保证安全”的操作。下单接口的核心流程校验用户状态是否禁用、余额是否充足根据前端传的toyId和rentDays查询该商品下状态为 0空闲的玩具实例在事务内发起更新锁住实例关键 SQL 如下锁住的实例创建订单和订单明细扣减用户余额租金 押金更新商品表的rent_count和rent_countMyBatis 里锁住实例的关键 SQLupdate idlockInstance parameterTypemap UPDATE t_toy_instance SET status 1, lock_order_no #{orderNo} WHERE id #{instanceId} AND status 0 /update看这个 SQL更新条件里带上了status 0。MySQL 的行锁在更新时会把匹配的行锁住如果两个事务同时执行这条 SQL只有一个会成功影响行数为 1另一个影响行数为 0就能判断“该实例已被抢走”回到步骤 2 重新选别的实例或者提示用户“手慢了换一个吧”。这里我踩过一个大坑第一次用select * from t_toy_instance where toy_id ? and status 0 limit 1查出实例然后在 Java 代码里判断状态再 update。结果高并发测试时发现两笔订单拿走了同一件实例——因为两个事务可以同时 SELECT 到同一条空闲记录然后在 update 时互相等待死锁或者直接出现“库存超卖”。所以查出来再判断是不安全的必须让数据库的更新语句来做原子判断。3.3 订单状态流转状态机枚举与守卫校验状态机不能只写在文档里要落到代码里。我定义了一个枚举OrderStatusEnum每个状态都带上“可流转到哪些状态”的规则public enum OrderStatusEnum { PENDING_PAY(0, 待付款, new int[]{1, 6}), TO_BE_SHIPPED(1, 待发货, new int[]{2, 6}), RENTING(2, 租赁中, new int[]{3}), TO_BE_RETURNED(3, 待归还, new int[]{4}), PENDING_CONFIRM(4, 待确认归还, new int[]{5, 7}), COMPLETED(5, 已完成, new int[]{}), CANCELLED(6, 已取消, new int[]{}), REFUNDING(7, 退款中, new int[]{5}), CLOSED(8, 已关闭, new int[]{}); private final int status; private final String desc; private final int[] nextStatus; public static boolean canTransit(int from, int to) { for (OrderStatusEnum status : values()) { if (status.status from) { for (int next : status.nextStatus) { if (next to) { return true; } } } } return false; } }业务层在改订单状态时先调用canTransit(currentStatus, targetStatus)做校验“非法流转”直接抛业务异常。这个设计彻底杜绝了“跳过归还直接完成”“已取消订单变成租赁中”这类逻辑漏洞。3.4 MyBatis 实战细节三个让我加班到深夜的坑只说网上一搜一大把的基础用法没有意思重点说我在这个项目里真正被坑过的三个点。坑一二级缓存导致的数据脏读项目做到一半我为了提高查询性能在几个 Mapper 上加了cache/开启了二级缓存。表面上测试没问题但上线前几天测试同学发现了一个诡异现象用户下单成功后回到商品列表页那件玩具的“可租数量”还是没变。原因很简单——二级缓存是基于 Mapper 命名空间的商品列表查询命中了缓存而写操作在订单 Mapper 里发生商品 Mapper 的缓存没有被同步失效。MyBatis 的二级缓存是粗粒度的它在多个表关联查询时非常容易出脏读。我的最终方案是这个项目全局关闭二级缓存只依靠一级缓存SqlSession 级别和 MySQL 自身的查询性能。玩具租赁系统的数据量远没到“必须用二级缓存”的程度为了那一点性能提升引入一致性风险不值得。如果你确实需要缓存请使用 Redis并把缓存边界做到可控。坑二模糊查询的#{}和${}用法需求是搜索玩具名称比如输入“遥控车”查所有包含该关键字的商品。我一开始写的 SQL 是select idsearch resultTypeToy SELECT * FROM t_toy WHERE name LIKE %${keyword}% /select本地测试没问题但同事做安全测试时发现keyword传入 OR 11 --时整个商品表被拖出来了。这就是 SQL 注入漏洞。${}是做字符串拼接用户输入会直接变成 SQL 的一部分。正确写法是用#{}占位符配合CONCATselect idsearch resultTypeToy SELECT * FROM t_toy WHERE name LIKE CONCAT(%, #{keyword}, %) /select#{}会被 MyBatis 预编译成?占位符由 JDBC 的PreparedStatement处理从根上杜绝注入。坑三批量插入数据问题后台导入玩具实例时一次可能插入几百条。我一开始直接在 Java 里 for 循环调用单条 insert几百条数据插了 4 秒多。优化方案是使用 MyBatis 的foreach批量插入一次 SQL 搞定insert idbatchInsertInstance INSERT INTO t_toy_instance (toy_id, instance_no, status, purchase_date) VALUES foreach collectionlist itemitem separator, (#{item.toyId}, #{item.instanceNo}, #{item.status}, #{item.purchaseDate}) /foreach /insert一个需要注意的细节instance_no有 UNIQUE 约束批量插入时一旦某一条重复整批都会失败。所以插入前最好先在 Java 端用 Set 做一次去重或者捕获DuplicateKeyException单独提示用户“第几行编号重复”。3.5 归还与押金扣减事务边界要清晰归还流程是“后台验收”触发不是用户点击。后台管理员在订单详情页点击“确认归还”时后端要做的事情是校验订单状态是PENDING_CONFIRM4更新订单状态为COMPLETED5记录实际归还日期根据管理员填的损耗扣款金额计算应退押金更新玩具实例状态为 0空闲或 2维修中给用户账户余额增加应退押金更新商品表的租用统计这 6 件事必须在一个事务里完成。我在这个接口的 Service 方法上加了Transactional(rollbackFor Exception.class)保证中间任何一步失败前面已更新的数据全部回滚不会出现“实例已经归位了但押金没退”的尴尬状态。rollbackFor Exception.class是必须显式声明的。Spring 默认只在 RuntimeException 时回滚事务如果你在业务代码里throws new Exception()抛受检异常事务不会回滚数据会出现不一致。这是新手最容易忽视的细节。4. 前端 Vue 落地路由、请求封装与组件划分4.1 项目初始化与目录结构前端我用 Vue CLI 创建项目Vue 2 配 Element UIVue 3 配 Element Plus思路完全一致。目录结构按业务模块切分src ├── api/ # 每个业务模块的接口请求 ├── assets/ # 静态资源 ├── components/ # 公共组件上传、分页、搜索栏 ├── router/ # 路由表 ├── store/ # Vuex 状态管理 ├── utils/ # axios 封装、工具函数 └── views/ ├── home/ # 首页商品展示 ├── goods/ # 商品详情 ├── order/ # 我的订单/下单流程 ├── cart/ # 租赁清单购物车 └── admin/ # 后台管理商品管理、订单管理、用户管理4.2 Axios 封装统一处理凭证与错误前端每个页面直接调this.$http.get(/api/toy/list)而不是每次都写 axios 实例前提是做好封装。我的utils/request.js核心逻辑import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }) // 请求拦截器自动携带 token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }, error Promise.reject(error)) // 响应拦截器统一处理错误码 service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } else if (res.code 401) { localStorage.removeItem(token) router.push(/login) Message.error(登录已过期请重新登录) } else { Message.error(res.message || 请求失败) } return Promise.reject(new Error(res.message || error)) }, error { Message.error(error.message || 服务器错误) return Promise.reject(error) } ) export default service注意到一个细节请求拦截器里 token 是从localStorage取响应拦截器里 code 401 直接清 token 跳登录页。这套机制在前后端分离项目里是标配写进简历可以算一个完整点。4.3 路由守卫登录拦截和角色分流玩具租赁系统分用户端和管理端两者职责完全不同。用户端要看商品、下单、查订单管理端要管商品、订单、实例。路由守卫需要做两件事未登录用户不能访问需要登录的页面普通用户不能访问管理端路由路由配置节选const router new VueRouter({ routes: [ { path: /, component: Home }, { path: /goods/:id, component: GoodsDetail }, { path: /login, component: Login }, { path: /order, component: OrderList, meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, role: admin }, children: [ { path: goods, component: AdminGoods }, { path: orders, component: AdminOrders }, { path: instances, component: AdminInstances } ] } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) return } if (to.meta.role admin role ! admin) { next(/) return } next() })这里把角色信息存了一份到localStorage每次路由跳转前检查。真正的权限校验在后端接口前端只是减少无效请求和提升体验——直接调用管理端接口时后端拦截器会再次校验前端防君子不防小人。4.4 组件划分不要让每个页面都变成“面条代码”初期我把所有东西都写在一个GoodsList.vue里搜索框、分页、商品卡片全塞进去结果文件 1500 行改一个样式要找半天。后来把页面按“区域”拆成独立组件SearchBar.vue接收关键字和分类触发搜索GoodsCard.vue单个商品卡片展示封面、价格、租金Pagination.vue分页组件组件通信遵循“父传子子传事件”的原则。GoodsList.vue持有搜索条件和分页数据透过 props 传数据给子组件子组件点击或输入时向父组件$emit事件父组件负责请求数据。这样可以保证数据流是单向的不会出现“一个组件改了数据另一个组件不知情”的混乱。4.5 三个“看起来很专业”的前端细节本科毕设或者初级项目的答辩老师其实不会期待你做出多炫酷的效果但有几个细节能明显拉高整体印象分。搜索防抖用户在搜索框连续输入“遥控”和“遥控车”会触两次请求防抖可以让用户停止输入 500ms 后才真正请求。实现方式watch: { keyword() { clearTimeout(this.timer) this.timer setTimeout(() { this.fetchList() }, 500) } }订单倒计时待付款订单需要在 15 分钟内完成支付否则自动取消。前端展示剩余时间可以用setInterval定时更新同时后端在超时检测时会主动关闭订单。前端倒计时归零后页面要自动刷新状态。视频播放玩具详情页接入商品展示视频时很多同学遇到.mp4播放没问题、.m3u8播放黑屏的尴尬。这里我引入hls.js解决 HLS 流播放问题安装依赖后写一个简易播放器组件import Hls from hls.js playM3u8(videoUrl, videoElement) { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(videoUrl) hls.attachMedia(videoElement) } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { videoElement.src videoUrl } }这个功能虽然不是租赁系统的核心但在答辩演示时能播放一段玩具展示视频演示效果好很多。5. 部署联调阶段最容易翻车的几个坑附排查链路5.1 跨域问题控制台飘红的 “CORS” 错误现象前端页面请求http://localhost:8080/api/toy/list控制台报错Access to XMLHttpRequest at http://localhost:8080/api/toy/list from origin http://localhost:8081 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.这个错误的本质是“不同源”的浏览器拦截前端跑在 8081 端口后端跑在 8080 端口浏览器认为跨域了。解决方案是在后端配置 CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个非常隐蔽的坑如果使用了allowCredentials(true)那么allowedOrigins(*)会失效必须换成allowedOriginPatterns(*)。Spring 的 CORS 规范不允许带凭证的情况下使用通配符域名。我在第一次写的时候用的allowedOrigins(*)前端带 token 请求时始终报错排查了半小时才反应过来。5.2 MySQL 8.x 驱动与时区问题现象后端启动报错java.sql.SQLException: The server time zone value Öйú±ê׼ʱ¼ä is unrecognized...这是 MySQL 8.0 的驱动com.mysql.cj.jdbc.Driver在连接时强制校验时区。解决办法是在 JDBC 连接串里显式指定时区spring: datasource: url: jdbc:mysql://localhost:3306/toy_rental?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver另外注意如果数据库和服务器都在国内serverTimezoneAsia/Shanghai是必填项否则日期字段会出现相差 8 小时的问题。传 UTC 也可以但存入数据库的DATETIME字段会变成 UTC 时间查询展示时还要换算徒增麻烦。直接用东八区最省心。5.3 前端代理配置解决联调时的跨域与调试除了后端加 CORS前端也可以配置开发环境的代理把/api开头的请求转发到后端地址。方式是在vue.config.js里module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 后端接口没有 /api 前缀时去掉 pathRewrite: { ^/api: } } } } }这样前端代码里写axios.get(/api/toy/list)就变成了一个相对路径请求开发环境下由 dev-server 转发到 8080生产环境把前端打包后的静态文件交给 Nginx再用 Nginx 代理转发/api到 Java 服务。整个链路非常干净。5.4 端口占用与产线部署使用现有 Java 服务方式后台启动时偶尔会遇到Port 8080 was already in use。排查命令netstat -ano | findstr :8080 # Windows lsof -i :8080 # Mac/Linux查到占用进程后强杀对应 PID或者修改服务端口。我建议本地开发统一约定前端 8081后端 8080数据库 3306。三个端口固定下来联调时不用每次口头确认。生产环境如果不依赖 Docker直接用打包后的 jar 启动即可mvn clean package -DskipTests nohup java -jar target/toy-rental-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod log.log 21 nohup和配合保证进程在 SSH 断开后继续运行输出日志重定向到文件方便事后排查。启动后先看日志里有没有Started ToyRentalApplication再访问接口验证这一套流程熟练后五分钟就能完成一次部署。6. 如果要拿去答辩或者写进简历这几个点值得深挖6.1 并发控制“库存锁定”比你想象中更有说头答辩老师大概率会问“两个人同时下单同一件玩具怎么办”这是一个送分题。我的回答链路是玩具实例表设计了状态字段0 代表空闲开启事务后用带status 0条件的 UPDATE 语句来原子抢占实例MySQL InnoDB 引擎对该行加了排他锁只有一个事务能成功更新影响行数为 1 表示抢占成功0 表示失败失败则回滚事务并提示用户这个方案不需要引入 Redis 分布式锁对于单体应用来说足够可靠。如果系统未来要拆成多个微服务实例部署再到 Redis 搞分布式锁思路是“先单机可靠再考虑水平扩展”。6.2 状态机的价值:越早抽象后面越轻松玩具有“待付款、待发货、租赁中、待归还、已完成”等状态直接在 Controller 里写 if-else 判断很容易失控。我把状态机的可流转规则收敛到枚举里业务层只调canTransit(from, to)这一个方法后续加状态或改规则只需动枚举一个文件。6.3 日志与异常处理一个容易被忽视的细节项目里我写了一个全局异常处理器RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后重试); } }这个类解决了一个最实际的问题SQL 异常、空指针异常不会再以 500 错误堆栈的形式直接甩到前端而是统一转换为“系统繁忙”。日志里保留了完整堆栈排查问题时直接看log.error输出。6.4 定时任务超过 15 分钟未支付订单自动取消需求里有一条“待付款订单超过 15 分钟自动关闭”。实现方式是在启动类加EnableScheduling然后写一个定时方法Component public class OrderTimeoutTask { Scheduled(fixedRate 60000) public void closeTimeoutOrders() { // 查询创建时间超过 15 分钟且状态为 0待付款的订单 // 批量更新状态为 6已取消 // 释放对应玩具实例状态改回 0 } }这个定时任务每分钟跑一次把超时订单关闭并释放被占用的玩具实例。实现简单但“释放实例”这个动作很容易被漏掉——很多人只改了订单状态忘了实例还是租赁中状态导致那件玩具永远租不出去。6.5 密码存储不要用明文也不要用 MD5用户密码我用BCryptPasswordEncoder加密存储。BCrypt 是一种自带盐值的哈希算法相同密码每次加密结果都不同安全性远高于 MD5。Spring Security 里自带这个类即使没集成 Spring Security 也可以单独引入spring-security-crypto依赖使用。登录时调用matches(rawPassword, encodedPassword)校验。答辩时如果老师问“为什么不用 MD5”你可以回答MD5 是快速哈希攻击者可以用彩虹表反查BCrypt 内部引入随机盐并做了多轮计算相同密码也会产生不同哈希值抗字典攻击和彩虹表攻击能力强。这款玩具租赁系统做下来我最深的感受是很多功能表面看是“增删改查”但每一个表设计、每一个状态流转、每一条 SQL 背后都藏着业务规则和并发考量。把订单状态机理清楚、把实例锁定做成原子操作、把事务边界划对系统的可靠性才会有基础。代码量其实不大真正值钱的是这些“想清楚为什么要这么设计”的部分。如果你也在做类似的系统希望这篇拆解能帮你少走一点弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询