Spring Boot+Vue景区管理系统:库存扣减与订单核销的并发实战
简介基于Web的景区管理系统是一套采用SpringBoot与Vue技术栈的完整前后端分离项目主要面向Java方向毕业设计、课程作业及二次开发学习。系统覆盖用户权限、景点信息、门票销售、在线预订、数据统计与日志管理等模块有助于理解典型管理类Web系统的设计思路。压缩包约16.08MB内含628个文件以213个Java文件与165个Vue文件为核心辅以xml/yml配置、SQL脚本、doc文档及构建后的静态资源目录结构清晰便于定位前后端代码。目前已有80人参与学习适合需要快速搭建演示项目或借鉴业务逻辑的开发者参考。通过源码可掌握SpringBoot接口开发、Vue页面交互、数据库表设计及系统权限控制等关键技能。1. 为什么景区还在用“对讲机 Excel”管门票每到旺季景区售票窗口排长队、OTA 渠道超卖、闸机核销数据对不上后台还在用 Excel 登记订单和退票。所谓“基于 web 的景区管理系统”说白了就是把订票、入园、退款、报表这些核心业务从本地单据搬到浏览器里让游客在小程序/H5 上预约管理员在后台管库存和对账。适合三类人来照着做做毕业设计的学生、接外包的单人开发者、以及要给中小景区做信息化改造的传统软件团队。这个系统的关键不在“能登录、能增删改查”而在库存扣减和订单核销的一致性。游客点了“支付成功”门票数必须真实少一张闸机扫了码订单状态必须立刻变成“已使用”。我最早那版就因为在“超卖”上踩了坑旺季一天退了几十单。下面按需求拆解、技术选型、核心模块、避坑和验证五步走落地可直接部署。2. 先拆业务流程再写第一行代码四类用户和六个状态2.1 需求边界不做会员商城只做“预约-核销-对账”闭环很多刚入行的开发者拿到“景区管理系统”就铺开做十几个模块——积分商城、社区论坛、员工考勤结果两三个月交付不了。真实景区的核心诉求只有四句话游客能查到余票并预约窗口/OTA/小程序渠道不超卖入园时验票员能快速核销财务要能按日对账、查退票率。我一般会把用户分成四类游客小程序端、售票员窗口 PC、验票员手持终端/闸机、管理员后台。权限用三个角色就够了游客、售票员、管理员验票员只是售票员角色在移动端的变体。需求分析阶段最要紧的是和景区确认一句话“同一个订单的票允不允许一张一张核销”如果允许订单表就要拆出“订单主表 票明细表”如果不允许核销逻辑能省一半。可惜绝大多数景区旺季都允许分批入园所以表结构必须按明细设计。另外嵌入式接口要考虑闸机或者手持机是通过 HTTP 调用我们接口的不是登录后台去点按钮。这个在系统设计时要单独留出/api/v1/verify接口验票员扫码后手机上只显示“有效/无效”不做完整后台 UI。2.2 六张核心表订单主表、票明细、景区、场次、支付流水、操作日志我习惯在写接口前先把 DDL 定下来至少省掉后面三次返工。以下是最小可用集合实际项目可以再加“公告表”和“分销商表”但别一上来就把表铺到二十张。-- 景区表 create table scenic ( id bigint primary key auto_increment, name varchar(64) not null comment 景区名称, capacity int not null comment 单日最大承载量, current_price decimal(10,2) not null default 0 comment 成人票挂牌价, status tinyint not null default 1 comment 1上架 0下架, create_time datetime not null default current_timestamp ) engineInnoDB default charsetutf8mb4; -- 场次表建议用日期作为场次维度 create table session ( id bigint primary key auto_increment, scenic_id bigint not null, biz_date date not null comment 游玩日期, total_cnt int not null comment 场次总库存, sold_cnt int not null default 0 comment 已售库存, status tinyint not null default 1 comment 1可售 0停售, version int not null default 0 comment 乐观锁版本号, unique key uk_scenic_date (scenic_id, biz_date) ) engineInnoDB default charsetutf8mb4; -- 订单主表 create table order ( id bigint primary key auto_increment, order_no varchar(32) not null comment 业务订单号, tourist_id bigint not null comment 游客ID, scenic_id bigint not null, session_id bigint not null, total_amount decimal(10,2) not null, pay_status tinyint not null default 0 comment 0待支付 1已支付 2已退款, order_status tinyint not null default 0 comment 0待使用 1部分核销 2全部核销 3已取消, expire_time datetime not null comment 支付超时时间, create_time datetime not null default current_timestamp, unique key uk_order_no (order_no) ) engineInnoDB default charsetutf8mb4; -- 票明细表一张订单多张票 create table ticket ( id bigint primary key auto_increment, order_id bigint not null, ticket_no varchar(32) not null comment 票号对应二维码内容, tourist_name varchar(32) not null comment 每张票的游客姓名, id_card_no varchar(18) not null comment 身份证号核销时校验, verify_status tinyint not null default 0 comment 0未核销 1已核销, verify_time datetime null, verify_user varchar(32) null comment 核销员账号, unique key uk_ticket_no (ticket_no) ) engineInnoDB default charsetutf8mb4;这里有两个参数值得解释。session.version是乐观锁字段扣库存时要用where version ?来防超卖而不是先 select 再 update。pay_status和order_status要分开因为“支付成功但未使用”和“已退款的订单还挂在待使用列表里”是两种完全不同的业务含义混在一起会让后台对账很难受。DDL 定完设计文档里的画图其实可以不画了表结构本身就是需求文档。你只需要再加一张支付流水表和一张操作日志表就能覆盖对账和审计需求。2.3 状态机定了接口自然就出来了我见过很多团队接口写得乱是因为状态没有画清楚。核心状态机只有三条链待支付 → 已支付 → 部分核销/全部核销待支付 → 已取消已支付 → 已退款。这三条链对应了 6 个核心接口创建订单、支付回调、取消订单、扫码核销、退款、查询余票。写接口命名时我不建议叫addOrder、updateOrder这种而是按业务动作命名POST /api/v1/orders创建POST /api/v1/orders/{orderNo}/pay支付POST /api/v1/verify核销。这样前后端联调时看到 URL 就知道在做什么避免“这是改状态还是改金额”的歧义。3. 用 Spring Boot 3 Vue 3 在本地跑通最小工程3.1 技术选型为什么不选 SSM 和 JSP这可能是你在搜索引擎里搜“基于web的景区管理系统”时最常看到的组合JSP Servlet MySQL。但今天再做新项目我建议直接用前后端分离后端 Spring Boot 3.x MyBatis-Plus前端 Vue 3 Element Plus数据库 MySQL 8缓存用 Redis。理由不是“旧技术不能用”而是两个现实问题一是 JSP 渲染的页面在移动端适配非常痛苦景区管理员和验票员大量使用手机浏览器访问二是你很难找到愿意维护 JSP 的老开发外包和实习生基本都是 Vue 技术栈。Spring Boot 3 和 Vue 3 的热搜度说明这是当前企业级 web 开发的默认组合H3C 网络设备、物联网 ESP32 嵌入式页面这些旁支先不管核心系统用这套最稳妥。3.2 创建工程一条命令拉起后端骨架用 IDEA 2024 创建 web 项目时可以直接用 Spring Initializr 把依赖勾好但我更推荐命令行因为可复制、可验证curl -G https://start.spring.io/starter.tgz \ -d dependenciesweb,validation,mysql,redis,mybatis \ -d baseDirscenic-server \ -d javaVersion17 \ -d artifactIdscenic-server \ -d namescenic-server | tar -xzvf -这段命令会生成一个标准 Maven 工程核心依赖只有五个spring-boot-starter-web提供 HTTP 接口能力、validation参数校验、mysql-connector-j数据库驱动、spring-boot-starter-data-redis缓存与分布式锁、mybatis-plus-spring-boot3-starterORM。注意没有勾选 security因为景区系统的权限并不复杂用拦截器加注解就能控制引入 Spring Security 反而会让新手在过滤器链上卡住两三天。生成后改application.yml里的数据源和 Redis 配置即可。第一件事是确认spring.datasource.druid或者 HikariCP 的连接池参数我习惯把maximum-pool-size设为 20minimum-idle设为 5。太小旺季会排队太大本地开发会拖垮电脑。3.3 前后端分离后的目录结构哪些包必须分开scenic-server/ ├── src/main/java/com/example/scenic/ │ ├── controller/ # 只做参数接收和路由不写业务 │ ├── service/ # 事务边界、业务规则、分布式锁都在这里 │ ├── mapper/ # MyBatis-Plus 的数据访问层 │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求/响应对象禁止把 entity 直接返回前端 │ └── common/ # 统一响应体、异常处理、工具类 scenic-web/ ├── src/ │ ├── api/ # axios 请求封装 │ ├── views/ # 页面级组件 │ ├── router/ # 路由和守卫控制登录跳转 │ └── store/ # Pinia 状态后端把这个目录结构定死了有个很划算的约定controller 里不写 if else业务判断全下沉到 service。这个习惯能让你在旺季排查“为什么订单支付成功但票没减”时少翻一半文件。前端工程用 Vite 创建npm create vuelatest一路回车即可。联调阶段最大的坑是代理在vite.config.js里配server.proxy把/api转发到localhost:8080不要在前端代码里写死http://localhost:8080。否则部署到服务器后你要改几十个文件。4. 实名预约与库存扣减事务和锁的三种写法对比4.1 最直接的写法先查后扣但并发一高就超卖“查询剩余票数大于 0 就执行 insert 订单然后 update 扣减”这是第一次做这个系统的人最自然写出来的代码也是超卖问题最容易出现的写法。两个用户同时查到余票为 1都去 insert 订单都去 update 库存结果卖出去两张票库存变成 -1。Transactional public Order createOrder(CreateOrderRequest req) { Session session sessionMapper.selectById(req.getSessionId()); if (session.getSoldCnt() session.getTotalCnt()) { throw new BizException(余票不足); } // 危险两个线程同时通过这里库存就超卖了 int updated sessionMapper.increaseSoldCnt(req.getSessionId()); if (updated 0) { throw new BizException(余票不足); } // 后续创建订单和票明细 }这里的increaseSoldCnt如果写成update session set sold_cnt sold_cnt 1 where id ?在并发下实际是有条件竞争的两个事务都读到sold_cnt99各自加 1最后变成 101。表面上看“更新了 1 行”不影响但数值已经被覆盖丢了。4.2 正确做法行锁 幂等号不让超卖有半点机会我踩坑后换成了悲观锁方案虽然牺牲一点并发但景区单日票务的并发量峰值几千笔完全扛得住。-- 在事务里先锁住这条场次记录其他线程只能等待 select * from session where id #{sessionId} for update;锁住之后后面再查余票、判断、更新都是串行执行。配合订单表的uk_order_no唯一索引同一个用户重复提交时第二次会被数据库挡掉代码里捕获 DuplicateKeyException 转成“请勿重复提交”即可。public Order createOrder(CreateOrderRequest req) { // 1. 生成业务订单号包含日期和随机序列 String orderNo SC LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) RandomStringUtils.randomNumeric(8); // 2. 事务内锁库存行防超卖 Session session sessionMapper.selectForUpdate(req.getSessionId()); if (session.getSoldCnt() req.getTicketCount() session.getTotalCnt()) { throw new BizException(余票不足); } sessionMapper.increaseSoldCnt(req.getSessionId(), req.getTicketCount()); // 3. 创建订单主表和票明细 }注意锁的顺序先锁库存行、再插入订单。如果反着来两个线程可能死锁一个先插订单再锁库存另一个先锁库存再插订单互相等对方释放。这是数据库事务里最常见的死锁原因我在本地复现过很多次。4.3 支付回调里的幂等处理别让双花导致票数虚增在线支付接入微信或支付宝后回调接口会被支付平台调用两次以上网络重试。如果回调处理不幂等游客支付一次系统给加了两张票库存就崩了。解决办法是支付流水表加唯一索引create table pay_flow ( id bigint primary key auto_increment, order_no varchar(32) not null, out_trade_no varchar(64) not null comment 支付平台交易号, amount decimal(10,2) not null, status tinyint not null default 0 comment 0处理中 1成功 2失败, unique key uk_out_trade_no (out_trade_no) ) engineInnoDB default charsetutf8mb4;回调逻辑先insert pay_flow如果唯一键冲突说明这条回调已经处理过直接返回成功。确认 catch 到 DuplicateKeyException 后再查询订单状态已支付则不再重复更新。这套流程做下来支付环节基本不会出幺蛾子。5. 避坑清单五个让景区系统翻车的常见问题5.1 库存扣减成功订单创建失败事务为什么没回滚Transactional默认只在 RuntimeException 时回滚。如果你在 service 里 try-catch 吞掉了异常或者抛出的是检查异常如 Exception事务不会回滚结果是sold_cnt加了订单记录却没有。解决自定义BizException继承 RuntimeExceptionController 层用RestControllerAdvice统一捕获。业务判断失败一律抛 BizException别返回 false 或 null。5.2 二维码内容直接用订单号被游客截图转发无限核销如果把订单号直接当核销凭证一个订单买了 3 张票截图发给 3 个人闸机扫一次放一个最后 3 个人都进去了订单状态还只核销一次。解决每一张票生成独立的ticket_no用 UUID 或者“日期随机数”拼接。核销时后端查 ticket 表的verify_status已核销直接返回“该票已使用”。同时校验系统时间与场次日期的关系非游玩日期拒绝核销。5.3 游客身份证号明文入库等保测评过不了很多景区系统要过等保二级。身份证号在数据库里不能明文存Java 端用 AES 加密入库查询时再解密。前端展示时脱敏只显示前 3 位和后 4 位。AES 密钥不要写在代码里放到环境变量ID_CARD_AES_KEY部署时由运维注入。5.4 Redis 缓存余票数缓存和数据库不一致为了减轻数据库压力把余票数缓存到 Redis结果支付后缓存没更新游客看到有票实际没票。解决方案是两选一不引入缓存直接查数据库或者写一个CacheEvict在支付成功后主动删缓存而不是等过期。缓存更新和数据库更新必须是同一个事务边界否则必然不一致。5.5 部署到服务器后前端刷新页面 404Vue Router 默认用 history 模式刷新/order/detail时 Nginx 找不到这个路径。解决在 Nginx 配置中加入try_files $uri $uri/ /index.html;。我第一次部署时忘了这条游客刷新订单页直接白屏当时后台日志没有任何报错排查了很久才发现是静态服务器路由问题。6. 验证这套系统值不值得上线接口压测与全链路脚本上线前我至少会做三类验证。第一类是接口级校验用 Postman 或 Apifox 跑 8 个用例正常预约、余票不足、重复提交、支付回调重复通知、核销成功、重复核销、退款后库存回补、超时取消订单。不要相信“代码能跑就行”把“超时订单释放库存”这个场景单独测因为定时任务漏配是最常见的隐性 Bug。第二类是并发压测。用 JMeter 模拟 200 个线程同时抢同一场次的 100 张票断言返回成功的订单数必须等于 100不能出现 101 个成功。这个测试跑完for update锁和唯一索引有没有生效一目了然。第三类是端到端脚本用 Playwright 模拟真实用户H5 选票 → 支付 → 查订单 → 验票员扫码核销 → 后台看报表。这一步能提前发现前后端字段对不上的问题比如前端传ticketCount字符串后端 Integer 接收直接 400。我做这类系统有个习惯上线前把 MyBatis 的 SQL 日志全打开跑一遍完整用例人工确认没有“1N 查询”和慢 SQL。景区系统数据量不大但如果报表接口把订单明细和票明细分页循环查一样会卡死。另外所有涉及金额的字段用 BigDecimal别用 double这是财务对账的底线。希望帮到你。本文还有配套的精品资源点击获取