最近在梳理一个前后端分离的物品租赁系统,技术栈是 SpringBoot + Vue + MyBatis + MySQL,顺手把完整源码整理了一遍,把从数据库设计到接口开发,再到前端页面和后端部署的整个链路都重新过了一遍。这个项目很适合拿来练手,因为它的业务逻辑不算复杂,但又覆盖了前后端交互、权限控制、订单状态流转、文件上传、定时任务这些真实项目里的高频需求,属于那种“小而全”的典型工程。
如果你正在学 SpringBoot 和 Vue,或者准备做一个简历级项目,这篇文章可以参考。我不会只贴项目截图,会把关键表结构、接口约定、核心代码逻辑、部署命令和排查思路都讲清楚,让你拿到的不是一套“跑不起来”的源码,而是一套能自己改、自己扩展的骨架。
1. 项目定位与技术选型解析
1.1 为什么是“前后端分离”
很多人一上来就纠结,到底是前后端分离还是传统的服务端渲染。我的看法很简单:只要你的系统里有“多端展示”或者“接口需要复用”的需求,前后端分离就是目前最稳妥的方案。
这个租赁系统有几个典型场景:用户在小程序端或网页端浏览物品,管理员在后台管理库存和订单,未来还可能接一个自助租赁终端。如果所有页面都交给后端渲染,每一端都要维护一份模板逻辑,接口也没法独立复用。分离之后,后端只输出 JSON,前端自己负责路由和渲染,理论上我可以随时把浏览器端换掉,而业务逻辑完全不动。
另外,分离项目在开发调试时也有优势。前端用 Node 开发服务器跑 8080,后端用 SpringBoot 跑 8081,两边通过接口联调,互不阻塞。你改页面不需要重启 Java 应用,我改接口不需要等你怎么编译,这一点在团队协作里的价值尤其明显。
1.2 技术栈选型背后的必然性
先说我为什么选这四样:SpringBoot、Vue、MyBatis、MySQL。
SpringBoot 解决的是“项目启动成本”问题。不用再写一堆 XML 配置,依赖管理也内置好了,内嵌 Tomcat 让我直接跑 jar 就能提供服务。它背后是 Spring 生态多年的积累,事务、AOP、依赖注入这些东西不用自己从零造。
Vue 则是当前国内中小型项目里普及度最高的前端框架,上手曲线比 React 平滑,模板语法对后端开发更友好。配合 Element UI,两周内就能把后台管理页面做出来。你要是用过 Angular 就会知道,Vue 的灵活性和生态相当接地气。
MyBatis 的好处是 SQL 可控。租赁系统的查询统计逻辑比较复杂,比如“当前可租数量”“某用户的历史订单”“每日租金收入报表”,这些用 ORM 自动生成 SQL 往往写不出高效查询,而 MyBatis 可以让我直接控制 SQL 语句,同时通过动态 SQL 灵活拼接条件。相比 JPA 那种把数据库当成对象仓库的做法,MyBatis 更贴近我这种习惯手写 SQL 的人。
MySQL 就不多讲了,免费、稳定、文档多,单表几百万数据用索引也能扛住,租赁系统这种中小规模完全没有性能焦虑。
1.3 总体架构与数据流
整个系统分成三块:前端应用、后端接口、数据库。前端通过 axios 把请求发到后端,后端先经过过滤器做 CORS 处理和登录态校验,然后进入 Controller 层,再经过 Service 层处理业务逻辑,Mapper 层访问 MySQL,最后数据返回给前端渲染。
这里的核心是“单向依赖”:前端不直接访问数据库,后端不关心页面长什么样。所有交互都通过 RESTful 接口完成,JSON 作为统一传输格式。登录态用 JWT 维护,前端把 token 存在 localStorage 里,每次请求带在 Header 上。
从部署角度,前端打包成静态文件放在 Nginx 下,后端打包成 jar 单独部署在一台服务器上,Nginx 把/api路径代理到后端端口,其他路径直接返回静态资源。这样一处挂掉不会影响另一处,就算前端要换成 CDN 也没问题。
2. 核心功能拆解与数据库设计
2.1 角色与核心业务流程
物品租赁系统里至少有三种角色:普通用户、管理员、运营人员(也可以合并成两种)。我把权限简化为 ADMIN 和 USER 两个级别,用 Spring Security 或者拦截器都能实现。
核心流程我梳理下来是这样的:用户注册登录后,在物品列表页按分类、关键词搜索物品;选中某个物品后查看详情,包括押金、租金、库存;点击“立即租用”,填写租赁时长,系统计算租金总额并生成订单;用户支付(这个项目里是模拟支付)后物品库存减少,订单状态变成“租赁中”;到了归还日期后,用户发起归还,管理员审核物品状态,然后订单完成,库存恢复。
如果把状态设计不好,后面会非常痛苦。我一开始用的状态枚举是:待支付、已支付、租赁中、归还审核中、已完成、已取消、已逾期。别小看这个枚举,每个状态转换都对应一次库存和钱包余额的变动,必须把这些逻辑收敛到 Service 层,不能在 Controller 里乱改。
2.2 数据库表设计:一张表解决一个问题
我用五张核心表加两张辅助表,没有刻意追求复杂的范式,但每一张表都能清晰表达一个业务概念。
第一张是用户表t_user,除了用户名和加密密码外,我还加了credit_score信用分和balance账户余额。信用分用于约束用户,租赁完成后如果按时归还,信用分增加;有逾期记录则扣分。余额是模拟支付的硬性依赖,用户先充值,再支付。
第二张是物品表t_item,字段有item_name、category_id、description、daily_price(日租金)、deposit(押金)、total_stock、available_stock、cover_image。这里需要特别注意available_stock的更新:它不能直接等于total_stock - 进行中的租赁数,因为数据库里没有“正在进行中”的直接字段,所以我每次都是通过订单状态动态计算,并在租赁开始时用事务更新这个字段,防止超租。
第三张是订单表t_order,包含order_no、user_id、item_id、start_date、end_date、total_price、deposit_amount、status、create_time。订单号我用时间戳加随机数生成,不直接用自增 ID 作为业务编号,主要是为了避免别人通过订单号猜到平台的总订单量。
第四张是归还记录表t_turnover_record,记录归还时间、归还时的物品照片、损耗情况、是否扣除押金。第五张是操作日志表t_operation_log,记录用户和管理员的敏感操作,方便定位问题。
建表时我强制所有表都带create_time和update_time,并且update_time用 ON UPDATE CURRENT_TIMESTAMP,这样不需要在代码里人工维护修改时间,省了不少事。索引方面,t_order里的user_id和status一定要建联合索引,因为列表页最常见的过滤条件就是“某个用户 + 订单状态”。
2.3 接口设计规范与安全控制
我习惯于在项目初期就约定好接口规范,否则前后端联调阶段会变成灾难。统一格式是这样的:
{ "code": 200, "message": "success", "data": {} }code 不等于 HTTP 状态码,而是业务状态码。200 表示成功,1001 表示未登录,1002 表示参数错误,1003 表示无权限,1004 表示库存不足。这样设计的好处是,前端 axios 拦截器只需要判断 code 是否为 200,如果是,直接取data;不是,则统一弹出错误提示。
接口路径我用 RESTful 风格,例如:
POST /api/user/register注册POST /api/user/login登录GET /api/items查询物品列表(支持分页和条件过滤)GET /api/items/{id}查询物品详情POST /api/order/create创建租赁订单POST /api/order/pay/{orderId}模拟支付POST /api/order/return/{orderId}发起归还
安全方面,我做了两层:第一层是 Spring Interceptor,拦截所有/api/**请求,检查 Header 里的 token;第二层是接口级别的权限判断,例如管理员接口必须校验角色为 ADMIN。密码不分明文存储,用 BCrypt 加密。前端也不会把密码放在 URL 参数里,全部走 POST 请求体。
有一点大家容易忽略:不能只在前端隐藏按钮来控制权限,后端接口必须硬校验。我见过不少项目,前端把删除按钮隐藏了就以为安全了,结果别人手动构造一个 DELETE 请求就能删数据。
3. 后端核心实现:SpringBoot + MyBatis 落地
3.1 工程结构与统一响应封装
后端项目结构我建议按业务模块分包,而不是严格按技术层堆包。也就是说,controller、service、mapper这三个包下面可以继续按业务模块拆成二级包,例如controller/UserController、controller/OrderController,而不是把所有 Controller 全部塞在一层里,否则项目稍微膨胀就找不着类。
我在这里用一个很常见的套路:统一的返回类Result<T>,定义静态方法Result.success(data)、Result.error(code, message)。所有 Controller 返回值都写成Result<?>,看起来像个模板,但确实能避免每个接口各自返回不同结构的 JSON 造成的前端适配问题。
另外,全局异常处理我用@RestControllerAdvice配合@ExceptionHandler。业务异常直接抛自定义BusinessException,由全局处理器转成统一 JSON。像参数校验异常、空指针异常,也统一拦截,防止把堆栈信息直接暴露出去。这个细节虽然不起眼,但对线上排查问题很有帮助。
3.2 MyBatis 映射与动态 SQL 使用要点
用 MyBatis 的第一关是写 XML。虽然可以用注解写简单 SQL,但复杂查询我还是建议用 XML,因为 SQL 的可读性和后期的调试能力都更强。
举个例子,查询物品列表时的多条件动态 SQL:
<select id="selectPageList" resultType="com.example.entity.Item"> SELECT * FROM t_item <where> <if test="keyword != null and keyword != ''"> AND (item_name LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>这里的关键在于<where>标签,它会自动处理掉第一个 AND,避免你在拼接 SQL 时还要判断是不是第一个条件。很多人写动态 SQL 喜欢用${}拼接字符串,这个必须避免,${}不会走预编译,会有 SQL 注入风险。我全项目里只允许#{}传参。
还有一点,MyBatis 的二级缓存我直接没开。因为租赁系统的数据一致性要求高,库存字段绝对不能丢,二级缓存容易造成脏读。如果要用缓存,我宁可自己去写 Redis 缓存,也不依赖 MyBatis 内置的二级缓存。
3.3 租赁业务的并发控制与状态机设计
这个系统里最容易出问题的不是 CRUD,而是租赁时的并发控制。设想两个用户同时看到了最后一件可租物品,都在下单,如果代码只是“先查库存,再减库存”,那么在高并发下就有超卖风险。
我采用的方案是乐观锁。在t_item表加一个version字段,更新库存时执行这样的 SQL:
UPDATE t_item SET available_stock = available_stock - 1, version = version + 1 WHERE id = #{itemId} AND available_stock > 0 AND version = #{version};在 Service 层判断受影响行数,如果为 0,说明库存被他人更新过了,直接抛出“库存不足”的异常。配合事务,就能保证一次订单创建要么成功要么失败,不会出现库存负数。
订单状态机我专门写了一个工具类,维护每个状态允许转移到哪些目标状态。比如“待支付”只能转移到“已支付”或“已取消”;“已支付”才能变成“租赁中”;“租赁中”才能变成“归还审核中”。任何非法的状态转移直接抛异常。这种设计听上去有点死板,但能避免很多低级 bug,尤其当你以后要加管理员手动改订单状态的功能时,状态机就是你的安全网。
4. 前端实现:Vue + Element UI 实战
4.1 前端工程化搭建与路由设计
前端我用 Vue CLI 初始化了一个标准工程,依赖包括 vue-router、vuex、axios、element-ui。初始化时要把 eslint 和 prettier 一起装好,代码风格不一致在多人项目里特别让人抓狂。我还会把.env.development和.env.production分开,分别配置开发环境和生产环境的接口基础路径。
路由设计上,我分成两个层级:前端整体是一个单页应用,但包含用户端和管理端两个界面。用户端路由是 Grocery 页面列表(其实叫 Items),管理端路由是 Dashboard 布局下的子路由,例如/admin/items、/admin/orders、/admin/users。路由守卫里我会检查用户角色,非管理员访问 admin 路由直接跳转到首页。
这里有一个实用技巧:不要把路由全部写在一个文件里。我会按模块拆分 router 模块文件,然后通过routes数组合并。这样后面增加新页面,只需要在对应模块里加一行,不用到总文件里翻。
4.2 核心页面与组件化思路
物品列表页是用户端最核心的页面。它包含了搜索栏、分类筛选、分页列表、物品卡片四个部分。我一开始把全部代码挤在一个 .vue 文件里,大概五百行,后来重构拆成了SearchBar.vue、ItemCard.vue、Pagination.vue,父组件只负责数据请求和状态管理,子组件只负责展示和事件通知。这套拆分逻辑以后在所有人接手的时候都省心。
订单确认页稍微复杂一点。用户选择了租赁天数后,前端要实时计算租金,展示押金和总价。前端计算公式和后端保持一直:总价 = 日租金 x 租赁天数 + 押金。计算过程我会在代码注释里写清楚,防止后续调整单价后两边算法不一致。
管理端我用了 Element UI 的 el-table 做订单列表和物品列表,通过 el-dialog 做新增和编辑弹窗。表格里的状态标签还能用 template 自定义渲染,比如“租赁中”显示为蓝色,“逾期”显示为红色,直观很多。
4.3 接口封装与鉴权交互
前端所有请求我都放到src/api目录下,按业务模块拆成user.js、item.js、order.js。axios 实例创建时统一设置baseURL、timeout和请求拦截器。请求拦截器里从 localStorage 取 token,添加到 Authorization 头部。
响应拦截器里,我会统一处理业务 code。如果 code 是 401(未登录),直接清除 token 并跳回登录页;如果 code 是业务异常,用 Element UI 的 Message 组件提示用户。这样业务页面里不需要每个接口都去 catch,代码会干净很多。
登录状态的持久化其实很简单,我用 localStorage 存 token,同时用 vuex 存用户信息,页面刷新时通过 vuex 的 initial state 从 localStorage 恢复。千万不要把密码存进去,token 过期时间我设置的是 2 小时。
5. 完整部署教程(前后端分离 + Nginx)
5.1 环境准备:JDK、Maven、Node、MySQL
实战部署前,先把环境准备好。服务端需要 JDK 1.8 或 11、Maven 3.6+、MySQL 5.7 或 8.0、Nginx 1.18+。Maven 我给你一个建议:下载 apache-maven 的二进制包直接解压,然后修改settings.xml里的本地仓库路径,最好别用 IDE 自带的 Maven,版本和镜像源都不好控制。
MySQL 在 Linux 上的安装,以 CentOS 7 为例,先安装官方 yum repo,然后安装 mysql-server,启动服务后执行安全初始化脚本。新建一个数据库,例如rent_db,然后导入项目里自带的rent_db.sql。导入时注意编码问题,我在 SQL 文件开头写上SET NAMES utf8mb4;,避免中文乱码。
MySQL 用户建议单独创建,不要用 root 直接连应用数据库。用户权限只给SELECT、INSERT、UPDATE、DELETE,不给 DROP 权限,降低误操作风险。连接串里记得加useSSL=false&serverTimezone=Asia/Shanghai,否则会经常报 SSL 时区错误。
5.2 后端打包部署
后端项目打包很简单,在项目根目录执行:
mvn clean package -DskipTests打包完成后,target目录下会生成一个rent-server.jar。如果之前没做配置分离,所有环境配置都在application.yml里,包括数据库密码。我一开始直接打包进 jar,后来发现换服务器就要重新打包,于是改成把application-prod.yml放在 jar 包同级的 config 目录下,SpringBoot 会优先读取外部配置文件,这样升级程序时只要替换 jar,不需要动配置。
启动命令我常用这样:
nohup java -jar -Xms256m -Xmx512m rent-server.jar --spring.profiles.active=prod > server.log 2>&1 &这个命令里,-Xms 和 -Xmx 设置了堆内存下限和上限。租用系统单实例 256~512M 就够,但如果你机器内存大,可以再放松一点。启动后看日志,通过localhost:8081/api/items测试接口是否正常返回 JSON。
如果前后端不在同机部署,后端接口肯定会被跨域问题卡住。我在后端写了 CorsFilter,允许前端域名访问。生产环境如果使用 Nginx 反向代理,通常可以忽略跨域问题,直接用 Nginx 把/api代理到后端,这样前后端访问同一个源,就没有跨域了。
5.3 前端构建与 Nginx 反向代理
前端构建前,先修改.env.production:
VUE_APP_BASE_URL = '/api'这样前端打包后所有接口请求都会指向相对路径/api。接着执行:
npm install npm run build构建完成后,后端目录下dist里就是静态文件。把dist里面的内容上传到服务器,比如放在/opt/rent-web/dist。然后修改 Nginx 配置:
server { listen 80; server_name your-domain.com; root /opt/rent-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这里最关键的配置是最后一行try_files $uri $uri/ /index.html。Vue 是单页应用,路由切换都是前端 history 模式,刷新某个子路由时,比如/admin/items,Nginx 没有这个真实文件,必须回退到 index.html 交给前端路由处理,否则会白屏 404。
配置完成后,nginx -t校验配置,然后nginx -s reload。访问服务器 IP,能看到前端首页,说明部署成功。
5.4 常见部署问题排查实录
部署过程中最容易踩的坑,我一个个说。
第一个是 MySQL 连接不上。报错Can't connect to local MySQL server through socket '/var/lib/mysql/mysql.sock',多半是 MySQL 没启动。先执行systemctl status mysqld或者service mysql status,启动后检查端口netstat -tlnp | grep 3306。如果 MySQL 跑在远程,还要检查云安全组是否放行了 3306 端口。
第二个是启动后端时端口被占用。解决办法:找到占用端口进程并杀掉,或者修改 application.yml 里的 server.port,换一个不常用的端口。
第三个是前端页面能打开,但登录调接口报跨域错误。原因多半是 Nginx 没有正确代理,或者代理路径写错。直接在浏览器按 F12 看网络请求,如果请求 URL 是http://ip/api/user/login,状态码 404,就去检查 Nginx 的location /api/是否生效。常见错误是proxy_pass后面少了一个/,导致路径拼接不对。
第四个是后端接口能访问,但中文乱码。这个多半是数据库连接串里没加characterEncoding=utf8,或者建表时默认字符集不是 utf8mb4。可以登录 MySQL 执行SHOW CREATE TABLE t_item;看看默认 charset,如果不是utf8mb4,需要重新导出导入。
还有前端构建后资源路径不对的问题。有时候页面打开一片空白,控制台报找不到 JS 文件,多半是vue.config.js里的publicPath配置不对。我一般把publicPath设为'./',这样资源路径是相对路径,放在任何子目录都能访问。
6. 项目避坑与经验总结
6.1 从开发到上线的10个避坑点
我整理了自己在这套系统开发中踩过的坑,专门列成一份清单,避免你重复踩。
- 数据库字段命名尽量用下划线风格,避免用
order这种 MySQL 关键字,否则每次查询都要加反引号,烦死了。 - 前后端联调前先和后端确认好时间格式,我统一用
yyyy-MM-dd HH:mm:ss,前端用 dayjs 格式化。 - JWT 密钥不要写在代码里,放到配置文件里,并且定期更换。
- 所有列表页都要做分页,不做分页的系统上线后数据一多就卡死,这是我的教训。
- 创建订单和扣库存必须在一个事务里,并且要加上对业务层的锁,否则并发下单会出大问题。
- 模拟支付成功后要回调更新订单状态,不要在前端直接改,前端判断只能作为展示。
- 归还审核时不要让管理员手动输入“是否损坏”,用默认值 + 附加图片佐证,减少操作时间。
- 租期计算要定义清楚:是按自然日还是按 24 小时。我按自然日,
endDate = startDate + days - 1,避免跨天时用户以为多算了一天。 - 后端全局异常捕获时,不要直接返回异常堆栈,避免信息泄露。统一返回 code 和 message 就够了。
- 所有金额计算都使用 Decimal 或者整数分,不要用 double/float。押金和租金的精度问题在结算时会害死人。
6.2 可扩展方向与个人心得
这个系统如果继续扩展,可以考虑几个方向。第一是接入 Redis 做验证码缓存和物品浏览量的统计,顺便把热门物品搞个榜单;第二是引入 RabbitMQ 或者延迟队列做订单超时取消,不需要用户支付时系统自动释放库存;第三是把文件上传换成 MinIO 做对象存储,目前项目里图片都是本地上传,正式部署后不太够用;第四是把管理员后台和用户端彻底拆成两个独立应用,降低前端耦合度。
我在实际开发这个项目的过程中,最大的体会是前后端分离项目真正难的从来不是某个框架用法,而是规范和约定。只要数据库设计合理、接口统一、状态流转清晰、部署流程明确,后面的功能扩展就会很顺。相反,如果在一开始就为了省事放弃这些约束,后期改一个字段都能牵出一堆雷。
这套源码加上部署教程,我建议拿到手之后不要直接照着抄,先跑通流程,再按自己业务场景改表、改接口、改页面。比如把“物品”换成“书籍”,把“日租金”改成“小时租金”,把“班级”或者“社区”作为维度重新组织业务,它就会变成另一个能讲出故事的项目。项目不在于大,在于你对每个环节想清楚了没有。