首页 / 资讯中心 / 文章详情

基于Spring Boot的自行车分享平台:从数据库到并发控制的完整实战

基于Spring Boot的自行车分享平台:从数据库到并发控制的完整实战 ★ FEATURED ARTICLE
拿“自行车分享平台”当课程设计或毕业设计是我回头看整个学生阶段觉得做得很值的一个选择。它不是最fancy的题目但“基于Java的自行车分享平台”这个帽子下面藏的其实是用户、车辆、装备、订单、评论、收藏、后台审核、数据统计这一整套业务闭环。你想想一个系统该有的常见考点它几乎全占了数据库设计、接口开发、权限控制、文件上传、订单状态流转甚至并发下的超卖问题也能在这里找到一个非常自然的载体。而且题目又不大一个学期或者一个冲刺阶段完全能hold住落到tony老师眼里就是一个“结构完整、功能明确、有深度可挖”的典型项目。这个选题对正在纠结毕设方向的同学或者想拿一个完整项目练手Spring Boot的开发者来说很值得拆开来看。我接下来不打算复述那些随源码附带的万字开发文档而是想以复盘的角度把从选题、技术选型、数据库设计、后端实现到部署踩坑的完整链路讲一遍。你会发现真正让这个项目“值钱”的往往不是那些常规的增删改查而是几个容易被忽略的关键设计。1. 为什么我把毕设选题押在“自行车分享平台”上1.1 表面是“分享”本质比CRUD复杂一个维度很多同学听到“分享平台”第一反应就是“发帖评论”但其实自行车分享平台并不是论坛。你点开任何一个共享单车App或者校园骑行租赁系统核心动作不是“看帖”而是“借车”和“还车”。这就决定了它天然带着交易系统的基因用户要能发布自己的车辆和骑行装备让其他人看到并借用借出方和借入方之间要有一个订单来约束责任比如押金、租期、归还时间车辆/装备不能无限被借必须有一个“可借/已借出/维修/下架”的状态控制管理员必须介入审核不然用户上传什么牛鬼蛇神都会直接展示出来。这么一拆项目就从单纯的增删改查变成了“内容发布订单交易权限管控”的混合体。对于课程设计而言它覆盖的知识点足够广对于毕设而言它有可以深挖的并发控制、状态机设计能让你在答辩时讲出点别人讲不出的东西。1.2 我最终落地的功能边界功能范围如果一开始不划清楚很容易陷入“做到哪算哪”的状态。我当时直接把功能切成三大块后面整个开发节奏都清晰了很多用户端注册登录、浏览车辆列表/装备列表、查看详情、预约借车、我要发布、我的借用记录、我的收藏、评论功能。管理端用户管理、发布内容审核、订单管理、公告管理、数据统计面板。公共能力JWT登录认证、统一异常处理、统一返回格式、文件上传与访问、分页查询。这些功能不是拍脑袋定的。它们的共同点都是“跟业务强相关”没有一个是为了凑功能硬加的。比如收藏、评论、公告单独看都是小功能但放在分享平台里它们就能形成一个“信任闭环”。一个人能不能把自己车借给陌生人靠的就是历史评价和平台管理员的背书。2. 技术选型Spring Boot 3 MyBatis-Plus 这套组合是怎么敲定的2.1 Spring Boot 3 Java 17不是赶时髦而是现实需求我选的是Spring Boot 3.x Java 17。有人问为什么不用更常见的Spring Boot 2.x我的理由很直接现在的技术生态已经全面倒向Jakarta命名空间Spring Boot 3在性能、可控性和长期维护上都更有优势。更重要的是大部分刚接触Spring Boot的同学如果直接学2.x等毕业找工作面试时被问到3.x的区别反倒露怯。不如一步到位上手新版本。有一点要提醒Spring Boot 3要求Java 17起步所以本地JDK版本必须提前确认别装了半天发现JDK是8跑起来全是兼容性报错。Maven也建议用3.6以上版本否则部分插件会识别不了。2.2 MyBatis-Plus 凭什么比原生 MyBatis 更适合课程设计ORM这块我选了MyBatis-Plus而不是原生MyBatis。原生MyBatis写SQL确实灵活但问题是每张表都要配XML映射文件一个用户模块写下来铺开的文件数量能让人瞬间失去耐心。MyBatis-Plus的BaseMapper提供了selectById、insert、updateById这些开箱使用方法对大部分简单查询完全够用。复杂的多表关联查询我再手写SQL也不迟。其实有一个隐藏好处很多人没提MyBatis-Plus可以根据实体类生成建表SQL这对接下来的文档撰写帮助很大。你定义一个Bicycle实体字段写完对应的表结构文档就有一半了。表结构和实体类一致后面代码生成器再扫出来整个二次开发的效率完全不一样。2.3 MySQL 8 与 Redis 的取舍数据库就是MySQL 8这个基本没什么争议成本低、稳定、生态好毕设用它不会有任何额外负担。Redis我留了一个口子但没把它做成硬依赖验证码存储、首页热点车辆的缓存这些是缓存合理介入的地方。但是对于课程设计来说如果MySQL查出来直接返回性能完全够没有必要为自己增加部署复杂度。我写的方案是“缓存预留接口但默认走数据库”这样既能体现你对系统性能的思考又不会因为Redis没装导致整个项目跑不起来。2.4 为什么放弃“看起来更炫”的Spring Cloud 和 JSP其实我一开始也犹豫过要不要上一套微服务架构毕竟写简历上好看。但冷静想想一个自行车分享平台用户量撑不起微服务的粒度强行拆服务只会把链路弄复杂。服务拆了分布式事务、配置中心、注册中心这些反而成为新的坑而那些坑跟课题本身毫无关系。前端真没必要非上不可一世的模板引擎JSP。JSP在前后端分离的今天实在太重了我直接用了Vue 3 Element Plus做管理端页面后端只提供JSON接口。前后端分离还有一个好处答辩时可以直接说“后端接口与前端页面解耦方便后期扩展到小程序和移动端”这句话一出来评委一般都会点头。3. 数据库设计决定这个系统上限的不是代码是表结构3.1 核心表结构与关联关系数据库我把schema命名为bike_share。整个平台的数据模型核心就围绕“人”和“物”展开。下面这几张表是骨架表名职责关键字段user用户账号username, password, nickname, phone, avatarbicycle车辆信息user_id, brand, model, location, deposit, statusequipment骑行装备user_id, type, title, description, deposit, statusborrow_order借用订单order_no, user_id, owner_id, item_type, item_id, statuscomment评价order_id, from_user_id, to_user_id, content, ratefavorite收藏user_id, item_type, item_idimage图片附件biz_type, biz_id, url, sortnotice公告title, content, publish_timebicycle和equipment我特意拆成两张表而不是一张“物品表”加类型字段。原因是车辆和装备的属性差异很大车辆有品牌、车架尺寸、刹车类型装备有头盔型号、码表型号、适用场景。硬塞在一起字段会越铺越多查询时还得不断判断类型反而不如分开维护清晰。borrow_order是整个系统的核心表。注意这里没有直接存“借出人id借入人id物品id”就结束因为同一辆自行车可能被不同的人反复借用每一条订单必须记录的是“这一笔借用里谁借了谁的什么”。所以我把物品拆成item_type和item_id订单上同时记录user_id借入方和owner_id借出方。评价的时候from_user_id和to_user_id都直接从订单拿避免问用户“你要评价谁”这种蠢问题。3.2 状态机设计让每一辆车和订单都有清晰的生命周期这是整个项目里我最想强调的设计点。很多同学做这类系统车辆状态就一个“可借/不可借”订单状态就一个“进行中/已结束”表面功能是实现了但很多异常情况根本没有容身之地。我的做法是给每个核心对象定义一套完整状态机车辆状态bicycle.status状态值含义说明0待审核用户发布后默认状态前台不可见1可借用审核通过展示在列表页2已借出有生效订单占用3已下架车主主动下架或管理员强制下架4审核驳回管理员驳回用户可编辑重新提交订单状态borrow_order.status状态值含义0待确认借入方发起等待借出方确认1已确认/待取用2借用中确认取车后3已归还4已取消5已逾期6已拒绝为什么要做这么细因为每个状态改变背后都对应一个真实的业务动作。比如车辆从“可借用”变成“已借出”必须发生在订单确认的那一刻订单到了归还日系统要能标出逾期管理员驳回后用户要能重新编辑。状态机设计好在代码里就是一道if判断加一次update操作但如果没设计好后面加一个“逾期”需求就能让你改七八处逻辑。3.3 索引、唯一约束与逻辑删除索引我基本按照查询场景来加borrow_order表上必须建(user_id, status)复合索引因为“我的借用记录”是这个系统最频繁的查询bicycle表建(status, create_time)复合索引对应首页按最新发布和状态筛选favorite表建(user_id, item_type, item_id)唯一索引防止同一用户对同一物品重复收藏。逻辑删除方面我用了MyBatis-Plus的TableLogic所有表都加了deleted字段。但这里有一个非常隐蔽的坑如果user表用户名加了唯一索引逻辑删除后再注册同名用户会因为旧数据还在而导致唯一索引冲突。我的处理方式是唯一索引改成(username, deleted)联合索引这样每个用户名的“未删除记录”只有一条同时历史数据又不会被物理清掉。4. 后端核心链路登录认证、发布审核、订单并发一个都没少4.1 JWT 登录认证与权限拦截登录这块用的是JWT令牌方案服务端生成Token返回给前端前端后续请求在Header里带上Authorization: Bearer xxx。之所以不用传统的Session是因为前后端分离之后Session天然不好跨源维护而JWT无状态、适合接口鉴权。拦截器代码的思路其实很简单Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); if (JwtUtil.validate(token)) { Long userId JwtUtil.getUserId(token); UserContext.setUserId(userId); return true; } } response.setStatus(401); if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod hm (HandlerMethod) handler; // 检查是否需要管理员权限 if (hm.hasMethodAnnotation(RequireAdmin.class) !UserContext.isAdmin()) { response.setStatus(403); return false; } response.setStatus(401); return false; } }注意那些不需要登录就能访问的接口比如注册、登录、首页列表和详情展示都要在WebMvcConfig里逐一放行。我这里用路径白名单的方式写死一个list不要图省事把所有公开接口都扣掉拦截器那会导致后面加接口时忘记放行而莫名其妙404。4.2 发布、审核与上下架的流转用户发布车辆后默认status0待审核。管理员在后台看到待审核列表点通过就把状态改成1点驳回就改成4。这里有一个细节驳回时一定要填驳回原因且车辆信息要允许用户重新编辑后再次提交。我在实现上给bicycle表加了reject_reason字段驳回后把原因写进去。用户端“我的发布”列表会展示这条记录的状态和原因编辑保存后状态重置为0。这套逻辑拷到装备模块完全可以复用相当于同一套代码模式写了两次代码结构的一致性也会在答辩时加分。4.3 借用订单的创建与并发控制这节课教你像抢购一样写“预约”自行车分享平台看似没有秒杀场景其实有一辆好车挂在首页同时有三个人点击“预约借用”这就是典型的并发资源抢占。如果代码写成“先查状态再插入订单再改车辆状态”很可能三个人都看到“可借用”最后全部下单成功一辆车借给三个人。解决方式我用了乐观锁思路不带version字段而是直接用条件UPDATE来保证原子性Transactional public Long createBorrowOrder(Long itemId, String itemType, Integer borrowDays, Long userId) { // 生成订单号 String orderNo OrderNoGenerator.generate(); // 关键条件更新只有status1才允许改成2返回影响行数 int updated; if (bicycle.equals(itemType)) { updated bicycleMapper.updateStatusByIdAndStatus(itemId, 2, 1); } else { updated equipmentMapper.updateStatusByIdAndStatus(itemId, 2, 1); } if (updated 0) { throw new BizException(手慢了该物品刚刚已被借出); } BorrowOrder order new BorrowOrder(); order.setOrderNo(orderNo); order.setUserId(userId); order.setItemType(itemType); order.setItemId(itemId); order.setBorrowDays(borrowDays); order.setStatus(0); orderMapper.insert(order); return order.getId(); }这里的关键是updateStatusByIdAndStatus的SQL在我Mapper里写的就是update idupdateStatusByIdAndStatus update bicycle set status #{targetStatus} where id #{id} and status #{expectStatus} /update这条SQL保证同一时刻只有一个人能把状态从“可借用”改成“已借出”改成功的人才能插入订单。当时我用JMeter开了50个线程同时预约同一辆车最终生成的订单只有一条其余的全部返回“手慢了”那一刻我确信这个设计是站得住的。4.4 归还、评价与数据闭环归还动作是订单状态从“借用中”变成“已归还”同时车辆状态从“已借出”回到“可借用”。操作顺序上先把车辆状态改回去再更新订单状态因为车辆能不能重新被借直接影响后续用户如果反了车辆状态停留在“已借出”下一个用户的请求就被错误拦截了。评价属于订单完成后的子动作评价表通过order_id关联订单评论里的from_user_id和to_user_id都从订单里取。这样写一方面防止恶意乱评价另一方面也方便统计每个用户的信用记录。4.5 统一返回体与全局异常处理接口返回体我做得很早期就定了后面没改过。所有Controller返回都走R类Data public class RT { private Integer code; // 0成功非0失败 private String msg; private T data; public static T RT ok(T data) { ... } public static T RT fail(String msg) { ... } }配合全局异常处理业务层只管throw new BizException(xxx)RestControllerAdvice统一捕获并转成JSON返回不用在每个Controller写try-catch代码一下干净很多。5. 前端页面与接口对接让评委看得“像个系统”5.1 页面划分与路由设计前端我用Vue 3 Element Plus搭了管理端界面用户端页面则是朴素的原生HTMLCSS加少量Vue语法。为什么这样组合管理端功能多用现成组件库能快速出效果用户端要展示的“逛”的感觉需要定制首页轮播和卡片布局直接用框架反而被限制。页面路由我给用户端设计了首页、车辆列表、装备列表、车辆详情、装备详情、登录注册、个人中心、我的发布、我的借用、发布物品管理端设计了数据看板、用户管理、审核管理、订单管理、公告管理。5.2 接口规范与联调中的实际问题接口路径我按模块划分/api/auth/login、/api/auth/register/api/bicycle/list、/api/bicycle/detail、/api/bicycle/publish/api/order/create、/api/order/myBorrowList、/api/order/confirm、/api/order/return/api/admin/audit/list、/api/admin/audit/pass、/api/admin/user/list联调时最容易出问题的不是正常请求而是异常情况。前端经常拿到HTTP 200但业务code非0如果你一开始就让前端判断HTTP状态码对接必然乱。所以接口设计上我们把业务错误全部收敛到R.code里HTTP状态只保留401和403前端统一处理response.data.code逻辑就清晰得多。分页查询我统一返回PageResultData public class PageResultT { private Long total; private ListT records; }前端分页组件直接拿total当total拿records当table data不用再做多余的拆包处理。6. 部署与验证本地启动、Docker 上线、并发压测6.1 本地环境与数据库初始化本地起项目我这边的标准流程是安装JDK 17、Maven 3.8、MySQL 8创建bike_share数据库导入项目根目录下的bike_share.sql修改application.yml里数据库用户名密码mvn spring-boot:run启动后端前端代码分别启动Vue开发服务或者直接把打包好的dist文件放到Nginx下。application.yml里一份基础配置长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bike_share?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 50MB jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 06.2 Docker 部署 Spring Boot 服务端的配置如果不想让评委现场看你在IDEA里按绿三角可以把后端打成一个镜像用Docker部署。Dockerfile极其简单FROM openjdk:17-jdk-alpine COPY target/bike-share.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar]构建命令是mvn clean package -DskipTests docker build -t bike-share:1.0 . docker run -d --name bike-share -p 8080:8080 -e DB_HOST127.0.0.1 -e DB_PORT3306 bike-share:1.0生产环境记得给JVM留内存参数alpine基础镜像体积小但默认对内存没有限制部署在小机器上容易OOM。我一般加一个JAVA_OPTS环境变量启动时指定-Xms256m -Xmx512m。6.3 功能测试与并发压测结果功能测试我当时用了Postman把每个接口的请求和响应保存成集合一条条过。重点测的东西不是“正常返回了什么”而是“异常情况返回了什么”没带Token访问需要登录的接口是否401、管理员接口普通用户访问是否403、借已借出的车是否提示失败、归还时物品状态是否恢复正常。压测我用JMeter跑了一下下单接口的并发场景。50个线程同时请求同一辆车最终数据库里有效的订单只有1条其余49条请求全部被乐观锁拦下并返回友好提示。这个结果我放进了文档里答辩时直接打出来比嘴上说“我的系统支持并发”有说服力得多。7. 踩过的坑和复盘这些经验比源码更值钱7.1 MyBatis-Plus 自动填充的失效场景我在user表里加了create_time和update_time打算用自动填充统一维护。结果第一次插入用户时发现create_time是null。原因是我只在实体字段上加了TableField(fill FieldFill.INSERT)却没有配置MetaObjectHandler实现类。一定要补上这个处理器并在插入更新时分别调用strictInsertFill和strictUpdateFill否则自动填充就是个摆设。7.2 逻辑删除与唯一索引的隐蔽冲突这个坑我前面提过这里再展开一次。逻辑删除字段本身不会让数据物理消失如果唯一索引建立在单个业务字段上删除后再新增同值数据就会撞索引。我的用户表就踩过用户退出了再注册同名账号直接报Duplicate entry。解决办法就是刚才说的联合索引(username, deleted)让历史数据不参与唯一性判断。这个坑如果不提前设计好数据一多就非常难受。7.3 文件上传路径的跨平台问题发布车辆时上传图片我把图片存到了项目运行目录下的upload文件夹本地Windows开发好好的一到服务器上就出问题。后来发现两个原因一是服务器上没有这个目录程序不会自动创建二是路径分隔符Windows是\Linux是/写死字符串肯定会出错。我的处理是在配置文件里增加upload.path启动时用File.mkdirs()保证目录存在然后通过重写addResourceHandlers把上传目录映射成/upload/**静态资源路径。这样本地和服务器都只需要改配置代码不用动。7.4 订单号生成与LocalDateTime序列化订单号一开始我用的是UUID但UUID在列表页展示实在太突兀。后来改成“yyyyMMddHHmmss 4位随机数”足够展示用。如果你觉得并发量大会冲突可以在后边再拼上userId的后几位基本上万无一失。LocalDateTime序列化如果不配置前端拿到手的是一串带T的字符串比如“2025-01-01T10:30:00”跟页面风格完全不符。这个在application.yml里配好jackson.date-format和time-zone就行配完谁调接口都不会踩这个雷。7.5 关于答辩和文档的一些心里话源码包里那份万字文档我建议不是写完就扔而是把上面这些踩坑内容也挑两条写进去。答辩时评委更想听到的是“你怎么解决问题的”而不是“你复制粘贴了什么”。你要是能讲清楚乐观锁防止车辆被重复借出的那一场压测讲清楚逻辑删除和唯一索引那一次程序崩溃讲清楚上传目录在Linux上的路径坑这个项目的技术含量在评委眼里和其他人瞬间就拉开了距离。最后再分享一点实际体会做这种带“分享”“交易”属性的系统最关键的不是把页面做得好看也不是把表建得多全而是想清楚每一个状态变更发生的时机和触发的动作。自行车分享平台最核心的一辆车、一笔订单、一次评价它们之间是严格咬合的。把这个咬合关系用代码紧紧锁住系统才是真正“活”起来的。源码和文档只是交付物你在思考过程中建立起来的业务建模意识才是这次课程设计给你留下的最值钱的东西。
阅读完成 · 觉得有帮助?
咨询建站