把“旅游门票信息系统”这种题目做成完整项目我前后带过不少同学一起搞过。这个题目听起来像典型的毕设选题实际上做起来并不难但也绝不是一个Controller加几张表就能糊弄过去的。特别是springboot055这种编号通常对应着学校选题库里的一套标准要求功能点、技术栈、文档结构都得对得上。这篇博文我就按自己实际做项目的流程把这个系统的设计思路、核心代码、数据库表结构和踩坑记录都梳理一遍给正在做类似题目的同学一个能直接照着写的参考。1. 项目到底要做什么需求拆解与功能边界很多同学拿到题目第一反应是打开IDE开始建项目这其实是大忌。旅游门票信息系统的核心不是“卖票”而是围绕“景区、门票、订单、核销”这条业务线形成闭环。我习惯先把角色和功能画出来再决定表结构和接口。1.1 角色与权限范围这个系统我按三种角色来设计游客用户注册登录、浏览景区列表、查看门票详情、在线下单购票、查看个人订单、在线退票。景区管理员维护自己负责的景区信息、管理门票类型和库存、核销已出售的门票、查看本景区的销售统计。系统管理员对所有景区、用户、订单进行全面管理包括禁用违规用户、下架异常景区、查看全平台报表。三种角色的权限差异必须从一开始就在代码层面拉开不能只靠前端按钮隐藏来区分。我用的方法是后端定义一个拦截器在请求进入Controller之前解析登录用户的角色字段按角色白名单放行。比如/admin/**只允许系统管理员访问/scenic/manage/**只允许景区管理员访问这个逻辑在功能开发之前就先搭好。1.2 核心业务功能清单需求清单理清楚之后整个项目的工作量就一目了然了。我通常把功能分成三类基础功能注册与登录含验证码校验、Session或Token管理景区分类浏览与关键词搜索门票详情展示库存、价格、有效期核心交易功能下单购票库存扣减、订单生成在线支付本项目用模拟支付实际是生成支付状态为“已支付”的订单退票申请与退款处理门票核销景区管理员扫码/输入票号确认入园管理功能景区管理新增、编辑、上架/下架门票类型管理一日票、两日票、家庭套票等订单管理订单列表、退款审核统计报表按景区、按日期的销售额统计这里有一个容易忽略的点退票和核销的状态机设计。一张票从下单到最终使用状态应该这样流转待支付 - 已支付 - 已核销或者待支付 - 已支付 - 退款中 - 已退款。状态字段我建议直接用int类型存数字0、1、2、3不要用字符串因为字符串状态在数据库里不好做校验而且容易写错。2. 技术选型为什么是Spring Boot MyBatis-Plus技术选型这个事情我见过很多同学纠结。有的非要上微服务有的非要前后端完全分离用Vue实际上对一个毕设级别的旅游门票系统来说选型的第一原则是自己hold得住。Spring Boot是题目里定死的框架持久层我选了MyBatis-Plus原因很简单单表CRUD可以完全不用写SQL它能省掉大量重复的Mapper代码把精力集中到订单和库存这些核心业务上。2.1 框架选择的逻辑Spring Boot这类Web项目我实际用的组合是这样的Spring Boot 2.7.x稳定、资料多、兼容性好。没必要用3.x因为3.x要求JDK17很多同学本机装的是JDK8环境搭起来就会浪费时间。MyBatis-Plus 3.5.x内置分页插件、条件构造器、逻辑删除非常适合快速开发。这个项目里订单列表、景区列表到处都要分页用它非常顺手。MySQL 8.0编码统一用utf8mb4这个不解释用过的都知道为什么。Thymeleaf服务端渲染模板。我知道有人喜欢前后端分离但毕设项目用Thymeleaf直接把页面放在resources/templates下部署时就是一个jar包不用再配Nginx和静态资源服务省事很多。选Thymeleaf还有一个更现实的原因答辩的时候只要演示浏览器页面即可不需要额外启动前端工程。整个项目打成一个jar包就能跑起来环境还原成本最低。这不是技术妥协而是把有限的时间花在业务逻辑上。2.2 工程目录结构我习惯按“分层分包”而不是“按功能分包”的方式组织代码这样结构最清晰也最容易向导师解释。项目根包名设为com.example.ticket下面这样分com.example.ticket ├── config // 配置类拦截器、MyBatis-Plus分页插件 ├── controller // 控制器层 ├── service // 业务接口与实现 ├── mapper // MyBatis-Plus Mapper接口 ├── entity // 实体类 ├── dto // 前端交互参数对象 ├── vo // 返回给前端的视图对象 └── common // 统一返回结果、异常处理、常量这个结构为什么要单独提一下因为很多同学喜欢把VO和DTO混在一起或者干脆直接用Map返回数据。短期的结果是代码能跑但到了写设计文档、画类图的时候就会发现没法描述。我建项目时先把这几个包建好后面每个接口的返回值都用统一的ResultT包裹前端拿到的JSON结构永远是{code, message, data}三件套联调的时候基本上不需要再对字段。3. 数据库设计一张表一张表地抠数据库设计是这个项目最值得花时间的部分。我接过好几个同学的半成品项目发现出问题最多的地方反而不是代码而是表关系没理清。比如订单表里直接存了“景区名称”字符串而不是景区ID——这种设计看起来很省事但景区改名之后历史订单的统计就会错乱。下面我把核心表结构逐个讲一遍字段名和类型可以直接照抄。3.1 用户与角色表用户表我只建一张sys_user通过role字段区分角色不建三张用户表。建多张表会让登录逻辑复杂三倍而且扩展新角色时要动表结构划不来。CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT MD5加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, role int NOT NULL DEFAULT 0 COMMENT 角色:0-游客,1-景区管理员,2-系统管理员, phone varchar(20) DEFAULT NULL COMMENT 手机号, status int NOT NULL DEFAULT 1 COMMENT 状态:1-正常,0-禁用, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码存储我用的不是简单的MD5而是“盐值MD5”的方式。具体做法是注册时生成一个随机字符串作为盐值拼在密码后面再做MD5盐值单独存在用户表里。这样就算数据库泄露反查明文密码也没那么容易。3.2 景区与门票类型表景区表scenic和门票类型表ticket_type是一对多的关系。一个景区可以有一日票、两日票、亲子票等多种票型每个票种单独维护价格和库存。这里有一个设计要点景区和票种分开后以后景区要增加一个夜场票只需要新增一条票种记录不需要动景区表和订单表。CREATE TABLE scenic ( id bigint NOT NULL AUTO_INCREMENT, scenic_name varchar(100) NOT NULL COMMENT 景区名称, description text COMMENT 景区介绍, address varchar(200) DEFAULT NULL COMMENT 详细地址, cover_image varchar(255) DEFAULT NULL COMMENT 封面图片地址, manager_id bigint DEFAULT NULL COMMENT 景区管理员ID, status int NOT NULL DEFAULT 1 COMMENT 状态:1-上架,0-下架, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE ticket_type ( id bigint NOT NULL AUTO_INCREMENT, scenic_id bigint NOT NULL COMMENT 所属景区ID, type_name varchar(50) NOT NULL COMMENT 票种名称, price decimal(10,2) NOT NULL COMMENT 售价, stock int NOT NULL DEFAULT 0 COMMENT 库存数量, limit_per_user int NOT NULL DEFAULT 5 COMMENT 单用户限购数量, status int NOT NULL DEFAULT 1 COMMENT 状态:1-可售,0-停售, PRIMARY KEY (id), KEY idx_scenic (scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格字段必须用decimal(10,2)绝对不能存成double。用double存金额计算时会出现0.10.20.30000000000000004这种问题虽然很多场景下显示没影响但订单金额对不上账排查起来很痛苦。3.3 订单与核销记录表订单表是整个系统数据流的核心。我的设计是“一张主表一张子表”主表orders存订单公共信息子表order_item存每一张票的具体信息。这样设计是因为一个订单里可能买了两张一日票和一张儿童票主表存总金额子表逐条记录票种和数量。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL COMMENT 下单用户ID, scenic_id bigint NOT NULL COMMENT 景区ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status int NOT NULL DEFAULT 0 COMMENT 订单状态:0-待支付,1-已支付,2-退款中,3-已退款,4-已完成, pay_time datetime DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, ticket_type_id bigint NOT NULL, ticket_name varchar(50) NOT NULL COMMENT 票种名称快照, price decimal(10,2) NOT NULL COMMENT 当时价格快照, quantity int NOT NULL COMMENT 购买数量, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;ticket_name和price这两个字段我称之为“快照字段”。为什么要存快照因为门票可能改价如果订单里没有留存当时的价格后续对账就会和当前票价混在一起。这个设计来自于实际需求不是学术上的洁癖。核销记录表verify_record我单独建一张记录每一张票被核销的时间、操作人和所属订单ID方便日后查入园记录也能防止同一张票重复核销。CREATE TABLE verify_record ( id bigint NOT NULL AUTO_INCREMENT, order_item_id bigint NOT NULL, scenic_id bigint NOT NULL, operator_id bigint NOT NULL COMMENT 核销操作人ID, verify_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4. 核心业务模块实现从登录到下单表结构设计好了之后最大的难点就剩下两块登录鉴权和下单事务。这两个模块我单独讲因为它们在实操中踩坑最多。4.1 登录鉴权与拦截器配置我用Session做登录状态管理不引入JWT。理由很简单毕设项目通常是单体应用没有多个服务端共享Session的需求用Session比JWT更简单而且天然支持服务端主动失效。登录成功之后我把用户信息放进Session同时设置一个全局的拦截器对所有请求统一校验public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object loginUser session.getAttribute(loginUser); if (loginUser null) { // 如果是Ajax请求返回JSON提示未登录 if (request.getHeader(X-Requested-With) ! null) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); } else { response.sendRedirect(/login); } return false; } return true; } }在Web配置类中注册这个拦截器并配置放行路径。我实际的做法是/login、/register、静态资源/static/**、/css/**、/js/**、景区浏览和搜索接口全部放行其余一律拦截Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /static/**, /scenic/list, /scenic/detail/**); }这里有一个我在项目中真实遇到过的坑Thymeleaf页面里如果引用了本地的CSS和JS这些请求也会经过拦截器。如果忘了把静态资源路径放行页面会显示“未登录”但接口调试时又感觉没问题排查起来特别浪费时间。4.2 门票下单的事务控制与库存扣减下单是典型的“多步写操作并发控制”场景。一个完整下单流程包括校验用户和票种、检查库存、扣减库存、创建订单主记录、创建订单子记录。任何一个步骤失败前面已写的数据都必须回滚所以这个流程必须用Transactional包起来。我实际写的下单核心逻辑简化后长这样Transactional(rollbackFor Exception.class) public OrderResult createOrder(OrderCreateRequest req) { // 1. 查票种,判断是否在售 TicketType ticketType ticketTypeMapper.selectById(req.getTicketTypeId()); if (ticketType null || ticketType.getStatus() ! 1) { throw new BizException(票种不存在或已停售); } // 2. 乐观锁扣减库存 int rows ticketTypeMapper.deductStock(req.getTicketTypeId(), req.getQuantity()); if (rows 0) { throw new BizException(库存不足); } // 3. 计算金额并生成订单号 BigDecimal total ticketType.getPrice().multiply(BigDecimal.valueOf(req.getQuantity())); String orderNo generateOrderNo(); // 4. 插入订单主表 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(req.getUserId()); order.setScenicId(ticketType.getScenicId()); order.setTotalAmount(total); order.setStatus(0); order.setCreateTime(new Date()); ordersMapper.insert(order); // 5. 插入订单子表 OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setTicketTypeId(ticketType.getId()); item.setTicketName(ticketType.getTypeName()); item.setPrice(ticketType.getPrice()); item.setQuantity(req.getQuantity()); orderItemMapper.insert(item); return new OrderResult(orderNo, total); }关于并发控制我一直强调一个点高并发扣库存不能用先查再update的方式因为那两步之间会有别的线程插入。我在Mapper里写的扣减语句是直接用一条Update完成的Update(UPDATE ticket_type SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}) int deductStock(Param(id) Long id, Param(quantity) Integer quantity);这条SQL的巧妙之处在于stock #{quantity}这个条件。MySQL在执行Update时会自动对当前行加锁多个请求同时扣减同一库存时后到的请求会因为条件不满足而影响0行返回的rows就是0业务层就能立刻判断“库存不足”。这个方式比先查询再判断再更新要可靠得多也是项目中核心并发逻辑的正确写法。4.3 门票核销的幂等处理核销这个功能看似简单就是查订单、改状态但如果不做幂等处理就会出现一个严重问题同一个核销请求因为网络原因被重复提交两次导致同一张票被核销两次游客入园记录就被算了两遍。我的处理方案是核销之前先查verify_record表是否已有该订单项的记录如果有就直接返回“该票已核销”不再执行更新操作。同时在order_item表里加一个status字段标记这张票是否已被核销。核销操作的整体逻辑是Transactional(rollbackFor Exception.class) public void verifyTicket(Long itemId, Long operatorId) { OrderItem item orderItemMapper.selectById(itemId); if (item null) { throw new BizException(票务信息不存在); } if (item.getStatus() 1) { throw new BizException(该票已核销请勿重复操作); } item.setStatus(1); orderItemMapper.updateById(item); VerifyRecord record new VerifyRecord(); record.setOrderItemId(itemId); record.setScenicId(item.getScenicId()); record.setOperatorId(operatorId); record.setVerifyTime(new Date()); verifyRecordMapper.insert(record); }核销的这个事务范围覆盖了“改状态”和“写核销记录”两步保证二者要么同时成功要么同时失败。5. 前端页面与接口联调很多同学把精力全放在后端结果页面做出来非常粗糙。旅游门票系统这种面向普通用户的系统页面美观度其实很影响观感也直接影响答辩时的第一印象。我不写复杂特效就把页面做得干净、整齐、信息层级清楚。5.1 页面设计与交互Thymeleaf页面我整体布局是这样安排的首页/顶部导航栏景区名称、搜索框、登录/注册入口、景区卡片列表、底部版权。景区卡片用Bootstrap的卡片组件每个卡片展示封面图、景区名、最低票价和“查看详情”按钮。景区详情页/scenic/detail/{id}景区大图、介绍文本、票种表格票价/库存/限购数、购买按钮。订单确认页/order/confirm/{ticketTypeId}展示票种信息、游客选择的购买数量、应付金额、“提交订单”按钮。个人中心/user/orders当前用户的所有订单列表支持查看明细和申请退票。后台管理页/admin/index侧边栏导航景区管理、票种管理、订单管理、统计报表中间内容区嵌入数据表格。为了不让页面显得太业余我做了两个小细节。第一是金额统一用两位小数展示在Thymeleaf中使用#numbers.formatDecimal(order.totalAmount, 1, 2)格式化。这个不处理的话数据库里5.50元会显示成5.5很影响观感。第二是空数据状态列表接口返回空数组时页面上显示“暂无数据”的提示不要让一张空白表格直接暴露给用户。5.2 接口返回格式约定前后端的数据交互全部使用统一的JSON格式。我定义了一个ResultT类public class ResultT { private Integer code; // 200成功, 400业务错误, 401未登录, 500系统错误 private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }这个统一返回值的好处体现在前端联调上页面里的JS只需要判断code是否为200其余情况一律把message弹给用户看。比如下单时库存不足后端抛业务异常全局异常处理器把异常转换成Result.error(400, 库存不足)前端拿到后弹一个提示框整个过程不需要在Controller里写任何try-catch。另外Controller层我推荐使用VO对象而不是Entity直接返回。比如订单列表页需要展示景区名称和一个“已支付/未支付”的中文状态如果直接把Orders实体返回给前端前端还要自己翻译状态码。我在Service里把实体组装成OrderVO里面直接带scenicName和statusText字段前端拿来就显示逻辑都收拢在后端。6. 常见问题与排查技巧实录这个系统做完之后我在测试阶段和帮别人debug的过程中收集了不少真实的问题这里挑几个最典型的写下来这些问题都是常规教程里很少提到的。6.1 事务不生效的三种情况用Transactional的同学经常会发现明明加了注解但数据还是没回滚。我归纳了三个最常见的坑方法被同类内部调用Spring的事务是通过AOP代理对象实现的如果A方法调用同类里的B方法B的Transactional不会生效因为调用的是原始对象而不是代理对象。解决办法是把事务方法单独抽到另一个Service类里。异常被捕获了事务回滚的触发条件是异常能从方法里传播出去。如果自己在方法内部try-catch把异常吞掉事务框架感知不到异常自然不会回滚。解决的思路是不要在事务方法里写try-catch要么捕获后重新抛出要么让异常往上抛。数据库表不支持事务MySQL的MyISAM引擎不支持事务InnoDB才支持。这个问题虽然不常见但我确实遇到过有人建表时用了默认引擎结果导致事务一直不生效。建表完成后顺手执行SHOW TABLE STATUS检查一下ENGINE字段。6.2 并发下库存变成负数这个问题出现的原因我前面已经讲过就是“先查后扣”的并发漏洞。两个用户同时查询到库存都是5张然后同时下单都执行“先查再减”最终库存可能变成3张等于多卖了两张。解决办法就是我前面写的乐观锁扣减SQL。这条SQL执行后要检查影响行数影响0行就是没有扣减成功直接提示用户库存不足。我在项目里只用这一个方法就解决了超卖问题没有再引入Redis分布式锁毕设场景完全够用。6.3 前后端日期格式显示不一致这个问题的典型现象是数据库里存的是2024-01-15 10:30:00页面显示变成Tue Jan 15 10:30:00 CST 2024或者直接出现一串毫秒数。原因是实体类里的Date字段序列化成JSON时默认格式不符合预期。我用的解决办法是加统一配置在application.yml里设置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果是Thymeleaf模板则要使用#dates.format()来格式化时间字段。这个问题补上配置之后能一次解决不要在每一个VO里手动把Date转成String那样代码太丑了。6.4 分页查询返回的total数值不对MyBatis-Plus分页插件在统计总数时如果SQL里用了多表关联或者DISTINCT自动生成的count语句可能统计结果不准。我遇到过一次订单列表总数莫名其妙翻倍的问题后来查明是订单表JOIN了子表导致主表一行被关联出多行count自然就多了。解决办法有两个一是对没有复杂关联的单表查询放心用分页插件二是如果SQL里有聚合和多表关联手动编写count语句使用select count(distinct o.id) from orders o left join ...。这个坑很容易被忽略因为接口整体功能是正常的只有总数不对不仔细核对数据根本发现不了。7. 打包部署与上线补充项目开发完了最终要能打成jar包跑起来这个环节也出过很多问题。下面把部署流程和关键配置再梳理一下。7.1 配置分离我习惯在application.yml里维护一套开发环境配置另建一个application-prod.yml放生产环境配置。生产配置里把数据库地址、用户名、密码换成服务器上的实际值。启动时指定环境变量即可java -jar ticket-system.jar --spring.profiles.activeprod。数据库连接配置里有一项比较容易忽略时区参数。如果连接串没有加serverTimezoneAsia/ShanghaiMySQL 8.0版本可能会报时区错误或者时间差了8个小时。我日常使用的连接串长这样spring: datasource: url: jdbc:mysql://localhost:3306/ticket_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 username: root password: 你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver7.2 部署步骤速查整个打包部署流程可以归纳成下面几个步骤照着做就行在项目的根目录执行mvn clean package -DskipTests构建出ticket-system.jar。这里-DskipTests是跳过单元测试避免测试用例导致打包失败。确认服务器上已安装JDK8及以上版本执行java -version检查。将jar包上传至服务器的指定目录比如/opt/ticket/。首次启动时初始化数据库手动在MySQL里执行建库命令然后启动应用时关闭ddl-auto因为我习惯把建表SQL提前整理好。也可以用框架默认的spring.jpa.hibernate.ddl-autoupdate但update模式不会删除已废弃字段后期容易留下隐患我建议自己管理建表脚本。执行nohup java -jar ticket-system.jar --spring.profiles.activeprod ticket.log 21 后台启动应用。检查日志确认启动成功然后访问http://服务器IP:8080/验证页面是否正常。最后再分享一个部署的小细节服务器的防火墙或者云安全组一定要放开8080端口。我做项目时遇到过代码明明启动成功但浏览器就是访问不了的状况检查了一圈才发现是安全组没有放行端口。这个排查顺序放在第6步之前能省不少时间。整个项目从需求梳理到部署上线核心工作量其实集中在数据库设计和订单事务处理这两个地方其他模块基本都是围绕这些基础数据在做增删改查。我个人做下来最大的体会是这种全栈型项目难点不在某个单个功能的技术深度而在把各个模块之间的数据流转正确串联起来。下单不要忘了扣库存核销不要忘了写记录统计报表不要单查一张表把这些业务逻辑理清楚系统自然就稳定了。如果后面还有时间这个系统可以再扩展一个短信通知模块或者对接真正的支付网关但就目前的架构来说保持Spring Boot加Thymeleaf这个简单方案是完全够用的。