每年毕设季总有同学来问我Java方向选什么题目比较稳。我的固定答案里经常有一个名字基于Web的出租车拼车系统。这个题目听起来不算惊艳但恰恰因为它足够贴近真实业务场景能覆盖Java Web开发从数据库设计、后端接口、前端交互到部署上线的完整链路而且做出来之后你完全有底氣拿给老师演示。这篇文章我就从选题原因、技术方案、数据库设计、核心算法到部署调试把整个项目从头到尾拆解一遍把我实际做过这个项目时踩过的坑、总结的经验全部写出来。如果你是计算机专业本科做毕设或者想找一个能写进简历的Java练手项目这篇文章应该能省你不少瞎折腾的时间。我会尽量用大家都能听懂的方式讲但该上的代码、该讲的原理一个都不会少。1. 项目整体设计与思路拆解1.1 核心需求解析出租车拼车到底要解决什么问题做项目之前先想清楚业务痛点不然代码写得再花哨也没用。出租车的闲置空驶率很高高峰期乘客又打不到车两边都难受。拼车就是让顺路的乘客共用一辆车费用分摊司机一趟多赚一点乘客少花一点城市交通压力也能缓解。所以这个系统最核心的价值不是“做一个网站”而是把“发布行程、寻找顺路人、确认拼车、费用计算、订单履约”这条链路跑通。把这个业务翻译成系统功能大概要管住几个事乘客发起行程起点、终点、出发时间、人数、愿意分摊的费用。司机发布空余座位路线、时间、剩余座位数。系统把匹配的乘客和司机拉到一起或者让乘客主动搜索拼车单。双方确认后生成订单订单状态要能跟踪待支付、已确认、进行中、已完成、已取消。用户能查看历史订单司机也能管理自己发布的行程。这里面最容易被忽略的是“状态机”——订单状态从创建到结束每一步都要有明确的流转条件。很多毕设代码写得很零散就是因为没先设计好状态。后面我会单独讲怎么设计字段。1.2 技术选型为什么用Java Spring Boot MyBatis这组技术栈放到今天依然是Java毕设里的“万金油”组合。Spring Boot把大量配置自动化你不用像以前用SSH框架那样写一堆XMLMyBatis让你自己控制SQL对新手友好也方便在答辩时讲清楚数据库操作逻辑前端用Thymeleaf服务端渲染再加一点Vue或原生JavaScript就够了没必要上一套前后端分离——毕设核心是完整度不是技术炫技。选这套方案还有一个实际原因你上网搜源码、查资料80%的Java毕设都是这个架构遇到问题几乎都能搜到答案。组里同学互相拷代码也方便。如果你用太冷门的技术栈比如响应式编程WebFlux、GraphQL连debug都会痛苦更别说答辩时被老师问倒。我的建议是JDK用8或者11别追新有些旧依赖在JDK17上会闹别扭。数据库用MySQL 5.7或8.0装个Navicat可视化操作。后端框架Spring Boot 2.7.x稳定且资料多。持久层MyBatis Plus也行但最好先了解原生MyBatis。做毕设的话MyBatis Plus能省很多CRUD代码答辩时也可以说“我用了MyBatis Plus提升开发效率”。前端Thymeleaf模板 Bootstrap布局 jQuery发请求三天能搞定页面。项目管理Maven必须用考勤表里也好看。1.3 功能模块划分乘客端、司机端、管理后台我实际做的时候把系统分成三个角色每个角色对应一套页面和接口。这样分层清晰写代码时脑子里有地图。角色核心功能关键页面乘客注册登录、发布拼车需求、搜索可拼车辆、查看订单、确认支付、评价司机首页、发布需求页、搜索结果页、订单列表页司机注册登录、发布空余座位、查看匹配乘客、接单/确认、完成订单、查看收入司机工作台、行程管理页、收入明细页管理员用户管理、行程审核、订单监管、统计数据后台管理页、数据看板角色不一定要分太多但乘客和司机这两类用户的核心流程必须完整。我见过不少项目只做了一个“发布拼车需求”的孤零零页面没有后续订单处理老师一问“然后呢”就卡住了。所以哪怕是统一样式也要把订单闭环做完。后端接口设计上我按模块划分ControllerUserController管登录注册TripController管发布搜索OrderController管订单流程AdminController管后台操作。每个Controller只做参数接收和结果返回具体逻辑丢给Service层Mapper只负责和数据库打交道。保持这个三层结构后面加功能或者改bug都轻松。2. 核心细节解析与实操要点2.1 数据库设计订单表、用户表、拼车记录表的关键字段数据库设计是这个项目的地基。我在带学生做的时候发现至少一半人的表结构有问题要么字段冗余要么状态含义不清要么没法支撑“拼车”这个核心概念。下面是我用过且跑通完整的表设计可以直接参考。用户表t_userCREATE TABLE t_user ( id int NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL, password varchar(128) NOT NULL, role tinyint NOT NULL DEFAULT 0 COMMENT 0-乘客1-司机2-管理员, phone varchar(20) DEFAULT NULL, real_name varchar(32) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY idx_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段一定要存加密后的值至少用MD5加盐别存明文。角色用数字枚举方便权限判断。行程表t_trip一个行程可以来自司机发布空座也可以来自乘客发布拼车需求所以统一起见我设计为“出行计划表”CREATE TABLE t_trip ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL COMMENT 发布人id, user_type tinyint NOT NULL COMMENT 发布人角色1-司机2-乘客, start_point varchar(128) NOT NULL COMMENT 出发点, end_point varchar(128) NOT NULL COMMENT 终点, start_time datetime NOT NULL COMMENT 出发时间, total_seats int DEFAULT 1 COMMENT 总座位数, available_seats int DEFAULT 1 COMMENT 剩余座位数, price decimal(10,2) DEFAULT NULL COMMENT 预计单人费用, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待匹配1-已成团2-已取消3-已完成, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_start_end (start_point, end_point) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status和available_seats是两个最容易出错的地方。比如乘客发布需求total_seats可以理解为“这个乘客愿意和几个人拼车”司机发布座位total_seats就是空位数。当有人加入行程时available_seats要减一当减到0就该把status改成“已成团”。订单表t_order拼车确认后生成订单将“谁、坐谁的车、什么路线、花多少钱”记录下来CREATE TABLE t_order ( id int NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单编号, trip_id int NOT NULL COMMENT 关联行程id, passenger_id int NOT NULL COMMENT 乘客id, driver_id int NOT NULL COMMENT 司机id, pickup_point varchar(128) DEFAULT NULL COMMENT 实际上车点, price decimal(10,2) NOT NULL COMMENT 实际费用, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待确认1-已确认2-进行中3-已完成4-已取消, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段里我特别用了pickup_point因为拼车场景下乘客不一定要去行程的起点集合可以约在沿途某个路口这个字段留给双方协调。虽然只是个小细节但答辩时能体现你对业务的理解。拼车记录表t_carpool_record这张表用来支持“查看某个行程都有谁参与”也可以用于统计司机收益、乘客花费CREATE TABLE t_carpool_record ( id int NOT NULL AUTO_INCREMENT, trip_id int NOT NULL, order_id int NOT NULL, user_id int NOT NULL, join_type tinyint NOT NULL COMMENT 1-司机2-乘客, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;有了这张表查询“某行程的拼车成员”就是简单的一行SQL。很多初学者会直接在行程表里拼逗号字符串存成员id这种做法极难维护一定不要学。2.2 拼车匹配算法最朴素但够用的实现思路“拼车匹配”听起来很高大上但毕设阶段真没必要去搞复杂的图算法或者多目标优化。我用的方法是基于“出发地相近 终点相近 时间窗重叠 座位充足”的规则匹配外加人工选择作为兜底。具体流程可以这样拆乘客或者司机发布行程后系统把这个行程存到t_trip。另一类用户搜索拼车时前端把起点、终点、时间传给后端。后端执行查询起点匹配模糊或精确、终点匹配、出发时间在前后两小时内、available_seats 0。把匹配结果按时间倒序返回用户自己选择加入合适的行程。搜索的核心SQL大概长这样SELECT * FROM t_trip WHERE status 0 AND start_point LIKE CONCAT(%, #{start}, %) AND end_point LIKE CONCAT(%, #{end}, %) AND available_seats 0 AND start_time BETWEEN #{startTimeMinus} AND #{startTimePlus} ORDER BY start_time ASC这里的startTimeMinus和startTimePlus是前后两小时的时间窗口。为什么不用“完全等于”因为拼车场景下时间不可能精确到分钟多一点宽容度才能匹配到人。如果后续想提升匹配率再加一个排序加分项出发时间越近、座位越多排越前或者引入起点距离通过经纬度计算。但毕设做完上面这版已经足够。我自己踩过的坑是把搜索条件一股脑全用AND拼起来结果测试时任何一项不满足就啥也查不出来。所以实际调的时候我会把“终点一样”作为强条件“出发地相近”用模糊匹配“时间窗”放宽到前后三小时。如果还搜不到就直接提示用户“暂无可拼行程可以自己发布”。2.3 会话管理与权限控制登录态、角色拦截Web系统逃不开登录态特别是这种多角色系统不加权限控制会被人从乘客页面直接访问司机接口改数据。我用的方案是Session 拦截器简单可靠不需要引入Shiro或者Spring Security如果你会可以加但毕设里手写拦截器更能体现基本功。用户登录成功之后把用户对象放进Sessionsession.setAttribute(loginUser, user);然后写一个拦截器检查Session里是否有登录用户以及当前请求的路径是否允许该角色访问。Spring Boot里通过WebMvcConfigurer注册拦截器Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /login, /register, /css/**, /js/**, /images/**, /error ); } }在拦截器的preHandle里写判断public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } // 角色判断如果是管理员接口只有role2能过 String uri request.getRequestURI(); if (uri.startsWith(/admin) user.getRole() ! 2) { response.setStatus(403); return false; } return true; }注意拦截器里做角色判断时路径匹配一定要规范。我见过有同学把/admin开头的所有路径都拦截住了结果管理员页面自己的静态资源也被拦掉页面样式全丢。记得把/admin/**的静态资源也放行或者在页面里使用公共静态资源路径。权限这块可以再扩展一个“司机接口”拦截只有role1才能访问发布座位接口。拦截器代码量不大但能让你的系统在安全和答辩两个维度都加分。3. 实操过程与核心环节实现3.1 开发环境准备与项目初始化正式开始打代码前把环境一次性配好免得后面边写边装。我推荐的版本组合是JDK 1.8这是最保守的选择所有框架都兼容Maven 3.6MySQL 5.7 / 8.0IDEA 2020版以上社区版也够用Navicat或DBeaver管理数据库Chrome浏览器调试前端初始化项目有两种方式去Spring Initializr官网下载或者直接在IDEA里新建Spring Boot项目。选依赖的时候勾上Spring Web、Thymeleaf、MyBatis Framework、MySQL Driver如果你要用MyBatis Plus就把MyBatis Framework换成MyBatis Plus的starter不过那个需要去Maven仓库单独引。application.yml里最容易被坑的就是时区spring: datasource: url: jdbc:mysql://localhost:3306/carpool?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: trueserverTimezoneAsia/Shanghai不加你会被数据库时间差8小时搞疯。map-underscore-to-camel-case开启后create_time就会自动映射到createTime省掉一堆resultMap。3.2 核心代码实现发布行程、搜索拼车、确认订单接下来我用三段代码把最核心的业务串出来这三个接口能通整个项目就成功了大半。发布行程接口乘客或者司机点击发布后前端POST一个trip对象过来Service层做校验比如出发时间不能早于当前时间Override public boolean publishTrip(Trip trip) { if (trip.getStartTime().isBefore(LocalDateTime.now())) { throw new RuntimeException(出发时间不能早于当前时间); } if (trip.getAvailableSeats() 0) { throw new RuntimeException(座位数必须大于0); } trip.setStatus(0); // 待匹配 return tripMapper.insert(trip) 0; }这里我把user_id从Session里取前端传的一律不信任。否则用户可以篡改表单数据把别人的账号伪装成自己。搜索拼车接口搜索逻辑放到Service层封装一个查询条件对象public ListTrip searchTrips(String start, String end, LocalDateTime time) { LocalDateTime startMinus time.minusHours(2); LocalDateTime startPlus time.plusHours(2); return tripMapper.searchTrips(start, end, startMinus, startPlus); }对应的Mapper XML刚才已经写过了。这里要提一个细节查询条件用LIKE CONCAT(%, #{start}, %)如果start传空字符串会匹配出所有记录。所以业务上要判断一下StringUtils.hasText(start)如果没填就只按时间过滤别把条件拼死。确认订单接口乘客选择了某个可加入的行程后点击“申请拼车”后端要做两件原子性的事情创建订单同时把行程的available_seats减一。这里必须加事务控制不然订单建好了座位没减或者座位减了订单失败数据就对不上。Transactional public Order createOrder(Trip trip, User passenger) { // 1. 再次校验行程状态和座位 Trip dbTrip tripMapper.selectById(trip.getId()); if (dbTrip.getStatus() ! 0 || dbTrip.getAvailableSeats() 0) { throw new RuntimeException(该行程已满或不可加入); } // 2. 创建订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setTripId(dbTrip.getId()); order.setPassengerId(passenger.getId()); order.setDriverId(dbTrip.getUserId()); order.setPrice(dbTrip.getPrice()); order.setStatus(0); // 待确认 orderMapper.insert(order); // 3. 扣减座位 dbTrip.setAvailableSeats(dbTrip.getAvailableSeats() - 1); if (dbTrip.getAvailableSeats() 0) { dbTrip.setStatus(1); // 已成团 } tripMapper.updateById(dbTrip); return order; }注意上面代码里的“校验行程状态”不是只查一次而是在事务内重新从数据库查要用SELECT ... FOR UPDATE锁行才能防止并发下两个乘客同时抢最后一个座位。毕设答辩时如果能说出“并发下可能超卖我用乐观锁或悲观锁解决”老师会对你刮目相看。我实际用的是乐观锁给t_trip表加了一个version字段更新时判断版本号UPDATE t_trip SET available_seats available_seats - 1, version version 1 WHERE id #{id} AND version #{version} AND available_seats 0这种写法比直接锁表更轻量适合毕设场景。3.3 前端页面开发与交互页面我用Thymeleaf做服务端渲染配合少量Ajax。整体框架直接从Bootstrap官方模板改省去自己想样式的时间。首页核心是一个搜索框用户填起点、终点、出发时间点击搜索跳转到结果页。搜索结果用th:each循环渲染每一行显示“出发地-终点、时间、剩余座位、价格”旁边放一个“申请拼车”按钮。tr th:eachtrip : ${tripList} td th:text${trip.startPoint}/td td th:text${trip.endPoint}/td td th:text${#temporals.format(trip.startTime, yyyy-MM-dd HH:mm)}/td td th:text${trip.availableSeats}/td td th:text${trip.price}/td td a th:href{/order/apply/ ${trip.id}} classbtn btn-primary btn-sm申请拼车/a /td /tr如果要用Ajax做局部刷新比如司机接单后不让整个页面刷新可以在Thymeleaf页面里写jQuery的$.post成功后更新按钮状态和订单状态字段。我建议别把所有交互都做成前后端分离的Vue因为毕设时间有限Thymeleaf足够而且代码量小、容易讲清楚。页面整体颜色建议用蓝白主色调别搞得太花哨老师看的是功能不是视觉设计。但至少要保证页面排版不歪、按钮能点、表单能正确提交。有的同学页面乱到按钮都点不到那种印象分一下就扣光了。4. 常见问题与排查技巧实录4.1 数据库连接与中文乱码这个几乎是人人都要踩的坑。启动项目后数据库能连上但是插入中文数据后显示问号或者查询条件带中文查不到。解决办法有三板斧建库时指定字符集CREATE DATABASE carpool DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接URL加上useUnicodetruecharacterEncodingutf8。如果是MySQL 8.0还要加上serverTimezoneAsia/Shanghai否则会报时区错误。另外IDEA里.java文件编码也要统一成UTF-8不然代码里写死的中文会变成乱码。在IDEA的设置里搜索File Encoding把Global Encoding、Project Encoding、Properties Files都改成UTF-8。4.2 拼车匹配逻辑的边界情况搜索接口写完测试时发现一堆“查不出来”或者“错误匹配”的问题。我举几个真实场景乘客发布的是“从五道口到中关村”有人搜“五道口地铁站到中关村”模糊匹配不出来但人知道这是同一个地方。解决方法是允许用户手动选择起点附近的地标或者干脆就用模糊匹配宁可查多也不能漏。时间窗口判断越界比如行程是8点发车用户搜8点30分的车如果只做了start_time startMinus而忘了 startPlus就会把8点之后的都漏掉。写条件时要同时用BETWEEN。重复申请同一个用户对同一个行程点了两次“申请拼车”生成了两个订单。解决方法是建唯一索引(trip_id, passenger_id)或者在Service层先查一下是否存在待确认订单。这些都是真实业务中会出现的问题把它们整理进你的“测试记录”里答辩时讲给老师听说服力非常强。4.3 部署到云服务器时的坑毕设答辩一般要求能在线演示很多人最后卡在部署上。我用一台最低配的云服务器2核4G部署这个项目几个关键点打jar包mvn clean package会生成target/carpool.jar。服务器装JDK和MySQL把本地数据库导出再导入。注意导出的SQL里如果有DROP TABLE记得先备份。启动命令建议用nohup java -jar carpool.jar log.log 21 这样SSH断开后服务不会停。云服务器安全组一定要开放8080端口或者你用80端口就用80。如果网站打不开第一步看日志tail -f log.log。90%的问题都是端口没开、数据库连不上、或者路径写死成了本地地址。4.4 项目讲解与答辩技巧技术做完了还得会讲。我建议准备一个十五分钟的演示脚本按这个顺序来先花一分钟讲清楚系统解决什么问题出租车空驶、高峰期打车难。展示系统架构图和三张核心表的关系。演示完整的业务流程注册两个账号一个司机发布座位一个乘客搜索并申请拼车司机确认订单生成状态流转。讲一两个你自己解决过的难点比如并发扣减座位、时间窗口匹配。这是拿分点比背一堆概念有用。答辩老师经常会问“为什么用MyBatis不用JPA”“为什么Session不用JWT”你不用背标准答案就用自己的场景解释查SQL更直观、调试简单、毕设项目不需要分布式鉴权。只要逻辑自洽老师不会为难你。还有一点私下提醒哪怕你是从网上找的源码也要自己动手改几个功能、加几个字段。因为答辩现场老师会随机问你某个页面怎么实现的你要是连代码在哪都翻不清楚那就真的很尴尬。我做定制化修改的时候最常加的功能是“历史订单导出Excel”和“按时间段统计司机收入”既简单又实用还能给项目增加亮点。这个项目后续还能怎么扩展如果你做完基础版还有精力有几个方向值得一试加入地图API展示路线、引入Redis缓存热门搜索、把单机部署改成Docker容器化、给拼车匹配增加更精确的经纬度距离计算。这些扩展不需要全做挑一个合适的做深一点项目的深度立刻就不一样了。我个人做这个项目最大的感受是它不像电商系统那样要堆一堆商品和购物车概念也不像管理系统那样枯燥拼车业务天然带着一点“算法味”和“社交味”做起来不无聊。而且它麻雀虽小五脏俱全用户体系、角色权限、业务状态流转、数据统计全都有用来检验自己的Java Web水平再合适不过。希望这篇拆解能帮你少走一些弯路如果你在做的过程中卡住了可以顺着文章里提到的“状态设计”“事务控制”“匹配窗口”这几个关键点先自查一遍多半能自己找到问题。