2026/9/14 12:35:13

SSM机票预订系统毕业设计:表结构、事务与部署全解析

SSM机票预订系统毕业设计:表结构、事务与部署全解析 简介这套基于SSM的航空机票预订系统毕业设计项目完整覆盖管理员信息、会员信息、航班信息、订单信息、公告信息、留言信息等管理模块面向计算机相关专业学生及需要快速上手SSM框架开发的初学者。管理员登录后可对各类数据进行增删改查系统设计遵循角色权限划分具备良好的扩展性与维护性可直接用于课程设计、毕业设计或项目练手。压缩包共1817个文件大小约58.7MB内部包含Java源码、JSP页面、XML配置、SQL数据库脚本、依赖JAR包、PNG/GIF界面素材等同时随附项目文档与答辩PPT结构完整便于按目录查阅。已有36人学习下载适合正在做航空/票务类选题或希望借鉴SSM整合流程的读者。资源价值在于提供了一条从数据库设计到前后端联调的通路通过研读源码可以掌握Spring、SpringMVC、MyBatis三者的协作方式以及典型业务模块的增删改查实现对论文撰写、系统演示和二次扩展均有实际帮助。1. SSM 的机票系统为什么毕业设计普遍选这套框架一个有意思的现象网上搜“机票预订系统 毕业设计”十个结果里八个是 SSM。原因不复杂——Spring 管业务对象、SpringMVC 管请求分发、MyBatis 管 SQL 映射三层各管一段代码边界清楚写起来不容易烂尾。这套带源码、数据库脚本、设计文档和答辩 PPT 的航空机票预订系统主体是后台管理视角管理员登录后维护航班、会员、订单、公告和留言没有过分复杂的前端交互适合拿来当 JavaWeb 毕业设计也适合用来理解 SSM 的数据流。如果你是刚学完框架还在凑项目、或者准备答辩前要快速复现一套完整业务这个 zip 里的内容足够支撑你从头跑通并讲清全过程。2. 项目落地的第一步航班、会员与订单的表结构设计拿到源码先不要急着启动 Tomcat把这个系统的表结构理清楚后面所有模块都是在这些表上做增删改查。该项目的数据库一共六张核心表管理员表、会员表、航班表、订单表、公告表、留言表。下表是各表的职责边界供你对照源码里的 SQL 脚本查验。表名核心字段业务含义与其它表的关系adminid, username, password, real_name系统管理员账号独立memberid, username, password, name, id_card, phone注册会员被 orders 引用flightid, flight_no, departure_city, arrival_city, time, price航班班次与余票被 orders 引用ordersid, order_no, member_id, flight_id, seat_count, status乘客订票记录关联 member 与 flightnoticeid, title, content, create_time航班公告独立messageid, member_id, content, reply会员留言与回复关联 member2.1 会员表和航班表的字段设计会员表是所有业务的前置条件没有会员就没有订单。字段设计上要留意身份证号这类业务唯一键不能只靠自增主键去重。下面这段建表 SQL 是常见做法也适合直接对照源码里的数据库脚本理解。CREATE TABLE member ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(64) NOT NULL COMMENT 登录密码建议BCrypt密文, name VARCHAR(30) DEFAULT NULL COMMENT 真实姓名, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, email VARCHAR(50) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_id_card (id_card), KEY idx_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT注册会员表;这里有几个关键点。id_card加唯一约束保证同一个人不能注册两次phone只用普通索引因为手机号允许为空空值不参与唯一约束。ENGINEInnoDB是硬性要求后面做事务扣减余票时必须依赖它MyISAM 不支持事务提交和回滚。密码列建议留64位长度给 BCrypt 密文留足空间直接存32位 MD5 是毕业设计里最常见的偷懒写法答辩时容易被追问。航班表是这个系统的核心数据源字段设计直接决定查询效率。上面这个表里departure_time和arrival_time用DATETIME而不是字符串排序和范围查询才能走索引。价格用DECIMAL(10,2)不要用FLOAT或DOUBLE浮点数存金额会出现 0.1 0.2 不等于 0.3 的问题。2.2 订单表唯一业务单号与关联索引订单表是六张表里唯一有多个外键关联的表。建议在项目里不要用数据库物理外键而是在代码层维护逻辑关联原因很直接航班或会员被删除时订单作为流水记录必须保留物理外键会阻止删除逻辑外键配合代码检查既灵活又能保留痕迹。CREATE TABLE orders ( id INT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 展示给用户的业务单号, member_id INT NOT NULL COMMENT 逻辑关联member.id, flight_id INT NOT NULL COMMENT 逻辑关联flight.id, seat_count INT NOT NULL DEFAULT 1 COMMENT 购买座位数, total_price DECIMAL(10,2) NOT NULL COMMENT 总价航班价*座位数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已退票, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_member (member_id), KEY idx_flight (flight_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;order_no是业务单号要展示给用户所以必须加唯一约束内部查询还是用自增id。member_id和flight_id分别建普通索引因为订单列表页最常见的两个查询条件是“查某人的所有订单”和“查某航班的销售情况”没有索引的话数据量到几万条之后关联查询会全表扫描。2.3 数据一致性删除前的关联检查用逻辑外键不代表可以随便删。会员表在member_id上被订单表引用航班表在flight_id上也被订单表引用删除前必须先查关联订单数。这个检查逻辑写在 Service 层字段设计阶段就要给订单表预留好两个查询索引否则删除时的一次count查询也会拖慢整个操作。关于这一点后面第三章的删除模块会给出完整代码。3. 后台管理模块实现管理员登录与会员/航班 CRUD管理端是这个系统的核心功能包含管理员信息维护、会员信息管理、航班信息管理。模块之间没有嵌套关系共用一个登录态。SpringMVC 的请求分发和拦截器校验要先搭好否则每个 Controller 里重复写登录判断会非常乱。3.1 拦截器校验/admin/**与/admin/login的路径博弈SpringMVC 的所有请求都经过DispatcherServlet入口配置在web.xml。源码里如果是传统的 XML 配置方式核心是这段servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingload-on-startup设为 1容器启动时就创建 Spring 容器并加载受管 Bean避免第一个请求进来才初始化导致页面卡顿。url-pattern用/配合RequestMapping实现 Restful 风格地址不用再写*.do后缀。登录限制用拦截器而不是 Filter因为拦截器能访问 Spring 容器里的 Service。放开登录接口时要注意路径粒度一般这样配置mvc:interceptors mvc:interceptor mvc:mapping path/admin/**/ mvc:exclude-mapping path/admin/login/ mvc:exclude-mapping path/admin/doLogin/ bean classcom.air.reservation.interceptor.AdminLoginInterceptor/ /mvc:interceptor /mvc:interceptors拦截路径用/admin/**把整个管理端包住然后单独放行登录页和登录提交接口。很多项目在这里把/admin/css/**、/admin/js/**也放行实际没必要模板页面里的静态资源走的是mvc:resources不会进拦截器。下面这张路径规划表可以直接用于排错请求路径是否被拦截说明/admin/login否登录页需要匿名访问/admin/doLogin否登录表单提交地址/admin/member/list是需登录后访问/admin/flight/add是需登录后访问拦截器里拿到session.getAttribute(admin)为空就sendRedirect到登录页这里注意重定向要用绝对路径相对路径在部署到带 contextPath 的环境时会 404。3.2 会员管理Controller-Service-Mapper 三层调用与分页参数会员管理模块的管理员操作包括查询、修改、删除。列表页的分页是必考题源码如果没集成 PageHelper建议自己加上。分页查询的标准写法如下Controller RequestMapping(/admin/member) public class MemberController { Autowired private MemberService memberService; GetMapping(/list) public String list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String keyword, Model model) { PageInfoMember page memberService.findPage(pageNum, pageSize, keyword); model.addAttribute(page, page); return admin/member/list; } PostMapping(/delete) ResponseBody public Result delete(RequestParam Integer id) { memberService.deleteById(id); return Result.success(); } }pageNum从 1 开始pageSize默认 10这两个参数是分页插件约定不要从 0 开始否则第一页会查不到数据。keyword是会员姓名或手机号的模糊搜索词为空时查询全部。Service 层再往下一层走要处理两件事分页和删除前的关联检查。Service public class MemberServiceImpl implements MemberService { Autowired private MemberMapper memberMapper; Autowired private OrderMapper orderMapper; Override Transactional(readOnly true) public PageInfoMember findPage(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListMember list memberMapper.selectByCondition(keyword); return new PageInfo(list); } Override Transactional public void deleteById(Integer id) { int count orderMapper.countByMemberId(id); if (count 0) { throw new ServiceException(该会员存在订单记录不能删除); } memberMapper.deleteByPrimaryKey(id); } }Transactional(readOnly true)是查询方法的优化标记提示底层连接只读。删除方法必须加Transactional因为它包含一次查询和一次删除两步操作为了保证两步之间数据一致需要在同一个事务里执行。countByMemberId就是前面表结构里idx_member索引发挥作用的地方。MyBatis 映射层需要强调一个易错点#{}和${}的区别。查询的时候用${}拼接关键字会触发 SQL 注入正确写法是select idselectByCondition resultTypecom.air.reservation.entity.Member SELECT * FROM member where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR phone LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY create_time DESC /selectMyBatis 对#{}会生成预编译占位符?值通过PreparedStatement参数传入这是防注入的基本保障。CONCAT(%, #{keyword}, %)不能改写成%${keyword}%后者直接把用户输入拼进 SQL是毕设答辩里最容易被挑出来的安全问题。3.3 航班信息录入日期时间与价格字段的处理航班录入模块涉及两个类型的字段最容易出错日期和价格。页面表单提交的是字符串比如2025-06-01 08:30Controller 接收时如果直接绑定到LocalDateTime会报类型转换异常。比较省事的做法是在实体字段上加格式化注解public class Flight { private Integer id; private String flightNo; DateTimeFormat(pattern yyyy-MM-dd HH:mm) private LocalDateTime departureTime; DateTimeFormat(pattern yyyy-MM-dd HH:mm) private LocalDateTime arrivalTime; private BigDecimal price; }DateTimeFormat处理的是表单字符串到LocalDateTime的入站转换。LocalDateTime比java.util.Date好用的地方在于它带时区无关的时间运算方法比如计算航班飞行时长可以直接duration.between(departureTime, arrivalTime)。价格字段用BigDecimal接收还有一个好处前端传1299.9这种值不会出现浮点精度丢失。数据库里DECIMAL(10,2)与BigDecimal直接映射写入时不需要做额外转换。录入校验里至少要有两条到达时间必须晚于出发时间票价必须大于 0。这两条通常先在前端做一次Controller 里再校验一次防止绕过页面直接调接口。3.4 航班的删除与状态切换航班删除和会员删除的逻辑一致都是先查订单再删。但航班有个更推荐的做法不物理删除而是把status置为4取消。原因是历史订单需要展示航班号、起降城市、价格等快照信息订单表里的flight_id如果关联不到真实航班查询详情时就要面对一堆空值。我一般会在删除按钮旁边并列一个“取消航班”按钮调同一个 Service 方法只是status不同。这样数据库里保留了完整历史前端订单列表通过 LEFT JOIN 也能查到已被取消的航班信息不会出现大面积空指针。4. 订单信息与公告留言多表联查、事务与状态流转订单模块是系统里业务逻辑最重的一块它同时依赖会员表、航班表还要处理余票变更和状态流转。这里拆成三个点讲订单号与状态枚举、下单扣余票的事务控制、订单列表的多表联查。公告和留言模块相对简单放在最后说明。4.1 订单号生成与状态枚举订单表用自增id做主键但对外展示必须用业务单号否则用户能通过订单号猜测系统单量比如输入10001查到10000。常见的生成方式是时间戳加随机数public String generateOrderNo(Flight flight) { String timePart LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); String flightPart String.format(%04d, flight.getId()); String randomPart String.format(%02d, ThreadLocalRandom.current().nextInt(100)); return timePart flightPart randomPart; }时间戳精确到秒加航班 ID 和两位随机数重复概率可以接受。String.format(%04d)把航班 ID 补零到四位避免单号长度不一致。这个编号在 Service 层生成生成逻辑放在方法内部而不暴露成公共方法防止别的模块绕过规则直接构造订单号。订单状态用TINYINT存储对应关系固定为下面这张枚举表。用数字不用字符串是因为状态字段要参与索引和范围查询数字比较比字符串快也能避免已支付和 已支付这类脏数据。状态值含义页面显示可执行操作0待支付待支付支付、取消1已支付已支付出票2已出票已出票退票3已退票已退票无4.2 下单扣余票一个必须加事务的方法下单操作涉及两步写操作更新航班余票和插入订单记录。如果第一步成功、第二步失败余票少了一张但订单不存在会造成超卖。正确的事务边界是整个 Service 方法Override Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateDTO dto) { Flight flight flightMapper.selectByPrimaryKey(dto.getFlightId()); if (flight null) { throw new ServiceException(航班不存在); } if (flight.getSeatLeft() dto.getSeatCount()) { throw new ServiceException(余票不足); } Flight updated new Flight(); updated.setId(flight.getId()); updated.setSeatLeft(flight.getSeatLeft() - dto.getSeatCount()); flightMapper.updateByPrimaryKeySelective(updated); Order order new Order(); order.setOrderNo(generateOrderNo(flight)); order.setMemberId(dto.getMemberId()); order.setFlightId(flight.getId()); order.setSeatCount(dto.getSeatCount()); order.setTotalPrice(flight.getPrice().multiply(new BigDecimal(dto.getSeatCount()))); order.setStatus(0); orderMapper.insertSelective(order); return order; }这里必须写rollbackFor Exception.class因为 Spring 默认只对RuntimeException回滚如果 Service 层抛的是受检异常事务会被提交出现余票扣了但订单没生成的严重 bug。updateByPrimaryKeySelective只更新非空字段避免把seat_total等其它字段覆盖成空值。totalPrice在 Service 层计算而不是依赖前端传值防止用户篡改票价。余票的扣减还有一个并发层面的隐患两个请求同时读到seatLeft 1都通过检查各自减一最后余票变成 -1。对这个体量的项目可以先在 SQL 里加条件WHERE seat_left #{seatCount}让数据库来判断余票是否足够。updateByPrimaryKeySelective改造一下也能实现这也是毕设答辩中数据库并发方向的高频问题。4.3 订单列表的 LEFT JOIN 多表查询订单列表页要展示航班号和会员姓名这三条信息分别存在三张表里必须联查。常见的坑是两张表都有create_time字段导致列名冲突以及 LEFT JOIN 加过滤条件把左连接退化成内连接。可以参考下面的 Mapper 写法ListOrderVO selectOrderPage(Param(status) Integer status, Param(memberName) String memberName, Param(offset) int offset, Param(limit) int limit);select idselectOrderPage resultMapOrderVOResultMap SELECT o.id, o.order_no, o.seat_count, o.total_price, o.status, o.create_time, f.flight_no, f.departure_city, f.arrival_city, f.departure_time, m.name AS member_name, m.phone FROM orders o LEFT JOIN flight f ON o.flight_id f.id LEFT JOIN member m ON o.member_id m.id where if teststatus ! null AND o.status #{status} /if if testmemberName ! null and memberName ! AND m.name LIKE CONCAT(%, #{memberName}, %) /if /where ORDER BY o.create_time DESC LIMIT #{offset}, #{limit} /select用LEFT JOIN而不是INNER JOIN目的是当某条订单的航班被逻辑取消或删除后订单记录仍然能查出来f.flight_no为空时页面显示“航班已取消”而不是整行消失。需要注意如果对f表的列加过滤条件比如AND f.status 1LEFT JOIN 会被过滤条件改写为内连接语义所以状态过滤只能加在o.status上。LIMIT #{offset}, #{limit}是手动分页方式offset等于(pageNum - 1) * pageSize。这种方式在数据量过万后会有深分页性能问题但毕业设计的数据规模下完全够用而且比 PageHelper 更好向答辩老师解释清楚分页原理。4.4 公告与留言模块的边界公告和留言是两个相对独立的模块。公告表字段少核心操作是发布和列表展示可以加一个top_flag字段控制置顶排序时ORDER BY top_flag DESC, create_time DESC。留言模块需要关注的是回复功能message表增加reply字段管理员回复时执行UPDATE没有回复时reply为空字符串。列表页查询时要区分“未回复”和“已回复”两种状态方便管理员优先处理未回复留言。5. 部署验证与排错从 IDEA 到 Tomcat 的完整检查清单拿到这套源码后第一个实际目标是让它跑起来。部署顺序建议按环境核查、编译打包、启动访问、接口验证四步走每步只检查一个变量出问题时定位更快。先确认基础环境版本JDK 1.8Maven 3.6 以上Tomcat 8/9MySQL 5.7。Spring 4.x 对 JDK 8 支持最稳Spring 5.x 需要 JDK 8 以上如果源码用的注解配置方式上述组合都不会有问题。修改数据库连接后执行打包mvn clean package -DskipTests-DskipTests跳过单元测试只编译代码并打 WAR 包。打包成功后再把生成的air-1.0.war拷到 Tomcat 的webapps目录启动后访问http://localhost:8080/air/。如果修改频繁直接在 IDEA 里用 Maven 的tomcat7-maven-plugin插件运行更快但要确保插件版本和容器版本匹配。部署过程中最常见的四类问题按优先级列在下表错误现象可能原因检查点Tomcat 启动失败端口被占用8080 被其它进程占用netstat -ano找出 PID 并结束进程页面 404contextPath 不对确认访问路径是/air/而不是根路径MyBatis 报 Invalid bound statementmapper 接口和 XML 的 namespace 不匹配namespace 必须是接口全限定名方法 id 一致中文乱码编码过滤器和连接参数缺失检查CharacterEncodingFilter与characterEncodingutf8数据库连接是第一个要验证的环节jdbc.properties里的 URL 写法直接决定能否连上jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/air_reservation?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456serverTimezoneAsia/Shanghai是 MySQL 8 驱动的必填项不写会报时区错误。useSSLfalse关闭 SSL 告警characterEncodingutf8保证中文写入不乱码。如果数据库里已有的 SQL 脚本文件编码是 GBK导入前先转成 UTF-8否则表注释和初始数据都是乱码。还有一个高频坑是事务不生效排查顺序记住三个关键点Spring 配置文件有没有启用事务注解DataSource的transactionManager是否正确装配MySQL 表引擎是不是 InnoDB。三者有一个不对事务的回滚都不会按预期工作。验证事务是否生效的简单办法是在createOrder方法里手动throw new RuntimeException(测试)看订单表是否多出记录没多出就说明事务生效。最后把 Mapper XML 里每个resultMap的column和实体字段对应关系逐个核对一遍别名不匹配会在运行时报Could not find property这类错误在编译期看不出来。本文还有配套的精品资源点击获取