简介:面向软件工程课程设计与SSM框架入门者,这套机票/火车票预订系统项目包提供了可直接研读的完整工程,覆盖车票查询、订单处理、后台管理、票数统计及用户登录注册等典型业务场景,适合用于课程设计选题、框架学习与项目实训。压缩包共1651个文件、约50.16MB,包含51个Java源码、36个JSP页面、85个XML配置、1个SQL初始化脚本与1个WAR部署包,另有37个JAR依赖及636个JS、204个CSS、354个PNG等前端静态资源,源码、页面、配置与素材层次较完整。已有273人学习下载,读者可据此梳理Spring、SpringMVC、MyBatis的分层写法,理解控制器、服务实现与持久层映射的协作方式,也能参考订单与查询模块的组织思路完成二次开发或答辩准备。
1. 一份能直接答辩的 SSM 预订系统:双票种、下单、后台一条链跑通
答辩前一周,A同学的系统能登录、能查票,点下「提交订单」就白屏报 500。翻了一晚上日志,才发现是 MyBatis 的resultMap里列名和实体属性对不上。这种最后一公里卡壳的情况,在软件工程课程设计里太常见了。这份基于 SSM 的机票/火车票预订系统,价值不在于把 Spring、SpringMVC、MyBatis 三个框架都写进 pom,而在于它把「查票—下单—订单管理—后台维护」这条链做成了能跑通的闭环:航班和车次两种票共用一套数据模型,注册登录、余票扣减、订单状态流转都能在页面上点出来。对正在做课设、手里缺一份「改得动、讲得出」完整骨架的同学最合适;对已经能写 CRUD、想看看分层边界怎么划的人来说,也能当成一次对照阅读。
2. 工程骨架与分层:Maven 依赖、四层包结构和三份 XML 的摆放方式
拿到一个 SSM 项目,第一步不用急着跑,先看目录和配置。SSM 的坑有八成出在「配置和代码对不上」——包扫描路径写错、Mapper XML 没被加载、静态资源被 DispatcherServlet 吞掉。这些在代码里看不出来,只在配置里埋着。所以这一章先把骨架立起来:依赖怎么引、包怎么分、三份 XML 各管什么,后面读业务代码才有参照系。
2.1 从 pom.xml 看 SSM 的依赖边界
SSM 不是三个 jar 包,是三个框架家族。最容易混淆的是spring-context和spring-webmvc的关系:前者是 IoC 容器,后者额外带来 DispatcherServlet 和九大组件,Web 项目两个都要;而spring-jdbc里包含的DataSourceTransactionManager才是@Transactional真正生效的地方。很多人只引了spring-webmvc,然后奇怪为什么事务注解没用,就是因为缺了spring-jdbc。
<!-- 版本统一在 properties 里管理,换环境只改这一处 --> <properties> <spring.version>5.x</spring.version> <mybatis.version>3.x</mybatis.version> <mysql.version>8.x</mysql.version> <jdk.version>1.8</jdk.version> </properties> <dependencies> <!-- IoC 容器,Service/Mapper 的 Bean 靠它管 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- Web 层:DispatcherServlet、@Controller、视图解析 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- 事务管理器在这里,少了它 @Transactional 就是个摆设 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 本体与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.x</version> </dependency> <!-- 数据库驱动与连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.x</version> </dependency> <!-- JSP 页面要用的标签库和 Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope><!-- 容器已经带了,打进去反而冲突 --> </dependency> </dependencies>几个参数值得单独说。javax.servlet-api的provided不能省,否则 Tomcat 启动时会因为重复的Servlet类抛ClassCastException,报错信息还特别含糊,我见过有人排查了一下午。mybatis-spring的版本必须和 MyBatis 主版本对得上,跨大版本会出现SqlSessionFactoryBean找不到方法的情况。druid换c3p0或HikariCP都行,课程设计里用 Druid 主要是图它自带监控页面,答辩时能多讲一页。
2.2 目录结构:四层包怎么切
分层不是摆样子,每一层的职责直接决定了后续改需求时你要动几个文件。预订系统的典型切法是 controller / service / mapper / entity 四层,再加 dto、interceptor、util 三个辅助包。
ticket-booking/ ├── src/main/java/com/xxx/ticket/ │ ├── controller/ # 页面请求入口,只做参数接收和结果返回 │ ├── service/ # 业务接口 │ │ └── impl/ # 业务实现,@Transactional 加在这一层 │ ├── mapper/ # MyBatis 接口,方法名对应 XML 的 id │ ├── entity/ # 与数据库表一一对应 │ ├── dto/ # 页面传输对象:查询条件、订单详情 │ ├── interceptor/ # 登录拦截器 │ └── util/ # 订单号生成、MD5 加盐、日期工具 ├── src/main/resources/ │ ├── mapper/ # Mapper XML,与接口同名同包路径 │ ├── spring-core.xml # 容器配置:数据源、SqlSessionFactory、事务 │ ├── spring-mvc.xml # Web 配置:注解驱动、视图解析、拦截器 │ ├── mybatis-config.xml # MyBatis 全局设置:驼峰映射、别名 │ └── db.properties # 数据库连接四项 ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── views/ # JSP 页面,按模块分目录 │ └── static/ # js / css / 图片 └── pom.xml这里有个约定要守住:resources/mapper下 XML 的目录层级必须和java下 Mapper 接口的包路径完全一致。MyBatis 加载 XML 时靠 namespace 找接口,靠mapperLocations通配符找文件,两边只要差一层目录,运行时就抛Invalid bound statement (not found)。这是 SSM 项目里出现频率最高的报错,没有之一。我自己习惯是建接口的同时就把 XML 骨架建好,宁可多敲几次路径也不事后补。
dto包容易被新手省掉,直接用entity接页面参数也无所谓,但订单详情这种「一张订单 + 一趟班次信息」的组合对象,硬塞进 entity 会让实体类长出十几个跟表无关的字段。单独放OrderDetail更干净,后面加字段也不用动表结构。
2.3 三份 XML 各管一段:web.xml 到 spring-mvc.xml
SSM 的配置分成三个层次,很多教程混在一起讲,导致读者搞不清哪一行该写哪儿。按「容器启动 → 请求分发 → 业务装配」这个顺序看就清楚了。
web.xml是最外层,只做两件事:把DispatcherServlet挂到/上,以及加一个编码过滤器。注意CharacterEncodingFilter的forceEncoding必须设成true,只设encoding的话,响应端的编码还是由 JSP 自己决定,会出现「数据库里是好的、页面上是问号」这种半乱码。
<!-- 前端控制器:所有请求先进 DispatcherServlet --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup><!-- 随容器启动,别等第一次请求 --> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <!-- 编码过滤器放最前面,否则后面的过滤器拿到的参数已经是乱码 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value><!-- 响应也强制 UTF-8 --> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>spring-mvc.xml管 Web 层:包扫描、注解驱动、视图前后缀、拦截器、静态资源放行。静态资源这一条必须写,否则/static/css/main.css会被当成一个 Controller 请求,404 找上门来。
<!-- 只扫 controller 包,service 和 mapper 留给 spring-core.xml --> <context:component-scan base-package="com.xxx.ticket.controller"/> <!-- 打开注解驱动,@ResponseBody 才能把对象转成 JSON --> <mvc:annotation-driven/> <!-- 静态资源交给容器默认 Servlet,别让 DispatcherServlet 接 --> <mvc:default-servlet-handler/> <!-- JSP 视图解析:/WEB-INF/views/trip/list.jsp --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 登录拦截器:下单、订单列表这些需要登录的路径统一挡 --> <mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/order/**"/> <mvc:mapping path="/trip/book"/> <bean class="com.xxx.ticket.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>spring-core.xml管业务容器:数据源、SqlSessionFactoryBean、Mapper 扫描、事务管理器、事务注解开关。这里的关键是包扫描范围要排除 controller,否则同一个 Controller Bean 会被父子容器各实例化一次,注入的 Service 变成两个不同的对象,事务和缓存就会出现「有的地方生效有的地方不生效」这种比较难查的问题。
三份配置各管一段,职责清楚了,出问题也能快速定位到是哪一层。这也是我在看别人 SSM 项目时最先确认的地方——配置分得乱的项目,业务代码基本也干净不了。
3. 核心链路拆解:一次订票请求从 Controller 走到 Mapper 的完整路径
查票和下单是这个系统里最值得读的两条链路。查票体现的是「多条件组合查询」,下单体现的是「事务 + 并发扣减」,一个考 SQL 动态拼接,一个考事务边界,正好覆盖 SSM 的两个核心能力。这一章按请求进来的顺序往下走,每一层都给出代码和参数说明。
3.1 Controller 层:参数接收与登录态校验
Controller 只干三件事:接参数、调 Service、决定返回视图还是 JSON。业务判断一律不下沉到这里,否则以后做接口复用就得复制一遍逻辑。
@Controller @RequestMapping("/trip") public class TripController { @Autowired private TripService tripService; /** * 票种查询 * tripType:1 查航班,2 查车次,不传则两种都查 * fromCity / toCity:可选,为空时不参与 SQL 拼接 */ @RequestMapping("/search") public String search(TripQuery query, Model model) { List<Trip> list = tripService.search(query); model.addAttribute("trips", list); model.addAttribute("query", query); // 回填查询框,翻页时用得上 return "trip/list"; } /** * 下单接口,返回 JSON * 登录校验交给拦截器,这里只兜一层空指针 */ @RequestMapping(value = "/book", method = RequestMethod.POST) @ResponseBody public Result book(@RequestParam Long tripId, @RequestParam Integer seatCount, HttpSession session) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.fail("请先登录"); } try { Order order = tripService.book(user.getId(), tripId, seatCount); return Result.ok(order); } catch (BizException e) { // 余票不足、班次停售这类可预期异常,转成提示语给前端 return Result.fail(e.getMessage()); } } }TripQuery是一个 DTO,字段就是fromCity、toCity、tripType三个 String 或 Integer,SpringMVC 会自动按属性名从请求参数里装配,不用手写request.getParameter。Result是统一的返回包装,code、msg、data三个字段,前端拿到之后统一判断code。
注意@ResponseBody和返回String的区别。search方法没有这个注解,返回的"trip/list"会被视图解析器拼成/WEB-INF/views/trip/list.jsp;book方法加了注解,返回对象会被 Jackson 序列化成 JSON。同一份配置里两种模式并存,这是 SSM 项目里常见的过渡写法——老页面用 JSP 渲染,新的交互接口用 AJAX,改造过程中不会一次性重写全部。
3.2 Service 层:事务边界、余票判断与订单号生成
下单是整个系统里唯一必须在同一个事务里完成两件事的地方:扣余票、建订单。顺序不能反,也不能拆开,否则中途失败就会出现「钱扣了票没出」或者「票出了没扣钱」这两种对不上账的情况。
@Service public class TripServiceImpl implements TripService { @Autowired private TripMapper tripMapper; @Autowired private OrderMapper orderMapper; /** * 下单:扣余票 + 建订单,两步必须在同一个事务里 * rollbackFor 指定 Exception,否则受检异常不会回滚 */ @Override @Transactional(rollbackFor = Exception.class) public Order book(Long userId, Long tripId, Integer seatCount) { // 1. 先查班次,拿到单价和当前余票 Trip trip = tripMapper.selectById(tripId); if (trip == null || trip.getStatus() == 0) { throw new BizException("班次不存在或已停售"); } if (trip.getSeatLeft() < seatCount) { throw new BizException("余票不足,当前只剩 " + trip.getSeatLeft() + " 张"); } // 2. 乐观扣减:where 里再判一次余票,防止并发超卖 int rows = tripMapper.decreaseSeat(tripId, seatCount); if (rows == 0) { // 说明在查和改之间有人抢走了余票 throw new BizException("手慢了,余票刚被抢完"); } // 3. 生成订单 Order order = new Order(); order.setOrderNo(OrderNoUtil.generate()); order.setUserId(userId); order.setTripId(tripId); order.setSeatCount(seatCount); order.setTotalPrice(trip.getPrice().multiply(new BigDecimal(seatCount))); order.setStatus(0); // 0 待支付 order.setCreateTime(new Date()); orderMapper.insert(order); return order; } }三个参数各有用处:userId来自 session,不从请求参数里取,避免客户端伪造别人的身份下单;tripId和seatCount从前端来,seatCount要在 Controller 里做一次范围校验(比如 1 到 5),防止有人传负数把余票刷上去。
事务这块有三个细节。@Transactional必须加在public方法上,加在 private 或同类内部调用上都不生效,这是 Spring AOP 代理的机制决定的。rollbackFor = Exception.class不能省,Spring 默认只在RuntimeException和Error时回滚,BizException继承的是Exception,不写这一条事务根本不会回滚。事务管理器 Bean 必须在spring-core.xml里配好,并加上<tx:annotation-driven/>,否则注解就是个装饰。
订单号生成我一般用「时间戳 + 用户 ID 后两位 + 三位随机数」,长度固定,可读性也好,出问题时一眼能看出大概的下单时间。用 UUID 也行,但一堆横杠在页面上展示不友好。
3.3 Mapper 层:动态 SQL 和乐观扣减的写法
MyBatis 的价值一半在动态 SQL,一半在结果映射。查票接口的 SQL 要能应付「只填出发地」「出发地目的地都填」「再加上票种」这几种组合,靠<where>和<if>拼。
<!-- 乐观扣减:seat_left 必须 >= 购买张数才允许更新 --> <update id="decreaseSeat"> UPDATE trip SET seat_left = seat_left - #{seatCount} WHERE id = #{tripId} AND seat_left >= #{seatCount} </update> <!-- 条件查询:出发城市、到达城市、票种都是可选项 --> <select id="search" resultMap="TripMap"> SELECT <include refid="Base_Column_List"/> FROM trip <where> <if test="fromCity != null and fromCity != ''"> AND from_city = #{fromCity} </if> <if test="toCity != null and toCity != ''"> AND to_city = #{toCity} </if> <if test="tripType != null"> AND trip_type = #{tripType} </if> AND status = 1 </where> ORDER BY depart_time ASC </select><where>会自动处理第一个AND,不用像以前那样写WHERE 1=1来凑数。#{seatCount}是预编译占位符,能防 SQL 注入;如果写成${seatCount}就是字符串拼接,虽然这里的值来自 Integer 风险不大,但养成习惯比事后补救更重要。
decreaseSeat这条 SQL 是整个系统里最关键的一行。它的WHERE里同时带了id和seat_left >= #{seatCount},更新时数据库会加行锁,两个请求同时扣同一趟票时必然排队,后到的那个发现余票不够就返回影响行数 0。这是最轻量的防超卖方案,不用 Redis、不用分布式锁,课程设计场景完全够用。Service 里拿到 0 行就直接抛业务异常,事务回滚,订单也不会生成。
Base_Column_List用一个<sql>片段定义,TripMap用resultMap显式声明列和属性的对应关系。字段少的时候可以靠mapUnderscoreToCamelCase自动转换(seat_left→seatLeft),字段一多、加上关联查询之后,我建议还是写显式的resultMap,出问题时不用猜 MyBatis 是怎么映射的。
4. 数据库与 MyBatis 映射:三张主表撑起双票种业务
机票和火车票看起来是两码事,字段却高度重合:都有编号、起点终点、出发到达时间、价格、余位。如果分成两张表,查询要写两遍、下单要写两套 Service,改一个字段要同步两个地方。所以这套设计把两者合并成一张trip表,用trip_type区分。这个决定看起来只是建模技巧,实际上直接决定了后面能省多少重复代码。
4.1 表结构:用户、班次、订单的关系
三张表的关系很直:一个用户下多个订单,一个订单对应一趟班次。order里存trip_id外键,同时把下单时的单价快照进total_price,这样班次调价之后历史订单的金额不会被改掉——这一点课程设计里经常被忽略,答辩老师一问「班次改价了历史订单怎么办」就答不上来。
-- 用户表:role 区分普通用户和管理员,密码存 MD5 加盐 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL COMMENT 'MD5(明文+盐)', `salt` VARCHAR(16) NOT NULL COMMENT '每个用户独立盐值', `real_name` VARCHAR(32), `phone` VARCHAR(20), `role` TINYINT DEFAULT 0 COMMENT '0 用户 1 管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 班次表:航班和车次共表,trip_type 区分 CREATE TABLE `trip` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `trip_no` VARCHAR(16) NOT NULL COMMENT '航班号或车次号', `trip_type` TINYINT NOT NULL COMMENT '1 航班 2 车次', `from_city` VARCHAR(32) NOT NULL, `to_city` VARCHAR(32) NOT NULL, `depart_time` DATETIME NOT NULL, `arrive_time` DATETIME NOT NULL, `price` DECIMAL(10,2) NOT NULL, `seat_total` INT NOT NULL COMMENT '总座位数', `seat_left` INT NOT NULL COMMENT '剩余座位,下单时扣减', `status` TINYINT DEFAULT 1 COMMENT '0 停售 1 正常', KEY `idx_route` (`from_city`, `to_city`, `depart_time`) ); -- 订单表:status 表达状态流转,total_price 是下单时的价格快照 CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` BIGINT NOT NULL, `trip_id` BIGINT NOT NULL, `seat_count` INT NOT NULL, `total_price` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0 待支付 1 已支付 2 已取消 3 已完成', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME NULL, KEY `idx_user` (`user_id`), KEY `idx_trip` (`trip_id`) );| 表名 | 作用 | 关键字段 | 索引策略 |
|---|---|---|---|
| user | 账号与权限 | username、password、salt、role | username 唯一索引 |
| trip | 航班/车次统一模型 | trip_type、depart_time、seat_left | (from_city, to_city, depart_time) 联合索引 |
| order | 订单主表 | order_no、status、total_price | user_id、trip_id 各一个普通索引 |
索引这块多说一句。idx_route的字段顺序不能随便调,查询条件里from_city和to_city是等值匹配、depart_time是范围排序,所以三者的顺序按「等值在前、范围在后」来排,这样最左前缀原则才能吃满。order表上的user_id索引是为了「我的订单」页面,trip_id索引是为了后台按班次统计销量。课程设计数据量小,不建也跑得动,但答辩时如果能讲清为什么这么建,比多写几个页面更能体现水平。
4.2 关联查询:订单详情怎么带出班次信息
「我的订单」页面要显示订单号、金额、状态,还要显示车次号和出发到达时间。这些信息分在两张表里,直接JOIN查出来用association映射成嵌套对象,比在 Java 里循环查数据库干净得多。
<resultMap id="OrderDetailMap" type="com.xxx.ticket.dto.OrderDetail"> <id column="id" property="id"/> <result column="order_no" property="orderNo"/> <result column="total_price" property="totalPrice"/> <result column="status" property="status"/> <!-- 订单里带出班次信息,页面直接显示车次号和时间 --> <association property="trip" javaType="com.xxx.ticket.entity.Trip"> <id column="trip_id" property="id"/> <result column="trip_no" property="tripNo"/> <result column="depart_time" property="departTime"/> <result column="arrive_time" property="arriveTime"/> </association> </resultMap> <select id="selectOrderDetailByUser" resultMap="OrderDetailMap"> SELECT o.id, o.order_no, o.total_price, o.status, t.id AS trip_id, t.trip_no, t.depart_time, t.arrive_time FROM `order` o JOIN trip t ON o.trip_id = t.id WHERE o.user_id = #{userId} ORDER BY o.create_time DESC </select>这里有个必须注意的地方:order表和trip表都有id字段,直接SELECT *会导致后一个id覆盖前一个,OrderDetail的 id 变成班次 id,页面上一片错乱。所以要么显式列字段,要么给trip.id起别名trip_id,代码里两种都做了,双保险。这种列名冲突导致的「数据好像错了但又没报错」,是 MyBatis 里最费时间的一类问题,日志里看不出来,只能一行行比对。
association默认是嵌套结果映射,一条 SQL 搞定,不用配select属性去发第二条 SQL。后者虽然写起来简单,但会出现 N+1 问题——查 20 条订单就多发 20 次查询,答辩演示时看不出来,真跑起来慢得明显。
4.3 分页与条件筛选:PageHelper 与手写 LIMIT 的取舍
订单列表和后台班次管理都需要分页。SSM 项目里两条路:引pagehelper插件,或者手写LIMIT。
// PageHelper 方案:startPage 之后的第一个查询会被自动改写 PageHelper.startPage(pageNum, pageSize); List<Order> orders = orderMapper.selectByUser(userId); PageInfo<Order> pageInfo = new PageInfo<>(orders); // pageInfo.getTotal() 总条数,pageInfo.getList() 当前页数据<!-- 手写方案:Controller 里算好 offset 再传进来 --> <select id="selectByUserWithLimit" resultMap="OrderDetailMap"> SELECT o.id, o.order_no, o.total_price, o.status, t.id AS trip_id, t.trip_no, t.depart_time, t.arrive_time FROM `order` o JOIN trip t ON o.trip_id = t.id WHERE o.user_id = #{userId} ORDER BY o.create_time DESC LIMIT #{offset}, #{pageSize} </select>PageHelper.startPage必须紧挨着查询语句,中间不能插别的查询,因为它靠 ThreadLocal 传递分页参数,一旦插了第二条查询,参数就被用掉了。这一点新手极容易踩,表现是「分页有时候对有时候不对」。
手写LIMIT的好处是不引额外依赖,缺点是总数要单独查一次COUNT(*),两个 SQL 之间数据可能有变化。课程设计场景两种都行,我一般用 PageHelper,省事;如果项目要求依赖越少越好,就手写,offset在 Service 里用(pageNum - 1) * pageSize算好再传。
5. 踩坑排查:从启动报错到事务失效的 5 个血泪现场
SSM 项目的报错有个共同特点:异常栈很长,但真正有用的就一两行。下面这 5 个是我在这类项目里反复遇到、也反复看到别人卡住的现场,按「现象 → 原因 → 解决」的格式记下来,遇到时对照着看能省不少时间。
5.1 环境与映射类问题
现场一:启动正常但访问任何页面都是 404,日志里没有任何异常
现象:Tomcat 起来了,控制台干干净净,浏览器访问首页返回 404,换任何路径都一样。
原因:web.xml里DispatcherServlet的contextConfigLocation指错了文件,或者spring-mvc.xml的组件扫描路径写错,导致容器里一个 Controller 都没有。还有一种情况是 IDEA 的 Artifact 里没把src/main/resources标记成资源目录,配置文件根本没打进WEB-INF/classes。
解决:先在启动日志里搜Mapped "{[/xxx]}",如果没有这一行,说明 Controller 没被扫描到。逐个确认web.xml的param-value、spring-mvc.xml的base-package、IDEA 的 Project Structure → Artifacts → Output Layout 里有没有resources目录。这三处任意一处对不上都会 404。
现场二:调 Mapper 接口直接抛 Invalid bound statement (not found)
现象:Service 里注入了 Mapper,一调用就抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found),日志里能看到具体是哪个方法。
原因:MyBatis 找不到 XML 里对应的 SQL 语句。常见的有三种:XML 文件没被mapperLocations扫到(路径通配符写错或目录层级不对);XML 的namespace和接口全限定名不一致;XML 里<select>的id和接口方法名不一致。
解决:先把mapperLocations打印出来确认扫描路径,再核对 namespace 和方法名。我习惯在mybatis-config.xml里打开<setting name="logImpl" value="STDOUT_LOGGING"/>,这样每次查 SQL 都会在控制台打出语句和参数,能直接看出是否加载成功。
5.2 事务、编码与类型转换类问题
现场三:下单报错,但订单记录生成了、余票也扣了
现象:book方法里某个步骤抛异常,前端拿到错误提示,但去数据库一看,order表多了一条、trip.seat_left也少了。
原因:@Transactional没生效。可能的情况有四种:注解加在了 Controller 上;加在了非public方法上;同类内部方法调用(AOP 代理绕不过去);异常被try-catch吞掉了没往外抛。另外rollbackFor没配Exception.class时,受检异常也不会触发回滚。
解决:事务注解统一加在 Service 层的public方法上,业务异常直接throw出去,让 Controller 或全局异常处理器统一处理。spring-core.xml里确认有<tx:annotation-driven transaction-manager="transactionManager"/>且事务管理器 Bean 用的是同一个数据源。
现场四:页面上正常,数据库里却是问号
现象:表单提交中文,页面上显示正常,SELECT出来是???或者空白。
原因:三个地方任意一处没对齐。数据库或表的字符集不是utf8mb4;JDBC 连接串里没写characterEncoding=utf8;web.xml的CharacterEncodingFilter没配或没设forceEncoding。
解决:从下往上查。先SHOW CREATE TABLE确认表字符集,再检查连接串jdbc:mysql://...?useUnicode=true&characterEncoding=utf8,最后确认过滤器配置。三处都齐了还乱码的,检查一下 IDE 控制台和 Tomcat 的server.xml有没有设URIEncoding="UTF-8"。
现场五:带日期的查询条件提交后直接返回 400
现象:查票页面在日期框里选了日期,点查询,浏览器直接返回 400 Bad Request,日志里能看到Failed to convert value of type 'String' to required type 'Date'。
原因:SpringMVC 默认没有日期类型的转换器,字符串2024-06-01转不成java.util.Date。
解决:两个办法。在 DTO 的日期字段上加@DateTimeFormat(pattern = "yyyy-MM-dd"),适合字段少的情况;或者在spring-mvc.xml里注册一个全局的ConversionService,把日期格式统一成一种。我一般用前者,字段少、看得见、改起来直观。另外注意,如果日期框传的是yyyy-MM-dd HH:mm这种带时间的格式,pattern也要跟着改,不然还是会转失败。
6. 二次开发手记:把模拟支付换成真实流程、给余票加一道锁
这套系统里最「假」的一块是支付。原始代码里点「立即支付」直接把status从 0 改成 1,没有支付流水、没有回调、没有幂等,答辩时能过,但真要往上线方向改,这是第一个要动的地方。
我的改法是加一张pay_record表,订单状态不再由前端直接改,而是由支付回调来推进。核心是幂等:同一个订单的回调可能来好几次,不能每次都处理。
/** * 支付回调:靠订单状态做幂等,重复回调直接返回 * 用 SQL 的条件更新代替「先查再改」,避免并发下的重复处理 */ @Transactional(rollbackFor = Exception.class) public void handlePayCallback(String orderNo, String tradeNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null) { throw new BizException("订单不存在"); } // 只有待支付(status=0)的订单才允许推进到已支付(status=1) int rows = orderMapper.updateStatusFromWaitPay(orderNo, tradeNo); if (rows == 0) { return; // 已经处理过,或者状态不对,直接返回不报错 } // 写支付流水,后续对账用 payRecordMapper.insert(new PayRecord(orderNo, tradeNo, order.getTotalPrice())); }对应的 SQL 也很简单,把状态判断塞进WHERE,让数据库来保证只推进一次:
<update id="updateStatusFromWaitPay"> UPDATE `order` SET status = 1, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0 </update>第二个改动点是余票的并发。现在的乐观扣减在单机下够用,但如果后面想加「多人同时抢最后一趟」的压测场景,可以再加一层重试:decreaseSeat返回 0 时,重新查一次余票,如果确实还有,就重试一次扣减,最多重试两次。这样能减少一些因为时序导致的「明明有票却提示没票」。
第三个是后台统计。trip表已经有seat_total和seat_left,order表有total_price和status,用一条GROUP BY trip_id的聚合 SQL 就能算出每个班次的销量和收入,不需要额外建表。这条 SQL 放在OrderMapper里,后台页面加个图表展示,答辩时是个不错的加分项。
验证方式也简单:把回调接口暴露出来,用curl或者 Postman 连续打三次同一个orderNo,看order表的status是不是只变了一次、pay_record是不是只有一条。跑通这个,才算真正理解了「幂等」这两个字。
从那以后我每次改这类涉及状态流转的代码,不管多简单,都强制走一遍「重复请求三次看数据对不对」的验证,宁可多花十分钟,也不在答辩或者演示现场翻车。希望帮到你。
本文还有配套的精品资源,点击获取