1. 项目背景与方案选型
1.1 售票痛点与系统定位
做线上动物园售票系统之前,很多人第一反应是“这无非是个 CRUD 项目”。真上手之后你会发现,订单、库存、支付状态、退票规则这些点,每一个都能让你加班到半夜。这个系统的核心价值,一方面是替游客省去现场排队买票的时间,另一方面是帮园区把票务数据沉淀下来——销量统计、热门时段分析、游客画像,全都得有数据支撑才能做。
这个项目我定位为课程设计/毕业设计级别的完整案例,不是玩具,但也没有过度工程化。它要覆盖一条完整的购票链路:游客浏览票种 → 注册登录 → 选择日期与数量 → 创建订单 → 模拟支付(或对接真实支付) → 生成电子票二维码 → 入园时扫码核销。同时后台需要支持票种管理、订单管理、用户管理、公告发布、数据统计。整个系统用 SpringBoot 作为后端主框架,前端我选了 Vue + Element UI,数据库用 MySQL,缓存加了一层 Redis。如果你正准备拿 Java 方向做毕设或课设,这套组合的覆盖面足够广,答辩时也讲得出东西。
1.2 技术选型背后的取舍逻辑
SpringBoot 在 Java 生态中的地位不用多说,我选它的核心原因是启动快、配置收敛、生态成熟。你不用像 SSM 时代那样写一堆 XML,一个@SpringBootApplication就能跑起来。但这不代表你不需要理解底层——比如 SpringBoot 默认整合了 Spring MVC、内嵌 Tomcat、自动配置机制,你至少要明白spring-boot-starter-web帮你做了什么,否则遇到诡异问题根本无从排查。
持久层框架我选了 MyBatis Plus,而不是原生 MyBatis 或 JPA。原因很简单:单表 CRUD 要是手写 XML,效率太低;JPA 虽然省事,但复杂查询和 SQL 调优不直观。MyBatis Plus 兼顾两者,内置的BaseMapper能覆盖 80% 的简单操作,复杂统计用注解 SQL 或 XML 自己写,可控性很强。另外它自带分页插件,后台列表页和订单查询都直接受益。
Redis 的引入主要是两件事:一是缓存票种信息和公告,减少数据库压力;二是购票时的库存预扣与防超卖处理。这两个场景用 Redis 的原子操作非常合适。如果只是纯课程设计,完全不引入 Redis 也能跑,但我想让这个项目有一点“生产味道”,所以把它加了进来。至于支付,真实对接微信/支付宝需要商户号,个人项目拿不到,所以我在项目中做了“模拟支付”模块,同时把支付接口抽象出来,后续要对接真实支付只需替换实现类。
2. 系统模块与数据库设计
2.1 角色权限与功能模块拆解
整个系统按角色划分成三类:游客、注册用户、管理员。游客只能浏览票种和公告;注册用户在前者基础上增加了购票、订单查询、退票、个人信息管理;管理员则进入后台,管理票种、订单、用户、公告,还能看销售统计报表。
我画模块图的时候习惯先列角色,再列每个角色能做什么,最后落到页面和接口上。前台部分的核心模块是:首页轮播+公告、票种列表、购票流程(选日期/选数量/创建订单)、订单中心(待支付/已支付/已退票状态流转)、电子票展示、个人中心。后台部分的核心模块是:登录鉴权、Dashboard 统计卡片、票种管理(增删改查+上下架)、订单管理(查看/退款)、用户管理、公告管理。
这里我特别想强调一个容易在设计阶段被忽略的点:票种与日期库存的关系。很多初学设计数据库时只做一张 ticket 表,存总量,结果用户选不同日期买票时,库存根本没法区分。我实际的做法是引入“日期库存表”,每个票种对应多个日期的库存记录,比如“成人票-2025-06-01-剩余500张”。这样既能控制单日可售量,也为后续的限流和峰值控制留了余地。
2.2 核心数据表结构详解
数据库我总共设计了9张表,挑核心的几张说一下设计思路。
第一张是用户表user。字段包括主键 id、手机号(登录账号)、密码(BCrypt 加密存储)、昵称、头像、角色标识(1-普通用户 2-管理员)、创建时间。手机号作为唯一登录凭证,在表上要加唯一索引。密码绝对不能明文存储,这是底线,我用 Spring Security 自带的BCryptPasswordEncoder做哈希。
第二张是票种表ticket_type。字段有:名称(成人票/儿童票/学生票/家庭套票)、描述、原价、售价、票种类型、状态(0-下架 1-上架)、创建时间。这里有个细节:原价和售价要分开,方便后续做优惠活动。金额字段用 DECIMAL(10,2),千万不要用 double/float,线上环境因为浮点精度导致对不上账的例子太多了。
第三张是日期库存表ticket_stock。字段有:id、票种 id、售卖日期、总库存、剩余库存、版本号。这个表就是防超卖的关键战场。版本号字段是为了后续做乐观锁控制用的,虽然我在最终方案里用了 Redis 预扣为主,数据库还保留了乐观锁作为兜底,双保险。
第四张是订单表ticket_order。字段有:订单编号(自定义生成规则)、用户 id、订单总金额、订单状态(0-待支付 1-已支付 2-已取消 3-已退票 4-已完成)、支付方式、支付时间、创建时间、更新时间。订单号的生成规则我用了“日期 + 随机数 + 用户ID后四位”,示例:202506011430221234。不建议直接用数据库自增 id 当订单号暴露给用户,容易被爬取和猜测,这个点面过几个面试官都问过。
第五张是订单明细表order_item。因为一个订单可能包含多种票(一张家庭套票 + 两张成人票),订单明细用来记录每种票的数量、单价、小计金额。主表存总金额,明细表存分项,这是标准的 1:N 设计,不要偷懒只建一张订单表。
最后还有公告表、轮播图表、退款记录表,逻辑相对直接,不展开细说。
2.3 订单状态机与关键字段设计
状态机是这个系统里最值得细说的地方。订单状态我定义了五个:0-待支付、1-已支付、2-已取消、3-已退票、4-已完成。它们之间的流转关系是:待支付可以到已支付(用户付款),也可以到已取消(用户主动取消或超时未付);已支付可以到已退票(用户申请退款),也可以到已完成(入园核销后自动流转);已退票和已完成都是终态,不能再做任何操作。
这个状态机定义清楚后,所有的接口逻辑都围绕它转。比如用户端“取消订单”接口,第一件事就是判断当前状态是否为 0,如果不是直接拒绝。又比如管理员“退款”接口,只处理状态为 1 的订单。这样看起来是代码里几行 if 判断,但设计阶段没想清楚,后期就会陷入各种状态错乱的 bug 泥潭。
我还在订单表里加了一个out_trade_no字段,专门存第三方支付流水号。虽然模拟支付用不上,但这是给未来对接真实支付留的扩展位。做设计时预留这类字段是很好的习惯,答辩加分项往往就在这些细节上。
3. 核心业务实现与实操细节
3.1 购票流程与库存防超卖方案
购票是整个系统最核心的链路。我把它拆成四个步骤:参数校验 → 库存预扣 → 创建订单 → 超时自动释放。前端页面点击“提交订单”后,后端接口按这个流程处理。
参数校验阶段,除了判断用户是否登录、票种是否存在、数量是否为正整数这些常规校验,我还加了一个“售卖日期必须大于今天”的判断,防止用户补买昨天的票。另外要校验单笔订单最大购买数量,我限制为每个票种最多 5 张,防止恶意刷单。
库存预扣是我重点处理的部分。方案是这样:先把票种信息和目标日期的库存量缓存到 Redis,key 设计为stock:ticket:{ticketId}:{date},value 存剩余数量。用户提交订单时,用一段 Lua 脚本原子执行“检查剩余量大于等于购买量 → 扣减剩余量 → 返回成功”,否则返回库存不足。为什么用 Lua 脚本?因为 Redis 单线程执行,Lua 脚本能保证判断和扣减这两步的原子性,避免并发情况下两个请求同时读到剩余量 1,然后都买成功,导致超卖。
库存预扣成功后,才创建订单记录,状态置为待支付。这里有一个关键细节:预扣的库存并不会立即从数据库的ticket_stock表扣减,而是通过一个定时任务(每隔 5 分钟扫描待支付订单,如果超过 15 分钟未支付就自动取消,同时恢复 Redis 库存)来兜底。为什么用延迟释放而不是立即释放?因为用户可能在支付页停留,如果提前释放库存,别人把票抢走了,用户支付成功却没票,这体验太差了。而 15 分钟不支付,基本可以断定用户已经放弃。
数据库表的剩余库存字段,只在订单支付成功后才真正扣减。也就是说,Redis 库存负责“并发拦截”,数据库库存负责“最终一致性”。等技术能力再强一点,你可以用消息队列把扣减动作异步化,但在这个项目体量下,定时任务足够。
核心 Lua 脚本我贴出来,这个脚本我调试过很多次:
-- KEYS[1] = stock:ticket:{ticketId}:{date} -- ARGV[1] = 购买数量 local remain = tonumber(redis.call('GET', KEYS[1])) local buyNum = tonumber(ARGV[1]) if remain and remain >= buyNum then redis.call('DECRBY', KEYS[1], buyNum) return 1 else return 0 end对应的 Java 调用侧要注意:执行完 Lua 脚本返回 1 才继续创建订单;返回 0 直接抛业务异常“库存不足”。另外 Redis 的 key 要设置过期时间,比如票种下架或售卖日期过后,可以让它自动淘汰,避免脏数据长期占用内存。
3.2 订单生成与超时自动关闭
订单创建时几个字段要特别处理。订单编号我用了自定义生成器,规则是yyyyMMddHHmmss + 4位随机数 + 用户ID后四位。UUID 虽然简单,但很长且无序,索引效率差;纯自增 id 又太容易暴露业务量。我的这个规则在演示效果和查询性能之间比较均衡。
创建订单的接口我加了@Transactional事务注解,保证订单主表和明细表要么一起成功,要么一起回滚。有人可能疑惑:Redis 库存已经扣了,数据库事务失败怎么办?我的处理是在事务失败时手动调用一个releaseRedisStock()方法,把预扣的库存加回去。这一步不能依赖 Redis 的自动过期,因为 15 分钟太久,用户立刻重试时会看到库存被“吞了”。
超时关闭订单的定时任务我用 Spring 自带的@Scheduled实现,固定间隔 30 秒执行一次。任务逻辑是:查询状态为待支付且创建时间小于当前时间 15 分钟的订单列表,逐单关闭,并恢复 Redis 库存。这里我踩过一个坑:千万不能用SELECT * FROM ticket_order WHERE create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE)一次性查出所有订单然后 for 循环处理。如果订单量大,长事务会把表锁住。正确做法是分页查询,每批 100 条,处理完再查下一批。这个习惯在大数据量下会救你一命。
@Scheduled(fixedDelay = 30000) public void autoCloseExpiredOrders() { // 分页查询待支付且超时的订单 Page<Order> page = orderMapper.selectExpiredOrders(new Page<>(1, 100), 15); while (page.getRecords().size() > 0) { for (Order order : page.getRecords()) { // 关闭订单,恢复Redis库存 closeOrder(order); } // 继续查下一页 page = orderMapper.selectExpiredOrders(new Page<>(page.getCurrent() + 1, 100), 15); } }定时任务里还要注意并发执行问题。@Scheduled默认单线程执行,但如果任务执行时间超过间隔,会出现任务堆积。我建议在方法上加一个@Lock(如果是用 ShedLock 的话),或者用一个简单的AtomicBoolean标志位防止重入。项目里我用的是标志位方案,够用。
3.3 电子票生成与核销流程
支付成功后,系统要给用户生成电子票。我用的是二维码技术,集成 ZXing 库生成二维码图片,内容是一串加密后的凭证串。凭证串包含:订单号、票种ID、入园日期、用户ID后四位,我用 AES 对称加密再拼接,防止有人伪造二维码。
核销场景是这样的:游客到了动物园入口,打开公众号或 App 里的“我的电子票”,出示二维码,工作人员用后台的“检票核销”功能扫码。扫码后后端解析密文,校验订单状态为已支付、入园日期与当日匹配、核销状态为未使用,满足条件就更新状态为已完成。
这个流程里我特别提醒一个问题:二维码内容不要直接放明文订单号,否则有人可以通过遍历订单号生成二维码逃票。AES 加密虽然谈不上绝对安全,但在这种应用场景里足以挡住大部分低级攻击。密钥别写在代码里,放到application.yml的外部配置,或者环境变量里,项目文档里也要注明。
核销接口必须是幂等的。如果游客的二维码被扫了两次,第一次成功,第二次要返回“该凭证已核销”,不能报系统异常。同时核销操作要加锁,防止同一张票同时被两个入口的机器扫码,导致并发更新。MySQL 行锁用SELECT ... FOR UPDATE,或者用 Redis分布式锁都可以,我这里用的是乐观锁,更新时带上核销状态条件,影响行数为 0 说明已被核销。
3.4 接口设计与统一返回规范
接口设计我走的是 RESTful 风格,但也没有严格到教条的程度。比如“购票”这个动作,用POST /api/ticket/order来创建订单,“取消订单”用PUT /api/ticket/order/{orderNo}/cancel来表示状态变更。这样接口的语义清晰,前端对接时也容易理解。
所有接口的返回格式统一为:
{ "code": 200, "message": "success", "data": {} }code 为 200 表示业务成功,其他为业务错误码,比如 40001 表示库存不足,40002 表示订单状态异常,40003 表示参数校验失败。我建议错误码分段规划:4xxxx 是前端传参问题,5xxxx 是服务端处理问题,这样通过错误码就能快速定位责任方。
统一返回格式我用一个Result<T>泛型类实现,配合全局异常处理器@RestControllerAdvice。业务异常类BizException里直接携带错误码和描述,控制器代码里只需要throw new BizException(ErrorCode.STOCK_NOT_ENOUGH),异常处理器统一捕获并包装返回。这个模式也是实际生产项目里的标准做法,值得从课设阶段就开始养成。
另外,接口层要加参数校验,JSR 303 的@Valid注解用起来。请求 DTO 里对数量字段加@Min(1)、对日期字段加@NotBlank。不要把这些校验依赖前端,前端校验只是用户体验,后端校验才是安全底线。
4. 前端页面与接口对接
4.1 前端技术栈与页面路由结构
前端我选了 Vue 2 + Element UI(如果是新学,建议直接上 Vue 3 + Element Plus,原理类似)。通过 Axios 请求后端接口,路由用 Vue Router,状态管理用 Vuex。为了演示方便,我用vue-cli搭的工程,开发时通过proxy配置把/api前缀的请求代理到后端 8080 端口,避免跨域开发问题。
页面路由按用户侧和管理侧拆分。用户侧页面包括:首页、票种列表、购票确认、订单列表、订单详情、电子票、个人中心、登录注册。管理侧页面包括:Dashboard、票种管理、订单管理、用户管理、公告管理、数据统计。整套页面数量在 15 个左右,工作量适中,但覆盖面足够用来演示前后端分离开发的能力。
4.2 购票页面的关键交互逻辑
购票确认页是最核心的交互页面。它要完成:加载票种列表 → 用户选择日期(日期选择控件禁用今天之前的日期)→ 选择数量 → 实时计算总价 → 提交订单。总价计算我做了前端实时计算展示,但后端接口会重新计算一遍,以后端为准。不要信任前端传过来的金额,这是防篡改的基本常识。前端传参只传票种ID、日期、数量,金额由后端查表计算得出。
订单支付页的逻辑也值得一提。用户点击“确认支付”后,前端调用支付接口,后端在模拟支付模式下直接返回成功,并将订单状态从待支付更新为已支付,同时生成电子票。真实接入支付时,这个接口会变成“调用支付平台下单,返回支付参数”,前端跳转收银台,支付结果通过回调通知。我把PaymentService接口定义好,分别实现了MockPaymentServiceImpl和预留的RealPaymentServiceImpl,这块设计在答辩时讲出来会显得专业。
前端还有一个细节:用户从购物车一样的购票页跳到订单确认页时,后端返回的订单号需要保存到 Vuex,这样支付成功后的电子票页面才能查到属于当前用户的电子票凭证。我见过不少同学把订单号放在 URL query 里,刷新页面就丢了,体验比较糟糕。
4.3 管理后台的统计报表实现
Dashboard 统计页面需要展示三个核心指标:今日销售额、今日订单数、本月售票总量。数据来源是订单表按时间维度做聚合统计,我用 MyBatis Plus 的selectMaps方法写聚合 SQL,返回List<Map<String, Object>>,前端用 ECharts 渲染柱状图和饼图。
这里有个性能优化点:统计接口每次实时查库,如果订单量大了会很慢。我的处理是第一次查询后把结果缓存到 Redis,设置 60 秒过期,相当于容忍 1 分钟内的数据延迟。对于园区这种体量的业务,这个延迟完全可接受。前端每 30 秒自动刷一次,配合缓存生效时间,体验和性能之间取得平衡。
5. 常见问题与排查心得
5.1 库存超卖问题排查实录
我测试时故意用 JMeter 开 200 个线程同时买同一个票种的最后一张票,第一次测试就翻车了——卖出了 7 张。排查过程很有意思,虽然最后定位到原因很简单,但把排查思路写下来对大家有参考价值。
首先检查数据库库存表,发现剩余库存变成了负数。再翻日志发现,Redis 里的库存量其实扣减是正确的,问题出在订单支付成功后的“数据库扣减”环节。我用的是先查库存再更新库存的代码:
Stock stock = stockMapper.selectByTicketIdAndDate(...); if (stock.getRemain() > 0) { stock.setRemain(stock.getRemain() - 1); stockMapper.updateById(stock); }这种“读改写”模式下,多线程同时读到剩余 1,条件都成立,都执行了更新,就超卖了。修复方式是直接更新:
int rows = stockMapper.deductStock(ticketId, date, 1); if (rows == 0) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); }对应 SQL 是UPDATE ticket_stock SET remain = remain - 1, version = version + 1 WHERE ticket_id = ? AND sell_date = ? AND remain >= 1。通过数据库行锁和受影响的 UPDATE 保证原子性。这也是我在前面提到“乐观锁作为兜底”的原因。
5.2 定时任务不执行或重复执行的坑
@Scheduled(cron = "0 */5 * * * ?")这个写法在单机环境下没问题,但如果未来系统部署了多实例,每个实例都会执行一遍定时任务,导致重复处理。课程设计阶段不会遇到多实例部署,但我还是在文档里提了一嘴解决方案:用 Redis 的SETNX做一个简单的分布式锁,SET key value NX EX 120,获取到锁的实例才执行任务,执行完删除锁;或者引入 ShedLock 依赖,两行配置就搞定。
另外提醒一个小坑:SpringBoot 的@Scheduled默认线程池只有一个线程。如果你在任务内部调了远程接口导致阻塞,后续的任务全部卡住。我建议在配置类里显式声明ThreadPoolTaskScheduler,设置核心线程数为 5,避免一个慢任务拖垮其他定时任务。
5.3 时间格式化、跨域与配置项小坑
时间格式化这个问题几乎每次都会遇到。后端返回LocalDateTime默认是2025-06-01T14:30:00这种 ISO 格式,前端展示很难看。我在application.yml里统一配置了 Jackson 的格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8跨域问题也一样。前后端分离开发时,前端 8080,后端 9527,浏览器会拦截跨域请求。我在后端写了一个CorsConfig配置类,允许所有来源、所有请求方法,开发环境足够。生产环境部署建议用 Nginx 反向代理统一入口,彻底规避跨域问题,同时也更安全。
还有一个配置项坑:SpringBoot 版本差异导致配置不生效。如果你用的 SpringBoot 2.4 以上的版本,配置文件里的多环境配置写法变了,spring.profiles.active需要放到application.yml最外层,不要再写在spring.profiles下面。类似这些小坑,我建议把踩过的记录下来,写进配套的“遇到的问题与解决”文档中,答辩时是一份很好的加分材料。
5.4 配套文档、PPT与源码的组织经验
这个项目带了完整的文档和 PPT,这部分经验我从来没见别人认真整理过。毕设/课设答辩的评委会翻文档,但更重要的是他们会在现场让你演示系统。所以我的文档组织逻辑是:需求分析(用户故事和功能清单) → 系统设计(架构图+数据库ER图+接口文档) → 系统实现(核心代码走读) → 测试报告(功能测试+并发测试) → 总结与展望。PPT 则控制在 15 页以内,核心是讲清楚“我做了什么”和“我遇到了什么问题怎么解决的”,不要堆代码。
源码组织方面也有讲究。后端模块我按 controller、service、mapper、entity、common、config 分包,一个包干一件事。前端把 api 请求封装到src/api目录下,页面组件在src/views下。整个代码层级清晰,别人拿到就能快速跑起来,这是“含源码”项目最基本的要求——不是把代码甩出来就叫含源码,而是要让接手的人能看明白、能改、能运行。
最后再分享一个小技巧
整个系统做下来,我最大的感受是:课设级别的项目,重点不在于技术多新多全,而在于逻辑闭环。你做一个售票系统,就要保证从注册到购票到支付到核销到退票,整条链路每一个分支都处理到位。我见过太多项目,演示时主流程很顺畅,一展示管理员退款就报错,一展示库存不足就白屏——这种硬伤特别致命。
我自己的习惯是,交付前花半天时间,把核心业务链路的每一个接口用 Postman 过一遍,包括异常场景:未登录访问订单接口、库存为 0 时下单、重复核销、非法参数请求。把这些异常处理正常了,演示再过不了只能说明运气不好。另外记得,MySQL 和 Redis 的本地环境配置、数据库初始化脚本、Redis 数据预置命令,全都要写进 README。换一台电脑就跑不起来,这比你代码写得漂亮但要花三小时才能启动要致命得多。