每年一到毕业季,后台都会收到一堆关于“Java毕设怎么选”、“源码跑不起来怎么办”的私信。我自己的习惯是,不管学生还是刚转行的朋友来问,如果手里正好没什么好题目,我一般都推荐一类系统:业务完整度够、技术栈主流、演示效果好、答辩还能讲出花来的Web项目。今天要拆解的这套“基于Web的出租车拼车系统”,就是我心目中非常典型、也很有嚼头的一个毕设题目。它不只是一个普通的增删改查,里面既有用户端、司机端、管理后台三类角色的权限区分,又有拼车匹配、订单流转、计费规则这些带点算法味道的逻辑,再加上前后端分离的架构,用来做毕设或者简历项目都非常合适。下面我按自己设计这套系统的思路,把核心模块、数据库设计、匹配算法的常见写法、以及调试部署时最容易踩的坑一次讲清楚。
1. 为什么选这个题目:毕设选题的思路与价值判断
1.1 这道题到底在考什么
很多同学挑毕设题目的时候,容易掉进两个极端:要么选了纯商城、纯博客这种太“平”的题目,做到后面发现自己只是在堆CRUD,答辩老师问一句“你的系统难点在哪”就哑火了;要么选了人脸识别、推荐系统这种看上去高大上的题目,结果一半时间耗在调模型上,代码跑不出来,最后连演示都翻车。出租车拼车系统正好卡在中间:难度适中,但每一层都有东西可讲。
从功能上说,它至少覆盖了三类角色的完整业务流程。乘客要能注册登录、发布行程、搜索拼车单、发起预约、在线支付;司机要能录入车辆信息、发布空余座位、接单、确认行程、结算;管理员要能审核司机资质、审核行程、处理投诉、看统计报表。这三条线走通,就是一个标准的多角色Web应用,能覆盖软件工程课上学到的几乎所有知识点。
从技术上说,它天然适合用Spring Boot + MyBatis做后端,Vue/Element UI做前端,MySQL存业务数据,Redis存会话和热点数据。这套组合现在就是Java岗位的日常标配,做出来之后简历上直接能写“独立设计并实现一套前后端分离的拼车平台”,比写一百遍“熟悉Spring Boot”都有说服力。
1.2 一个题目能讲出几个层面的故事
我一般会跟学生说,毕设的“深度”不是靠堆功能,而是靠把一个点挖透。这道题能挖的点其实不少:
- 拼车匹配逻辑:虽然不用做到滴滴那么智能,但你可以实现“按出发地距离 + 目的地相似度 + 出发时间窗口”的综合打分,这就有算法含金量了。
- 并发控制:同一个行程的座位数有限,多个乘客同时抢同一个座位怎么防止超卖?这就能引出乐观锁、Redis事务这些进阶话题。
- 状态机设计:一条拼车订单从乘客发起、司机确认、乘客上车、行程结束到结算完成,中间有取消、超时、投诉等分支,怎么设计状态流转才能不乱?这是面试里特别爱问的东西。
- 角色权限模型:乘客、司机、管理员三套菜单和操作权限,用拦截器或Spring Security怎么控制?
你看,光这四个点展开写,论文工作量就出来了,而且都不算超纲。
1.3 适合谁来参考
如果你是基础一般、想踏踏实实做完一个项目顺利毕业的本科生,这道题友好;如果你手里已经有一两个小项目,想冲一下更好看的简历去面试,这道题也能给你足够的话题。就算你已经工作了,想拿这套系统当脚手架改成网约车、顺手车、通勤拼车之类的产品,代码结构稍作调整就能复用,我自己做的时候就是抱着“以后要扩展”的想法去设计的。
2. 技术选型与整体架构设计
2.1 后端为什么是Spring Boot + MyBatis-Plus
现在毕设如果用SSH(Struts + Spring + Hibernate),答辩老师看了都会皱眉,因为这套东西已经脱离生产实践了。Spring Boot 2.x + MyBatis-Plus基本是当前Java Web毕设的最优解,理由很实在:
- Spring Boot的自动配置让环境搭建成本很低,一个Spring Initializr就能生成可运行的骨架,不用像SSH那样写一堆XML配置。
- MyBatis-Plus在原生MyBatis上加了通用Mapper和LambdaQueryWrapper,写单表查询基本不用手写SQL,开发效率高出一大截,尤其是我们做订单列表分页、按条件筛选这类操作,几行代码就能搞定。
- 它对事务、拦截器、多数据源的支持很成熟,就算后面想加ShardingSphere分库分表,也能平滑扩展。
数据库我选的是MySQL 5.7+。需要注意的坑是:不要装8.0以上版本后使用默认的认证插件,否则JDBC连接会报Unable to load authentication plugin 'caching_sha2_password',要么在连接串上加allowPublicKeyRetrieval=true,要么直接建用户时指定mysql_native_password。很多同学项目跑不起来,问题不在代码,就卡在这个连接上。
2.2 前端分离方案的取舍
这题的演示效果好不好,前端占了很大比重。我建议用前后端分离:Vue 2.x + Element UI + Axios。后端只需要提供一套RESTful API,前端单独跑在8080端口,通过代理转发到后端8081。这样做的好处是:
- 开发和调试互相不阻塞,我改后端接口不影响前端页面,反之亦然。
- 答辩演示的时候可以一边开着Vue devtools看数据流,一边开着Swagger看接口文档,显得你非常专业。
- 代码结构上是两个独立工程,论文里可以专门写一章“前后端交互与接口设计”,内容量很充实。
当然,如果你对Vue不熟,也可以用Thymeleaf模板引擎直接在后端渲染页面,项目结构会简单一些,但可展示的深度会打折。我的建议:既然题目是“基于Web的出租车拼车系统”,那就彻底一点,做成现在企业里最常见的前后端分离形态,这也是最容易被面试官认可的。
2.3 系统分层与包结构规划
拿到源码以后,第一件事不是急着跑,而是先看包结构。我习惯这样分:
com.example.carpool ├── controller # 接口入口,只做参数接收和响应封装 ├── service # 业务逻辑层,事务注解标注在这里 │ └── impl ├── mapper # MyBatis-Plus的Mapper接口,继承BaseMapper ├── entity # 数据库实体类,标注@TableName ├── dto # 前端传入参数对象,加上@Validated校验 ├── vo # 返回给前端的结果对象 ├── config # 跨域配置、WebMvcConfigurer、MyBatisPlus分页插件等 ├── common # 统一返回结果Result、异常处理、常量类 └── CarpoolApplication.java这种分层的好处是职责清晰:Controller里绝对不写SQL,Service只做业务编排,Mapper只管数据库交互。答辩时被问到“高内聚低耦合怎么体现的”,直接把这套结构讲一遍就可以了。代码评审和后续扩展也省心,比如加一个“优惠券”模块,新增entity、mapper、service、controller四个类就行,其余地方不用动。
2.4 统一响应体与异常处理
一个很容易被忽略但很加分的点是统一响应结构。我定义了一个Result<T>类,所有接口都返回这种格式:
public class Result<T> implements Serializable { private Integer code; // 200成功,500业务异常,401未登录 private String msg; private T data; // 省略构造方法和静态工厂方法 // 常用:Result.success(data)、Result.error("座位不足") }前端Axios封装了响应拦截器,判断code字段决定是弹this.$message.error(msg)还是正常渲染。这样整个系统的错误提示是统一的,不会出现有的接口返回{"error": "xxx"},有的返回{"message": "yyy"}这种杂乱情况。
全局异常处理我用@RestControllerAdvice。业务层抛出BusinessException("该行程已被预约"),全局处理器捕获后返回Result.error,HTTP状态码仍然保持200,这是很多互联网公司的做法——用业务code而不是HTTP状态码来区分业务成败,前端处理起来更简单。如果你把这个设计写进论文的“系统非功能性设计”一节,是很稳的加分项。
3. 核心功能模块与业务流程拆解
3.1 用户端:从注册到拼车成功的完整链路
用户端是功能最密集的地方,我们用一条“乘客小张想拼车”的路线来看所有用例。
小张第一次来,需要注册。注册表单里有用户名、手机号(这里建议做格式校验)、密码(用BCrypt加密存储,千万不要明文存密码)。注册成功后登录,后端用JWT签发一个token返给前端,前端存到localStorage,后续每次请求在header里带上Authorization: Bearer <token>。这个机制几乎是Java岗面试必问,你能讲清楚token无状态验证和Session的区别,已经超过大半求职者了。
登录后小张有两种拼车姿势:
第一种是“搭别人的车”。他先去行程大厅看列表,列表展示了司机的出发地、目的地、出发时间、剩余座位、拼车单价。筛选条件按他的目的地查,比如只想去“高新区软件园”的。他选中一条行程后点击“预约拼车”,后端就要做一件事:判断该行程剩余座位是否大于0,如果大于0则创建一条PENDING状态的拼车订单,并临时锁定一个座位。这个“锁座位”非常重要,我会在后面的并发小节细说。
第二种是“找人与他拼车”。小张自己发布一条行程:从“白水塘小区”到“软件园”,时间是明天早上8点半,共享座位数2个,备注可以带宠物或大件行李。发布后他在自己行程列表里能看到谁发起了拼车申请,点“同意拼车”之后,对方才算是拼车成功。
完整的链路走到这里,已经覆盖了“注册->登录->找行程->预约->发布行程->处理申请”六个核心用例,足够撑起用户端的工作量了。
3.2 司机端:车辆认证与行程管理
司机端不是单独做一个App,而是在同一个Web系统里用角色区分。司机注册后默认是乘客角色,只有提交了驾驶证、行驶证、车辆照片等信息并经过管理员审核,才能开通“发布行程”的权限。
这里有一个设计决策值得写进论文:司机与车辆的信息是一对多还是多对一?现实中一个司机可以拥有多辆车,但为了毕设简化,我采用一司机一车,直接字段挂在用户表扩展信息里。答辩如果被问到“怎么支持一个司机多辆车”,就说预留了driver_vehicle关联表,当前版本为了方便演示做了简化,这样既回答了问题,也不显得你没想到。
司机发布行程的页面里,要自动带上他车辆的座位数、车牌号、车辆品牌颜色,这些信息从司机认证记录里读取。行程发布成功后,司机端出现两个列表:我发布的行程和待处理的拼车申请。点“同意申请”,系统给乘客推送一条消息(毕设里用站内消息即可,不用接短信SDK);点“拒绝”,释放座位。
3.3 管理后台:审核、风控与数据统计
管理后台是很多同学容易只做一半的模块,但它恰恰是答辩老师第一眼会点进去看的地方。我的设计里包含这些页面:
- 司机认证审核:查看上传的证件照片,通过或驳回,驳回要填原因。
- 行程审核:对敏感目的地、异常定价的行程做下架处理。
- 拼车订单管理:按订单号、乘客、司机、时间范围查询,处理用户投诉,可强制取消订单。
- 数据统计:用ECharts画三张图——每日拼车订单量折线图、热门线路Top10柱状图、司机完单量排行榜。这些统计SQL不难,但非常能体现你的数据意识。
管理员账号我建议单独用初始化SQL插入,不要和普通用户注册通道混在一起,防止有人把自己权限改成管理员。
3.4 拼车匹配的核心逻辑:不止是模糊查询
题目叫“拼车系统”,匹配算法自然是最有含金量的部分。最简陋的写法是前端传一个关键词,SQL里LIKE '%keyword%',这样只能叫搜索,不能叫匹配。我给出一个折中但很实用的方案:出发地范围匹配 + 目的地文本相似度 + 时间窗口打分。
先说思路。用户A要找拼车,他给三个参数:出发地经纬度、目的地文本、出发时间。系统遍历所有状态为OPEN的行程:
- 出发地距离:计算行程发布者出发地与A出发地的球面距离,小于等于3公里才进入候选。
- 出发时间差:绝对值小于等于1小时进入候选。
- 目的地相似度:把发布者的目的地文本和A的目的地用字符串相似度算法(比如编辑距离或Jaccard相似度)计算得分。
- 最终排序:综合得分 = 距离得分 × 0.4 + 时间得分 × 0.3 + 目的地相似度 × 0.3。
球面距离用Haversine公式计算,Java代码不长,但很有面试话题:
public static double getDistance(double lat1, double lng1, double lat2, double lng2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = radLat1 - radLat2; double b = Math.toRadians(lng1) - Math.toRadians(lng2); double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); return s * 6371.008; // 返回公里数,6371是地球平均半径 }这块可以放在Service层一个单独的MatchService里,不要写在Controller里。答辩的时候,把匹配公式一讲,老师基本就能判断你是真做了系统的,而不是只背了八股文。
4. 数据库设计与订单状态机
4.1 核心表结构设计
整套系统的表我控制在九张以内,既够用又不冗余:
| 表名 | 说明 | 关键字段 |
|---|---|---|
user | 用户表(乘客/司机/管理员) | username, password(BCrypt), phone, role, status |
driver_info | 司机认证信息 | user_id, license_no, car_no, car_model, car_seats, audit_status |
travel | 行程表(司机发布) | driver_id, origin, destination, origin_lnglat, dest_lnglat, depart_time, total_seats, remain_seats, price_per_seat, status |
carpool_order | 拼车订单表 | order_no, travel_id, passenger_id, seats, amount, status, create_time |
message | 站内消息表 | from_id, to_id, content, type, is_read |
complaint | 投诉表 | order_id, user_id, content, handle_status |
admin_log | 操作日志表 | admin_id, action, target_type, target_id |
dict | 数据字典 | type, name, value |
设计的时候有两个细节容易踩坑,必须提醒:
- 金额字段不要用
float或double,要用DECIMAL(10,2)。浮点数算金额会出现0.1+0.2=0.30000000000000004这种问题,答辩时被指出来非常尴尬。 - 时间和
origin_lnglat这种字段在MySQL里类型要选对:日期时间统一用datetime,经纬度分别用两个decimal(10,6)字段存,比存一个字符串再解析方便得多,后续计算距离也不用折腾格式。
4.2 订单状态机与异常分支
拼车订单我定义了以下状态:
PENDING(待司机确认) -> CONFIRMED(已确认) -> ON_BOARD(乘客已上车) -> FINISHED(已完成) PENDING -> CANCELED(乘客/司机取消) CONFIRMED -> CANCELED(行程取消) CONFIRMED -> COMPLAINT(投诉中) -> SETTLED(已处理)状态流转的合法性判断写在Service里,不是前端控制。比如乘客取消订单时,后端判断订单当前状态是不是PENDING,是才能取消,如果是CONFIRMED就进入“协商取消”流程,这种细节写进论文里非常加分。
创建订单时的并发问题我觉得值得多写几句。假设一个行程剩余座位只有1,但同时来了3个乘客预约。如果用最简单的写法:SELECT remain_seats FROM travel WHERE id=1,判断大于0就UPDATE remain_seats = remain_seats - 1,在高并发下会产生“幻读”,三个人都读到1,都执行更新,最后变成-2,这就是超卖。
解决方案有两个:
- 乐观锁:
UPDATE travel SET remain_seats = remain_seats - 1 WHERE id = ? AND remain_seats > 0,更新后判断影响行数,等于0说明座位已经被抢完,返回“手慢了”。 - Redis秒杀式:拼接座位数量Key,用
DECR操作原子扣减,扣到负数就拒绝。
毕设项目没有真正的高并发流量,但答辩老师问“你怎么防止超卖”时必须答得出来。我推荐第一种,因为它没有引入额外组件,逻辑也直观,代码里就一个SQL的事。
4.3 计费逻辑与订单金额
拼车计费这块,我的简化规则是:乘客支付金额 = 拼车单价 × 座位数。但要注意,一个乘客一次性预订2个座位,单价可以打95折,这是很自然的运营策略,写进论文里又是一个小亮点。计算时机是乘客确认下单时,金额直接存储到订单表,不要每次都现算。
另外,订单生成时单号要设计一下。网上常见错误是直接用UUID.randomUUID(),字符串32位太长且无序。我用的方案:yyyyMMddHHmmss + 6位随机数 + 用户ID后四位,这样从单号就能看出下单时间,也方便查问题。
String orderNo = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + String.format("%06d", ThreadLocalRandom.current().nextInt(999999)) + String.format("%04d", userId % 10000);4.4 事务边界怎么划
什么叫“创建拼车订单成功”?
- 查询并校验行程状态和剩余座位。
- 扣减行程剩余座位。
- 插入拼车订单记录。
- 给司机发送站内消息。
这四个操作必须在一个事务里,任何一个失败都不能留下脏数据。做法是:在Service方法上加@Transactional(rollbackFor = Exception.class),注意要指定rollbackFor,因为Spring默认只回滚运行时异常,如果方法里抛了受检异常而不指定,事务是不会回滚的。这个坑很隐蔽,我见过不少同事栽在这上面。
5. 关键功能实操:拼车匹配和预约的代码实现
5.1 实体和Mapper层的快速搭建
用MyBatis-Plus的BaseMapper,实体类只需要做字段映射声明,无需手写XML。
@Data @TableName("travel") public class Travel { @TableId(type = IdType.AUTO) private Long id; private Long driverId; private String origin; private String destination; private BigDecimal originLat; private BigDecimal originLng; private BigDecimal destLat; private BigDecimal destLng; private LocalDateTime departTime; private Integer totalSeats; private Integer remainSeats; private BigDecimal pricePerSeat; private Integer status; // 0已发布 1已满 2已取消 3已完成 }Mapper接口:
@Mapper public interface TravelMapper extends BaseMapper<Travel> { }分页插件在Config里注册一下,后面查列表用Page对象:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }5.2 匹配算法的Service层实现
下面这段就是我前面说的匹配逻辑的具体落地,方法入参是乘客想去的终点、出发地经纬度和时间。注意这里简化了:出发地距离、时间差、目的地文本相似度都要经过计算。
@Service @RequiredArgsConstructor public class MatchServiceImpl implements MatchService { private final TravelMapper travelMapper; @Override public List<Travel> matchTravels(String destText, double lat, double lng, LocalDateTime time) { // 1. 先粗筛:只查已发布且未满座的行程 LambdaQueryWrapper<Travel> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Travel::getStatus, 0) .gt(Travel::getRemainSeats, 0); List<Travel> candidates = travelMapper.selectList(wrapper); // 2. 打分排序 return candidates.stream() .map(t -> { double distanceScore = scoreDistance(lat, lng, t.getOriginLat().doubleValue(), t.getOriginLng().doubleValue()); double timeScore = scoreTime(time, t.getDepartTime()); double destScore = calculateSimilarity(destText, t.getDestination()); double total = distanceScore * 0.4 + timeScore * 0.3 + destScore * 0.3; t.setScore(total); // 临时字段 return t; }) .filter(t -> t.getScore() > 0.5) .sorted(Comparator.comparingDouble(Travel::getScore).reversed()) .limit(20) .collect(Collectors.toList()); } }假设出发地3公里以外直接给0分,时间差超过1小时也给0分,目的地相似度低于0.3就给0分,这样能一次性把低质量候选过滤掉,不用在SQL里拼一堆复杂的条件。
5.3 预约订单的Controller与事务处理
Controller层保持简洁,只接收请求参数和返回统一结果。下面这是预约拼车的完整入口:
@RestController @RequestMapping("/api/order") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; @PostMapping("/create") public Result<CarpoolOrder> create(@RequestBody @Valid CreateOrderDTO dto, @RequestAttribute Long userId) { return Result.success(orderService.createOrder(dto, userId)); } }Service层的实现,我加了详细注释:
@Transactional(rollbackFor = Exception.class) @Override public CarpoolOrder createOrder(CreateOrderDTO dto, Long userId) { // 1. 查行程并加锁,防止并发超卖 Travel travel = travelMapper.selectByIdForUpdate(dto.getTravelId()); if (travel == null) { throw new BusinessException("行程不存在"); } if (travel.getStatus() != 0 || travel.getRemainSeats() < dto.getSeats()) { throw new BusinessException("该行程座位不足或已关闭"); } // 2. 扣减座位 travel.setRemainSeats(travel.getRemainSeats() - dto.getSeats()); if (travel.getRemainSeats() == 0) { travel.setStatus(1); } travelMapper.updateById(travel); // 3. 创建订单 CarpoolOrder order = new CarpoolOrder(); order.setOrderNo(generateOrderNo(userId)); order.setTravelId(travel.getId()); order.setPassengerId(userId); order.setSeats(dto.getSeats()); order.setAmount(travel.getPricePerSeat().multiply(new BigDecimal(dto.getSeats()))); order.setStatus("PENDING"); orderMapper.insert(order); // 4. 通知司机 messageService.send(travel.getDriverId(), userId, "您有一条新的拼车预约,订单号:" + order.getOrderNo()); return order; }这里用了selectByIdForUpdate,对应SQL是SELECT ... FOR UPDATE,是用悲观锁控制并发。虽然前面推荐的乐观锁更轻量,但在这段业务里,因为事务里要读取并扣减,悲观锁更直观不易出错。两种方案都写了,你答辩时就可以说:我用了乐观锁的SQL扣减思想,同时也在关键路径用FOR UPDATE做兜底,防止极端并发。这句话一出来,老师基本不会再往下追问了。
5.4 前端联调里的几个细节
前端我用Vue2 + Element UI + Axios,起步流程就三步:
npm install安装依赖。- 在
vue.config.js里配置代理,把/api开头的请求转发到后端地址。 - 写一个
request.js封住Axios,统一加token、统一处理错误码。
有一个很常见的问题:前端访问http://localhost:8080/api/xxx,而后端跑在8081,如果不配置代理,浏览器会直接跨域报错。配置看起来是这样:
devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }同时后端也要做好跨域配置,因为可能有人绕过代理直接调试。我用一个WebMvcConfigurer注册全局CORS:
@Configuration 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); } }两个解决方案其实是双保险,一个是前端代理,一个是后端放行。实际企业开发一般只保留后端放行就行,但毕设环境里大家开发方式不一样,我特意都写上,避免你哪个环节卡住。
6. 部署运行与调试避坑实录:让源码真正“能跑起来”
6.1 环境准备:JDK、Maven、MySQL、Redis
我拿到一套新的毕设源码,第一步永远是“按能跑的最小集去配环境”,你别一上来就开两个IDE调试,先确认基础软件版本没问题。建议组合与注意事项如下:
| 组件 | 推荐版本 | 注意事项 |
|---|---|---|
| JDK | 1.8 / 11 | 项目pom里的java.version要和本机一致 |
| Maven | 3.6+ | 源里如果有私服的依赖要确认拉得到,不然一直编译失败 |
| MySQL | 5.7 / 8.0 | 初始化SQL先执行,注意字符集要UTF-8,排序规则utf8mb4_general_ci |
| Redis | 5.0+ | 如果项目里用到Redis缓存登录态,要先启动Redis,否则后端启动报连接超时 |
| Node.js | 14+ | 前端工程编译需要,太老的版本装依赖容易报错 |
拿到源码后不要立刻跑,先看一遍application.yml里的数据库账号、密码、端口,再执行init.sql初始化数据。我见过很多学生把账号密码写错或没执行SQL,启动起来页面一片红,这个锅真不在代码。
6.2 启动后端常见的几个报错
后端起不来,九成是以下几种原因,每一条都是我这几年远程帮人调源码时高频碰到的:
- 端口被占用:后端8081被之前没关干净的程序占着,启动直接
Port already in use。Windows下用netstat -ano | findstr 8081查PID,再taskkill /PID xxx /F杀掉即可。Mac/Linux用lsof -i:8081。 - 数据库时区错误:报
The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。连接串加上serverTimezone=Asia/Shanghai,并确保MySQL全局时区是+08:00。 - Redis连接失败:如果你把登录token存Redis,Redis没启动或者密码不对,启动日志会一直刷连接异常。本地开发可以先在配置里换成JWT本地校验,不依赖Redis,演示环节更稳。
- MyBatis-Plus扫描不到Mapper:启动类上忘记加
@MapperScan("com.example.carpool.mapper"),所有的Mapper注入都会失败。这个IDE可能会提示Field xxxMapper in ... required a bean of type。
6.3 前后端联调时的“经典三连问”
前端页面能打开但数据是空的,或者接口报跨域错、返回401,基本上就是三件事没做好:
- 后端接口路径对不对?打开
http://localhost:8081/swagger-ui/index.html,对照接口文档看一眼。 - 登录token是否存上了?打开浏览器F12 -> Application -> Local Storage,看有没有token字段。
- 请求头是否正确?Axios拦截器是不是把token拼到了
Authorization头里。token没拼上,后端拦截器统一返回401,页面自然拿不到数据。
这“三连问”我几乎每次远程调试都会用,基本能排除80%的联调问题。
6.4 必看的几个潜在逻辑Bug
光能跑起来还不够,演示的时候如果逻辑出错非常尴尬。这里列几个这套系统里最容易出Bug的地方:
- 创建行程时剩余座位没初始化:
totalSeats赋值了但remainSeats为null,匹配查询里gt(0)查不出来,行程列表空一片。解决方案是在实体类里加@TableField(fill = FieldFill.INSERT)配合MetaObjectHandler自动填充,或者简单点在前端表单做必填校验。 - 取消订单时没恢复座位:订单取消逻辑里只把订单状态改成了CANCELED,忘了把行程的
remainSeats加回去。结果就是没人抢的位置越来越少,乘客投诉“明明有空座却不让拼”。这个逻辑务必写在同一个事务里。 - 金额计算丢精度:单价用BigDecimal没问题,但如果你用了
double当单价,再乘以人数,可能出现0.68元这种奇数。我建议所有金额字段在数据库就是DECIMAL,Java里就是BigDecimal,运算都走multiply,不要在代码里混转。 - 越权操作:乘客A通过手动改URL里的订单id,直接操作乘客B的订单,取消、评价都能触发。这属于严重安全问题。解决办法是在订单Service层增加校验
order.getPassengerId().equals(currentUserId)或判断当前用户是否为该订单司机的逻辑。我为这个点单独写了一个OrderAccessGuard,每次操作前统一校验,代码长了点,但安全上踏实。
6.5 答辩时的高频问题与回答思路
最后给你备一份答辩高频问题清单,这套系统的回答要点我顺手写出来,你有空可以对着练:
| 高频问题 | 回答思路 |
|---|---|
| 系统架构是怎样的? | 前端Vue、后端Spring Boot、MySQL数据存储的标准前后端分离,配合Redis可选做会话热点缓存,数据交互走RESTful API |
| 拼车匹配算法怎么实现? | 距离、时间窗、目的地相似度三者加权打分,讲解清楚每个权重的理由 |
| 超卖问题怎么解决? | 乐观锁SQL + 悲观锁兜底,结合订单状态机说明事务保证一致性 |
| 项目里最有挑战的点是什么? | 拼车匹配和订单状态机,以及座位扣减的并发,围绕这三处展开 |
| 以后如果推广到真实生产,还缺什么? | 消息队列削峰、Redis缓存行程热数据、分布式事务、地图导航API对接,提方向即可 |
7. 从毕设到项目的最后一公里:如何把源码讲出自己的故事
源码拿到手上,最重要的一件事不是急着跑通,而是把它“讲成自己的故事”。我每次给同学远程调试,都会说这句话:毕设不是把你的名字贴在别人代码上,而是把每一段关键代码读懂、能改、能扩展。
建议你拿到这套系统之后,按下面三步走:
- 第一遍“跑”:不看源码,按文档部署,跑通主流程,记录下所有踩坑点。这些踩坑过程就是论文里的“系统测试与问题排查”素材。
- 第二遍“读”:从Controller顺着Service到Mapper读一遍,把每个接口的请求链路画出来(不用画太细,手写脑图都行),搞懂订单状态每次是怎么变的。
- 第三遍“改”:选一个点自己动手扩展。比如给拼车匹配加一个“价格区间过滤”,或者给订单加一个“乘客与司机互相评分”功能。不用写太多,一个功能就够让你在答辩时说“我在原项目基础上额外实现了xxx”。
这三步走完,你再面对答辩老师,整个人的状态完全不一样——因为你不再是被动复述,而是能主动聊设计、聊取舍。如果你打算拿它做求职项目,再花两三天把系统截图、核心接口文档、数据库设计整理成一份作品集,面试前把匹配算法和超卖处理这两段讲熟练,基本就能压住场子。
这套出租车拼车系统的价值,我看着是越挖越多的:技术上它踩得实,业务上它离真实场景近,代码上它又留有充分的扩展余地。希望这篇拆解能帮你少走弯路,无论是顺利毕业还是拿到心仪的offer,都有实实在在的底气。