Spring Boot线上线下动物园管理系统:从购票到核销的业务闭环实践
最近不少朋友找我要一套“能直接演示、能写进简历、又不会太复杂”的项目源码Spring Boot 做的“线上线下动物园管理系统”是我经常推的一套。项目归档编号 61317说白了就是个版本标记不用过度解读。这类系统的需求量一直不小高校课设、毕设、培训班综合实战甚至一些中小型园区做数字化改造的轻量原型都会拿 Spring Boot 管理后台 游客端这套组合来打底。它和那种纯 CRUD 的“动物园信息登记表”最大的区别就在“线上线下”这四个字上——游客在小程序或 H5 页面完成注册、购票、预约、查看动物科普到园区后凭二维码入园运营人员在后台维护动物档案、上架票种、核销门票、看经营数据。整个系统涉及库存扣减、订单状态流转、二维码防伪、接口幂等等一堆真实业务问题用来练手和学习“企业级开发套路”性价比非常高。1. 项目定位线上线下结合的动物园管理该怎么拆1.1 这个系统到底在解决什么问题我见过不少号称“某某管理系统”的项目点开一看就是一张表的增删改查页面倒挺多业务逻辑等于零。这个动物园管理系统有意思的地方在于它把“线上”和“线下”两条业务链路真实地串起来了。线上链路是游客端完成的注册登录 - 浏览动物和票种 - 下单支付 - 收到电子票二维码 - 到园区门口出示二维码。线下链路是运营端完成的检票人员登录核销界面 - 扫描游客二维码 - 校验订单是否有效 - 标记已使用 - 放行入园。游客在园区里还能扫码查看动物语音介绍、参与科普答题这部分又和线上内容联动起来。这两条链路对应的是两个完全不同的用户视角。线上面对的是 C 端游客交互要轻、流程要短、下单要顺畅页面挂了体验极差线下面对的是运营人员操作要快、状态要明确、异常情况要有兜底比如二维码失效、重复扫码、网络中断都得有明确提示。所以这个系统在架构上不能做成一张大表而是要把用户、票务、核销、内容拆成相互独立的模块再用订单号和二维码把线上和线下串起来。需求分析可以用一张表格快速理清模块核心痛点方案方向线上购票票卖超、重复支付库存预扣、订单状态机、支付回调线下核销伪造票、重复入园二维码签名、一次性核销、幂等控制动物科普游客看完就走互动弱动物档案 科普答题 积分后台运营票务和客流数据不透明聚合报表、图表展示1.2 三类角色与三大核心流程这个系统最典型的角色有三种游客、运营/票务人员、系统管理员。游客的诉求很简单——快速买到票、入园不排队、能看点有意思的内容运营人员的诉求是——今天卖了多少票、核销了多少人、哪只动物最受欢迎系统管理员则要维护整个系统的基础数据用户、动物、公告、票种配置。围绕这三个角色核心流程可以归纳成三段购票预约流程游客选择票种 - 填写日期和人数 - 创建待支付订单 - 模拟支付或对接第三方支付 - 支付成功后生成电子票二维码。入园核销流程游客出示二维码 - 检票员扫码 - 系统校验签名和订单状态 - 核销成功并记录入场时间 - 游客入园。内容互动流程游客进入动物详情页 - 查看图文和语音讲解 - 参与科普问答 - 获得积分 - 积分可在个人中心查看。这三个流程里购票和核销是强业务逻辑内容互动是体验差异化。很多开发者在做这个项目时只关注了第一个流程把订单表做得很完整但核销流程直接跳过了或者只是手动把订单状态改成“已入园”这就丧失了这个项目最有价值的亮点。面试时被问“线上线下一体化怎么实现的”你如果答不出二维码核销和防重复入园的设计项目含金量会大打折扣。1.3 “线上线下”不是噱头而是业务流程的闭环有人会问直接做一个线上售票系统再配一个后台管理系统不就行了吗为什么非要强调“线上线下”关键点在于数据闭环。线上购票量、支付金额、用户浏览行为、动物点击量这些数据最终都要汇聚到管理后台的报表里支撑运营决策。比如某只动物详情页点击率很高但对应的线下展区排队人数很多运营就可以通过后台数据发现“科普内容吸引人但展区承载不足”从而引导游客错峰游览。又比如某天线上售票 500 张但实际核销只有 300 人说明游客爽约率挺高需要设计预约提醒或改签机制。这些分析都依赖“线上行为数据”和“线下核销数据”打通后的同一份报表。所以我在搭建这个项目时特意把“订单表”“核销记录表”“动物浏览记录表”都设计成独立的、可被统计的明细表而不是简单地在订单表上加几个状态字段。这样既方便业务扩展也为后面做数据大屏和经营分析预留了空间。这个设计思路比单纯写几个接口更能体现你对业务的理解。2. 技术选型与整体架构为什么非 Spring Boot 不可2.1 Spring Boot 在中小型管理系统里的统治力做这类管理系统技术选型其实没有太多悬念。Spring Boot 几乎是当前 Java 后端入门和中小型项目的标准答案。它把 Spring 家族里繁琐的 XML 配置基本干掉了内置 Tomcat打成一个 jar 包就能跑配合 Maven 管理依赖新手也能很快把环境拉起来。相比传统的 SSMSpring Boot 的核心价值不是“功能更多”而是“少写配置、快速集成”。这一点在项目实战中的体感差异非常明显我把主要依赖往 pom.xml 里一放写几个 starter数据库连接、参数校验、JSON 序列化这些基础设施基本就齐了。版本选择上建议用 2.7.x 系列搭配 JDK 8 或 11。虽然 Spring Boot 3.x 已经普及但很多高校和培训机构的课件还是基于 2.x而且 2.7 的生态成熟网上资料多遇到问题很容易搜到答案。如果你是非科班转行或者刚开始做毕设没必要追新版本稳定压倒一切。我见过有人拿着 Spring Boot 3.2 写课设结果 MyBatis-Plus 版本不兼容折腾一晚上环境这种时间成本完全不值得。2.2 分层架构与代码组织方式这个系统我推荐标准的 Controller - Service - Mapper 三层架构再加一层 entity 放实体、dto/vo 放传输对象、config 放配置类、common 放统一返回体和异常处理。模块拆分上按照业务领域划分包而不是按技术类型堆在一起。比如com.zoo.manage ├── controller # 接口层游客端、管理端分开 ├── service # 业务层接口 实现 ├── mapper # 数据访问层 ├── entity # 数据库实体 ├── dto # 入参出参对象 ├── vo # 页面展示对象 ├── config # 配置类Redis、拦截器、WebMvc ├── common # 统一返回体、异常、工具类 ├── utils # 二维码、JWT、日期工具 └── task # 定时任务过期订单关单为什么要强调这个分层因为很多初学者喜欢把业务逻辑写在 Controller 里一个方法几百行接口一多就乱成一锅粥。分层的好处不只是“规范”更重要的是可测试、可复用。比如订单过期关闭这个逻辑既可以被定时任务调用也可以在用户查询订单时懒触发写在 Service 里就很自然。Controller 只负责参数接收和结果包装业务判断全部下沉到 Service这才符合真实项目里的协作习惯。2.3 数据库设计关键表与核心字段数据库是这类系统的灵魂。我建议至少设计以下核心表表名用途关键字段sys_user用户表id、username、password、role、nickname、avataranimal动物档案表id、name、category、habitat、description、image、statusanimal_feed_record喂养记录表id、animal_id、feed_time、food、staff_name、remarkticket_type票种表id、name、price、stock、sale_start、sale_end、statusticket_order订单表id、order_no、user_id、ticket_type_id、quantity、amount、status、pay_time、visit_dateverification_record核销记录表id、order_id、verify_code、verify_time、operator_id、statusknowledge_question科普答题表id、animal_id、question、options、answer、scoreuser_answer_record答题记录表id、user_id、question_id、is_correct、scoresys_notice公告表id、title、content、publish_time、status订单状态是业务的核心我通常用整数类型存状态码配合状态机流转0 待支付、1 已支付、2 已核销、3 已过期、4 已退款。不要小看这个状态机它决定了所有业务判断的写法。比如核销接口只允许“已支付”的订单流转到“已核销”退款接口只允许“已支付”流转到“已退款”如果状态不对直接抛出业务异常。把状态流转画清楚后面写代码几乎不会出逻辑漏洞。库存字段建议用 int配合乐观锁 version 字段。虽然也可以用 Redis 做库存扣减但数据库层面的扣减逻辑更直观适合课设和演示。后续我会专门讲超卖问题。2.4 前端方案管理后台、游客端与移动端怎么取舍前端方案是这个项目最容易纠结的地方。常见组合有三种第一种是纯后端模板引擎用 Thymeleaf 渲染页面简单但交互弱第二种是管理后台用 Vue Element Plus游客端用 H5 页面前后端分离第三种是管理后台用 Vue游客端做成微信小程序。我的建议是如果你是用于毕设或简历展示选第二种最稳妥。管理后台用 Vue Element Plus 非常成熟表格、表单、弹窗这些后台组件开箱即用页面整洁游客端用 H5 页面放在同一个前端工程里路由分开部署简单。如果非要做小程序工作量会明显上升需要处理微信登录、审核、域名校验等问题对于一个练手项目来说性价比不高。当然如果项目演示场景需要“扫码”H5 页面生成的二维码完全够用不必上小程序。前后端分离就要面对跨域问题。开发环境下用 Vite 或 Webpack 的代理转发解决生产环境由 Nginx 统一反向代理把 /api 路径转发到后端服务。我在下面部署章节会给出具体配置照着抄就行。3. 核心功能模块实操解析3.1 注册登录与角色权限一个拦截器讲清楚用户模块看起来简单实际做起来有不少细节。密码必须加密存储推荐 BCrypt不要用 MD5。登录成功之后签发 token这个系统适合用 JWT把用户 id 和角色放进 token 里后端通过拦截器统一解析。我的实现思路是这样public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); // 解析 token失败则返回 401 LoginUser loginUser JwtUtils.parseToken(token); // 放行游客可访问的接口管理端接口校验管理员角色 request.setAttribute(loginUser, loginUser); return true; } }注册登录流程里最容易忽略的是 token 过期和注销问题。JWT 本身无状态服务端无法直接让它失效所以我在 Redis 里维护了一个黑名单注销时把 token 的剩余有效期写入 Redis拦截器查一下命中就直接拒绝。这个细节虽然代码量不大但能体现出你对“无状态认证”的理解。角色权限采用简单的方式用户表里有 role 字段取值可以是 0 游客、1 运营、2 管理员。在拦截器里判断请求的接口路径前缀比如 /admin/** 必须要求管理员角色否则返回 403。对于小项目这种方式比引入 Spring Security 更简单直观也更容易在答辩时讲清楚。如果非要引入安全框架Shiro 比 Spring Security 上手更快但拦截器的实现已经足够没必要徒增复杂度。3.2 线上购票与订单状态机库存、支付回调与退款购票是这个系统技术含量最高的模块没有之一。核心难点在于防止库存超卖以及订单状态与支付结果的一致性。先说库存。ticket_type 表里有 stock 字段下单时不能先查库存再扣库存因为并发场景下两个请求可能同时查到剩余 1 张然后各自生成订单最后就超卖了。正确做法是使用数据库的原子更新int count ticketTypeMapper.deductStock(ticketTypeId, quantity); if (count 0) { throw new BusinessException(库存不足); }对应的 SQL 是UPDATE ticket_type SET stock stock - #{quantity}, version version 1 WHERE id #{ticketTypeId} AND stock #{quantity}只有受影响行数为 1 时才说明库存扣减成功。这是最朴素也最可靠的防超卖方案比“先 select 再 update”安全得多。如果你还想更贴近企业实践可以在下单前用 Redis 的 DECR 做预扣超时未支付再回补库存但这种方案复杂度高课设阶段用数据库扣减已经足够。扣减库存之后立即生成待支付订单并设置过期时间通常 15 到 30 分钟。这里我用了一个定时任务每分钟扫描一次待支付且创建时间超过 30 分钟的订单把订单状态改成已过期同时把扣掉的库存回补。回补库存必须也要加 where 条件避免订单已经支付了定时任务还在回补UPDATE ticket_type SET stock stock #{quantity} WHERE id #{ticketTypeId}支付环节课设项目通常不会真的接入支付平台。比较专业的做法是预留一个 payment 接口内部模拟支付成功回调把订单状态从待支付改成已支付并记录支付时间。对接真实第三方支付时只需要替换支付实现类核心订单逻辑不动。有能力的可以加一个简单的“钱包余额”支付用户在系统里预充值下单时扣余额余额不足则支付失败这样也是一个完整的资金闭环答辩时很好讲。3.3 电子票二维码从生成到线下核销的完整链路线上购票完成之后最关键的线下环节就是二维码核销。二维码不能直接存一个订单号因为容易被伪造。我的做法是生成一个签名串把订单号、票种、游玩日期拼接后用 HMAC-SHA256 生成签名再把订单号和签名拼起来作为二维码内容。生成核心代码大概是String content orderNo | sign; QrCodeUtil.generateQrCode(content, 300, 300);核销时的流程是这样扫码 - 解析内容 - 校验签名是否合法 - 查询订单 - 校验订单状态必须是已支付 - 更新订单状态为已核销 - 插入一条核销记录。这里最坑的就是重复扫码。游客第一次核销成功后不小心退出了页面再次扫码如果系统没有做幂等处理就会变成“重复核销”。更严重的是如果两个检票口同时扫同一张票只有一次能成功另一次必须被拒绝。幂等处理的正确姿势在更新订单状态时加上状态条件让数据库保证原子性int count orderMapper.updateStatusByCondition(orderNo, 1, 2); if (count 0) { throw new BusinessException(该订单已核销或状态异常); }where 条件同时限定“当前状态必须是已支付”和“目标状态是已核销”这样即使并发请求同时进来也只有一个查询能更新成功。而且核销记录表要唯一索引用 order_id 做唯一约束从数据层面再加一道防线。3.4 动物档案与科普答题让游客不只是“看一眼”动物档案模块是内容差异化所在。我在 animal 表里放了分类、栖息地、习性描述、图片 URL 等字段后台可以维护前台展示卡片。这个模块本身不复杂但有一个扩展点值得做动物喂养记录。饲养员可以每天记录喂食时间、食物、备注游客在动物详情页能看到这些记录这就从静态展示变成了动态运营。科普答题模块我采用的方式是每只动物关联几个题目游客进入动物详情页后可以参与答题答对得积分积分累计可以兑换虚拟勋章。答题记录表关联用户和题目避免同一个人反复刷同一道题。这个模块的好处是能带来用户粘性而且在数据统计里可以看到“最热门动物”和“答题正确率”让系统不只是卖票工具。答题接口要注意一个细节题目答案不能直接返回给前端。我提供的接口只返回题目和选项前端提交用户答案后由后端判断对错并返回得分。如果你把正确答案放在接口返回体里游客用浏览器 DevTools 一抓包就能全部答对这个设计就失真了。3.5 管理端数据统计几张聚合报表看园区经营情况统计模块是管理后台的收尾重点。我用三个报表撑起整个驾驶舱门票销售趋势、客流核销统计、热门动物 TOP10。门票销售趋势本质上就是按日期分组求和SELECT DATE(pay_time) AS day, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM ticket_order WHERE status IN (1, 2, 4) GROUP BY DATE(pay_time) ORDER BY day客流核销统计则要关联核销记录表按小时或按日统计核销人数用来评估园区高峰时段。热门动物 TOP10 可以通过统计 animal_feed_record 的访问次数或者答题记录数量得到我实际上是在动物表里加了一个 view_count 字段游客点击详情页时异步 1简单有效。前端用 ECharts 展示折线图和柱状图管理端首页放四个指标卡今日订单量、今日销售额、今日核销人数、在园人数估算。这个“在园人数”可以由今日核销总人数减去已离园标记来近似如果没有离园数据直接显示今日累计入园人次也是一种合理的业务口径需要在页面注明。4. 踩坑实录代码走查和部署过程中最常见的问题4.1 票务超卖乐观锁并不万能我刚拿到这套源码时第一件事就是看库存扣减。结果发现很多初版项目写的是TicketType ticketType ticketTypeMapper.selectById(ticketTypeId); if (ticketType.getStock() quantity) { ticketType.setStock(ticketType.getStock() - quantity); ticketTypeMapper.updateById(ticketType); }这个写法在单线程测试时很正常但用 JMeter 或者直接写个多线程脚本一压超卖马上就出现了。两个线程同时读到 stock1都满足条件都执行了扣减最终 stock 变成 -1。我在前面已经给出了正确写法核心就一句话把“检查库存”和“扣减库存”合并成一条原子 SQL。这不是什么高深技巧就是数据库的 compare-and-set。如果用了 MyBatis-Plus 的乐观锁插件也可以给实体加 Version 注解但还是要提醒乐观锁解决的是“更新丢失”不是“超卖”真正防超卖靠的是 where stock quantity 这个条件。4.2 核销接口的幂等重复扫码不能重复入园上线前测试时我让同事拿同一张票连续扫码两次第一次成功第二次直接返回 500。为啥因为核销记录表没有做唯一约束代码里又是先查询订单状态再更新两个请求同时进来都能查到“已支付”然后都去更新状态其中一次更新成功另一次更新时状态已经变成已核销但代码里没有对这个结果做判断直接抛了异常。排查以后我做了两层修复。第一层是在业务代码里把状态条件带上更新影响行数为 0 时抛出明确的业务异常“该票已使用请勿重复扫码”第二层是在核销记录表给 order_id 加唯一索引即使并发穿透到数据库层也只会有一条记录插入成功。这两层一加重复核销的问题彻底解决。修复完我才意识到核销接口这个场景非常适合作为面试题提问它考察的就是并发下的数据一致性意识。4.3 LocalDateTime 序列化之后少了 8 小时这是前后端分离项目中反复出现的经典问题。后端用的是 LocalDateTime默认序列化格式是数组或者带 T 的 ISO 格式前端解析很容易出问题更麻烦的是时区。数据库连接串我没写 serverTimezoneAsia/Shanghai 之前从库里查出来的时间直接少了 8 小时导致订单过期时间计算错乱明明刚创建的订单定时任务一看已经超时直接关单了。解决方式有两处。第一JDBC 连接串必须显式指定时区jdbc:mysql://localhost:3306/zoo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二在 application.yml 里统一配置 JSON 序列化格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai另外实体类里的时间字段统一用 LocalDateTime不要有的用 Date 有的用 LocalDateTime混用会让排序和比较变得很痛苦。这是我在代码走查时发现的高频问题。4.4 文件上传本地能传服务器上就 500动物图片和用户头像都涉及文件上传。本地开发时我直接把文件写到项目根目录下的 upload 文件夹一切正常。部署到 Linux 服务器后同样的代码直接 500。原因很简单项目 jar 包运行时的相对路径和本地不一样而且每次重新部署jar 包内的临时目录会被清空上传的头像隔天就没了。正确做法是把上传目录配置成绝对路径放到 application.yml 里custom: upload-path: /data/zoo/uploadController 里从配置读取路径然后用 FileOutputStream 写文件再把可访问的 URL 存到数据库。为了让图片能通过浏览器访问我用 Nginx 把 /upload/ 路径映射到磁盘目录这样前端拿到的图片链接就是https://域名/upload/xxx.jpg。千万不要把上传的文件放在 classes 目录里那是新手最容易踩的坑。4.5 权限拦截放行静态资源一个粗心导致登录页白屏配置拦截器时我发现登录页和静态资源全部被拦截了页面白屏控制台一堆 401。排查后确认是拦截器配置写得太宽excludePathPatterns(/login, /register)但没有把静态资源路径排除掉。前端资源如果是单独部署不受后端拦截器影响如果是用 Thymeleaf 或把前端打包到后端一起部署就必须显式放行 /static/、/css/、/js/、/images/、/favicon.ico 等路径。这个坑很小但能浪费你半小时在搭建环境时留意一下就好。5. 从源码 61317 到真正部署环境准备与启动步骤5.1 本地跑起来需要的环境清单拿到源码 61317 之后先别急着打开 IDEA。先把环境对齐能省掉 90% 的启动报错。我建议的环境组合是软件版本建议说明JDK1.8 或 11与 pom.xml 里 java.version 对应Maven3.6IDEA 内置即可MySQL5.7 或 8.0utf8mb4 字符集Redis5.x 及以上如果代码里用到缓存或 token 黑名单Node.js14如果前端工程是 Vue数据库初始化一般会提供 sql 脚本。我建议手工执行脚本然后打开 application.yml 确认数据库名称、用户名、密码是否匹配。最常见的启动失败就是 root 密码不一致、数据库没建、Redis 没启动。5.2 配置文件的坑数据源、Redis、文件路径application.yml 是启动前必须逐个核对的配置文件。我需要重点检查三块。第一块是数据源spring: datasource: url: jdbc:mysql://localhost:3306/zoo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456第二块是 Redis 连接如果本机 Redis 默认端口 6379 但没有密码配置里就留空不要随手填一个密码。第三块是自定义文件上传路径我刚才提过改成你本地真实的目录比如D:/zoo/upload或/data/zoo/upload。还要确认 MyBatis-Plus 的日志输出级别开发阶段建议开启 SQL 日志logging: level: com.zoo.manage.mapper: debug这样能在控制台直接看到执行 SQL排查数据库问题非常方便。5.3 Linux 生产部署jar 包 守护进程 Nginx本地跑通之后部署到 Linux 服务器也很直接。先在项目根目录执行mvn clean package -DskipTests构建成功后在 target 目录下得到 xxx.jar。我习惯把 jar 放到 /data/zoo 目录然后用 systemd 写一个守护进程确保服务宕机后自动拉起[Unit] DescriptionZoo System Afternetwork.target [Service] Userroot ExecStart/usr/bin/java -Xms512m -Xmx512m -jar /data/zoo/zoo-manage.jar SuccessExitStatus143 Restartalways RestartSec10 [Install] WantedBymulti-user.target前端工程构建后生成的 dist 目录放到 Nginx 的 web 目录下然后反向代理后端接口server { listen 80; server_name your.domain.com; location / { root /data/zoo/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /data/zoo/upload/; } }这里有一个安全细节生产环境一定要给后台管理端加访问限制最简单的方式是改默认端口、限制内网访问或者至少把默认密码改掉。很多课设项目部署后裸奔在公网后台密码还是 admin/123456这是非常危险的。你把这个注意事项写进 README既体现安全意识也是加分项。5.4 拿到源码之后先别急着跑最后给拿到源码 61317 的朋友一个建议下载代码后先花一个小时通读项目结构不要一上来就点运行。先看数据库脚本理清表关系再看 controller 层的接口清单了解系统有哪些功能然后看 service 里订单和核销部分的实现理解状态流转。如果你能画出一张“购票 - 支付 - 核销”的时序图说明你已经把这个项目吃透了。接着再去运行遇到问题也更容易判断是环境问题还是代码问题。运行起来以后重点测这几组场景正常购票支付核销购票后不支付等超时关单同一订单重复核销库存只剩 1 张时并发下单管理员修改票种价格后游客端是否同步。这些场景测试完你对这个系统的理解会远超单纯“跑起来”的人。我个人在这个项目上最大的收获其实是“状态设计”。订单状态、核销状态、用户状态每个字段背后都对应一条业务规则。很多初学者写代码只关注功能点通没通忽略了状态边界结果就是测试时这漏一个那漏一个。如果你正在用这套源码做毕设建议把状态机画在文档里答辩的时候直接展示这张图比念代码有说服力得多。还有一个小技巧把常见的异常提示统一封装成业务异常类Controller 里就不需要到处写 try-catch代码会干净很多。这个项目后续的扩展空间也很大比如接入真实支付、做园区热力图、增加会员年卡、对接小程序推送都是顺手就能加的方向。