☰
景区管理系统从Demo到上线:权限与票务并发设计实战
2026/10/10 6:26:17 网站建设 项目流程

简介:这是一套面向高校计算机专业学生与Java Web开发初学者的景区管理系统实战项目,可作为毕业设计、课程作业或SpringBoot+Vue全栈练手参考。系统围绕景区业务展开,涵盖用户注册登录与角色权限管理、景点信息与分类维护、门票发布定价与在线销售、游客在线预订与旅游攻略、举报反馈、销售统计与用户行为分析,以及日志管理和系统参数配置等模块,功能链路较为完整。资源包共628个文件,以213个Java后端源码、165个Vue前端组件为主,另含XML配置、SQL建表脚本、图片素材及CSS、JS等静态资源,压缩包约16.08MB,前后端分离结构清晰,便于按模块阅读与二次开发。目前已有80人学习下载。通过该资源可快速理解景区管理系统的整体架构、接口划分与数据库设计,对照源码梳理权限控制、订单生成、报表统计等实现思路,适合需要完整项目案例支撑论文或课程实践的同学参考借鉴。

1. 景区管理系统从 Demo 到上线:为什么 90% 的团队卡在权限和票务并发上

很多开发者第一次接到「基于 Web 的景区管理系统」这个需求时,脑子里浮现的是一套 CRUD:景区信息增删改查、门票下单、订单列表、后台登录。真动手做两周,页面跑通了,一放到真实场景就翻车——售票窗口三个人同时点「出票」,库存扣成负数;检票口网络抖一下,同一张票被核销两次;财务想看当天分渠道收入,发现订单表里连「渠道」字段都没设计。这类系统的难点从来不在页面好不好看,而在权限模型能不能撑住多角色、票务库存能不能扛住并发、订单状态机能不能自洽。

这篇文章面向的是要真正交付一套景区管理系统的后端和全栈开发者,也适合正在做课程设计但想做出「能讲清楚设计取舍」版本的同学。我会按「需求拆解 → 数据模型 → 权限与票务核心 → 并发与对账 → 避坑 → 进阶验证」的顺序,把一套可复现的最小可用方案讲透。技术栈我选 Spring Boot + MyBatis-Plus + MySQL + Redis,前端 Vue3 + Element Plus,这是国内中小型景区信息化项目里最常见、招人最好招、运维成本最低的组合。你换成 Django 或 Express 思路一样,关键是那几个设计决策。

需要先明确边界:景区管理系统通常包含景区资源管理(景点、路线、设施)、票务管理(票种、价格策略、库存、订单、核销)、游客服务(预约、导览、投诉)、运营分析(客流、收入、渠道)四大块。一篇文章不可能全铺开,我把重心压在票务和权限这两块——它们决定了系统能不能上线,其余模块都是围绕它们长出来的。

2. 需求拆解与数据模型:先把票种、库存、订单三张表的关系理清

2.1 景区管理系统的四类角色与权限边界

动手写代码前,先把角色定死,否则后面权限表会反复改。典型景区系统有四类角色:系统管理员(管账号、管配置)、景区运营(管景点、票种、价格、活动)、售票员/检票员(只操作订单和核销)、财务/管理者(只看报表,不能改数据)。这四类角色的权限边界不是简单的「菜单可见性」,而是数据行级和操作级的双重约束。

常见做法是用 RBAC(基于角色的访问控制)打底,再叠加数据权限。RBAC 管「能不能访问这个接口」,数据权限管「能看哪些行」。比如售票员只能看自己窗口产生的订单,运营能看全景区订单但不能改财务状态。我一般会设计五张表:sys_user、sys_role、sys_permission、sys_user_role、sys_role_permission。权限点用「模块:操作」的字符串编码,比如ticket:create、order:refund、report:view,这样前端按钮级控制和后端接口注解能共用一套编码。

-- 角色表:区分内置角色和自定义角色 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE COMMENT '角色编码,如 ADMIN/OPERATOR/CHECKER', role_name VARCHAR(64) NOT NULL, data_scope TINYINT NOT NULL DEFAULT 3 COMMENT '1全部 2本景区 3本人', builtin TINYINT NOT NULL DEFAULT 0 COMMENT '1内置不可删', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 权限表:权限点用冒号分隔的编码 CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL UNIQUE COMMENT '如 ticket:create', perm_name VARCHAR(64) NOT NULL, perm_type TINYINT NOT NULL COMMENT '1菜单 2按钮 3接口', parent_id BIGINT DEFAULT 0 );

data_scope这个字段是血泪经验:一开始不做,等运营提「售票员能看到别人窗口订单不合理」时再补,就要动所有查询。数据权限的落地方式我推荐在 MyBatis 拦截器里统一拼 SQL 条件,而不是每个 Service 手写if。拦截器读取当前登录用户的角色data_scope,自动在WHERE后追加create_by = #{userId}或scenic_id = #{scenicId}。这样业务代码干净,权限规则集中一处,改起来不慌。

2.2 票种、库存、订单三张核心表怎么设计

票务模型是这套系统的地基。很多人把「票种」和「库存」混在一张表里,结果遇到「成人票平日 80、周末 120、节假日 150」这种价格策略就崩了。正确拆法是三层:票种(ticket_type)定义「卖什么」,价格日历(ticket_price)定义「哪天卖多少钱」,库存(ticket_stock)定义「每天剩多少张」。

-- 票种:定义票的基本属性 CREATE TABLE ticket_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL COMMENT '所属景区', type_name VARCHAR(64) NOT NULL COMMENT '成人票/学生票/联票', base_price DECIMAL(10,2) NOT NULL, valid_days INT NOT NULL DEFAULT 1 COMMENT '有效天数', need_realname TINYINT NOT NULL DEFAULT 0 COMMENT '是否实名', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架' ); -- 价格日历:按日期覆盖基础价 CREATE TABLE ticket_price ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_type_id BIGINT NOT NULL, price_date DATE NOT NULL, price DECIMAL(10,2) NOT NULL, UNIQUE KEY uk_type_date (ticket_type_id, price_date) ); -- 库存:按票种+日期维度 CREATE TABLE ticket_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_type_id BIGINT NOT NULL, stock_date DATE NOT NULL, total_stock INT NOT NULL, sold_stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', UNIQUE KEY uk_type_date (ticket_type_id, stock_date) );

订单表的关键是状态机字段和幂等字段。状态我一般定六个:待支付、已支付、已核销、已退款、已取消、已过期。order_no用「日期 + 渠道码 + 雪花 ID」生成,天然带渠道信息,财务对账时不用再关联。idempotent_key由前端下单时生成 UUID 传上来,后端做唯一索引,防止用户连点两次产生两笔订单。

CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, scenic_id BIGINT NOT NULL, ticket_type_id BIGINT NOT NULL, visit_date DATE NOT NULL, quantity INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已核销 3已退款 4已取消 5已过期', channel VARCHAR(32) NOT NULL DEFAULT 'WINDOW' COMMENT 'WINDOW/OTA/WECHAT', idempotent_key VARCHAR(64) NOT NULL, buyer_name VARCHAR(64), buyer_phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_idem (idempotent_key), KEY idx_scenic_date (scenic_id, visit_date), KEY idx_status (status) );

三张表的关系是:下单时先查ticket_price拿当天价格,再对ticket_stock做扣减,最后写ticket_order。核销时只改订单状态并记录核销时间,不回滚库存——因为库存是「售出」维度,退款才回滚。这个区分想清楚,后面并发和退款逻辑才不会乱。

2.3 用 Flyway 管理建表脚本,别再用 Navicat 手动同步

团队协作里最常见的翻车是「我本地表结构和你不一样」。解决办法是把所有 DDL 放进版本管理,用 Flyway 或 Liquibase 自动执行。我习惯在src/main/resources/db/migration下按V1__init.sql、V2__add_channel.sql命名,应用启动时自动比对执行。

# application.yml 关键配置 spring: flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: true # 已有库首次接入时打基线 validate-on-migrate: true # 校验脚本是否被篡改

baseline-on-migrate是给已经手工建过表的老库用的,第一次接入时 Flyway 会记录当前版本而不重复执行历史脚本。validate-on-migrate会校验已执行脚本的 checksum,谁偷偷改了历史脚本启动就报错,这个约束能省掉大量「线上表结构和代码对不上」的排查时间。注意生产环境不要开clean,那个会清库。

3. 权限与票务核心实现:从登录鉴权到下单扣库存的完整链路

3.1 JWT 登录与接口级权限校验

登录鉴权我用 Spring Security + JWT。用户登录成功后签发 token,token 里放userId、roleCode、dataScope,不放权限点列表——权限点太多会让 token 膨胀,而且改权限后旧 token 不失效。权限点每次请求从 Redis 缓存里取,缓存 key 是perm:role:{roleCode},角色权限变更时主动删缓存。

// JWT 生成:只放身份和角色,不放权限明细 public String createToken(LoginUser user) { Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getUserId()); claims.put("roleCode", user.getRoleCode()); claims.put("dataScope", user.getDataScope()); return Jwts.builder() .setClaims(claims) .setSubject(String.valueOf(user.getUserId())) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7200_000L)) // 2小时 .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

接口级校验用自定义注解@RequiresPerm("ticket:create")配合 AOP 切面。切面里从 Redis 取当前角色的权限集合,判断是否包含目标编码。这样加权限不用改配置文件,运营在后台勾选角色权限后,删掉对应 Redis key 即可生效。

@Aspect @Component public class PermAspect { @Around("@annotation(requiresPerm)") public Object check(ProceedingJoinPoint pjp, RequiresPerm requiresPerm) throws Throwable { String permCode = requiresPerm.value(); Long userId = SecurityUtils.getUserId(); String roleCode = SecurityUtils.getRoleCode(); // 从缓存取角色权限集合,缓存未命中回源数据库 Set<String> perms = permCache.get(roleCode); if (perms == null || !perms.contains(permCode)) { throw new BizException(403, "无权限:" + permCode); } return pjp.proceed(); } }

参数说明:permCache.get内部用@Cacheable或手写 Redis 模板都行,TTL 建议 30 分钟,配合主动删除。SecurityUtils从SecurityContextHolder里取当前用户,这个上下文由 JWT 过滤器在请求进入时填充。注意切面顺序要排在事务切面之前,否则无权限请求也会开事务。

3.2 下单扣库存:乐观锁 + Redis 预扣的双层方案

库存扣减是票务系统最容易出事的地方。单机环境用数据库乐观锁就够,但景区旺季 QPS 上来后,大量请求打到同一行ticket_stock,行锁排队会让响应时间飙升。我的方案是双层:Redis 做预扣挡流量,数据库做最终一致。

public OrderVO createOrder(CreateOrderDTO dto) { // 1. 幂等校验:同一 idempotentKey 直接返回已有订单 TicketOrder exist = orderMapper.selectByIdemKey(dto.getIdempotentKey()); if (exist != null) return toVO(exist); // 2. Redis 预扣:Lua 脚本保证原子性 String stockKey = "stock:" + dto.getTicketTypeId() + ":" + dto.getVisitDate(); Long remain = redisTemplate.execute(stockLua, Collections.singletonList(stockKey), String.valueOf(dto.getQuantity())); if (remain == null || remain < 0) { throw new BizException("库存不足"); } try { // 3. 数据库乐观锁扣减 int rows = stockMapper.deductStock( dto.getTicketTypeId(), dto.getVisitDate(), dto.getQuantity()); if (rows == 0) { // 回滚 Redis 预扣 redisTemplate.opsForValue().increment(stockKey, dto.getQuantity()); throw new BizException("库存扣减失败,请重试"); } // 4. 写订单 TicketOrder order = buildOrder(dto); orderMapper.insert(order); return toVO(order); } catch (Exception e) { redisTemplate.opsForValue().increment(stockKey, dto.getQuantity()); throw e; } }

Lua 脚本内容:先GET当前值,判断是否大于等于购买数量,是则DECRBY并返回剩余,否则返回 -1。这段脚本在 Redis 单线程里执行,天然原子,不会被并发穿插。

-- 数据库乐观锁扣减,version 字段防并发 UPDATE ticket_stock SET sold_stock = sold_stock + #{qty}, version = version + 1 WHERE ticket_type_id = #{typeId} AND stock_date = #{date} AND total_stock - sold_stock >= #{qty}

注意WHERE里的total_stock - sold_stock >= #{qty}是真正的库存判断,version字段其实可以省——因为这条 UPDATE 本身在 InnoDB 行锁下就是原子的。我保留 version 是为了排查问题时能看到扣减次数。Redis 预扣的 key 要设过期时间,比如到visit_date次日凌晨过期,避免冷数据占内存。

3.3 核销接口的幂等设计:同一张票不能被核销两次

检票口是并发和网络问题的高发区。游客二维码被扫两次、检票员手抖点两下、网络超时重试,都会导致重复核销。核销接口必须幂等,且要能区分「已核销」和「核销失败」。

@Transactional public CheckResult verify(String orderNo, Long checkerId) { // 1. 查询订单,加行锁 TicketOrder order = orderMapper.selectForUpdate(orderNo); if (order == null) return CheckResult.fail("订单不存在"); if (order.getStatus() == OrderStatus.VERIFIED) { // 已核销,返回成功但标记重复 return CheckResult.duplicate(order.getVerifyTime()); } if (order.getStatus() != OrderStatus.PAID) { return CheckResult.fail("订单状态不可核销:" + order.getStatus()); } // 2. 校验参观日期 if (!order.getVisitDate().equals(LocalDate.now())) { return CheckResult.fail("非当日票"); } // 3. 更新状态 orderMapper.updateStatus(orderNo, OrderStatus.VERIFIED, checkerId, new Date()); return CheckResult.success(); }

selectForUpdate加行锁是关键,它保证同一订单的核销请求串行执行。第二个请求进来时读到状态已是VERIFIED,直接返回重复标记而不是报错——这样检票员看到的是「已核销」提示,不会误以为票有问题。核销记录我建议单独建verify_log表,记录每次扫码的时间、设备、结果,出纠纷时能查。

4. 并发、对账与报表:上线后真正让你加班的三件事

4.1 超卖排查:从日志到库存快照的定位路径

上线后如果出现超卖,排查顺序是:先看订单表有没有超出total_stock的记录,再看 Redis 预扣 key 和数据库sold_stock是否一致,最后看有没有绕过预扣直接调数据库扣减的入口。我一般会加一个定时对账任务,每 5 分钟比对 Redis 剩余和数据库剩余,差值超过阈值就告警。

@Scheduled(cron = "0 */5 * * * ?") public void reconcileStock() { List<TicketStock> stocks = stockMapper.selectTodayStocks(); for (TicketStock s : stocks) { String key = "stock:" + s.getTicketTypeId() + ":" + s.getStockDate(); String redisVal = redisTemplate.opsForValue().get(key); if (redisVal == null) continue; // 未预热的 key 跳过 int dbRemain = s.getTotalStock() - s.getSoldStock(); int redisRemain = Integer.parseInt(redisVal); if (Math.abs(dbRemain - redisRemain) > 5) { log.error("库存不一致 type={} date={} db={} redis={}", s.getTicketTypeId(), s.getStockDate(), dbRemain, redisRemain); // 以数据库为准,重置 Redis redisTemplate.opsForValue().set(key, String.valueOf(dbRemain)); } } }

阈值设 5 是因为预扣和落库之间有短暂窗口,正常波动会有小差值。超过 5 说明有异常路径。重置 Redis 时以数据库为准,因为数据库是最终真相。这个任务本身要加分布式锁,多实例部署时只能有一个在执行。

4.2 订单状态机与退款回滚库存

退款不是简单把状态改成「已退款」,还要回滚库存、可能涉及部分退款、要记录退款流水。状态流转我用枚举 + 校验表控制,非法流转直接拒绝。

当前状态允许流转到触发操作
待支付已支付 / 已取消 / 已过期支付回调 / 用户取消 / 超时任务
已支付已核销 / 已退款检票 / 退款申请
已核销已退款特殊审批退款
已退款无终态
已取消无终态

退款回滚库存要同时改数据库和 Redis。数据库sold_stock减回去,Redis 预扣 key 加回去。注意如果订单是「已核销」后退款,库存回滚要谨慎——票已经用过了,回滚库存等于凭空多出一张票。我一般对已核销退款只退钱不回滚库存,并在退款记录里标记stock_rolled_back = 0。

4.3 渠道对账报表:按日聚合的 SQL 怎么写才不慢

财务要的报表通常是「某天各渠道各票种的销量和收入」。直接对ticket_order做GROUP BY在订单量上百万后会明显变慢。做法是建一张日汇总表daily_sales_summary,每天凌晨跑批聚合前一天数据,报表查汇总表。

-- 每日聚合,写入汇总表 INSERT INTO daily_sales_summary (scenic_id, stat_date, channel, ticket_type_id, order_count, ticket_count, total_amount) SELECT scenic_id, visit_date, channel, ticket_type_id, COUNT(*) AS order_count, SUM(quantity) AS ticket_count, SUM(total_amount) AS total_amount FROM ticket_order WHERE visit_date = DATE_SUB(CURDATE(), INTERVAL 1 DAY) AND status IN (1, 2) -- 已支付和已核销计入收入 GROUP BY scenic_id, visit_date, channel, ticket_type_id ON DUPLICATE KEY UPDATE order_count = VALUES(order_count), ticket_count = VALUES(ticket_count), total_amount = VALUES(total_amount);

ON DUPLICATE KEY UPDATE让这个脚本可以重复跑,补数据时不用先删。汇总表加UNIQUE KEY (scenic_id, stat_date, channel, ticket_type_id)。报表查询直接走汇总表,响应能压到毫秒级。注意退款订单要从汇总里扣掉,我一般单独跑一个退款聚合再相减,而不是在同一个 SQL 里用条件 SUM,那样索引利用不好。

5. 避坑指南:景区管理系统上线前后最容易踩的五个坑

坑一:库存扣了但订单没写成功,库存凭空消失。现象是 Redis 和数据库库存都比实际少。原因是扣库存和写订单不在同一事务,或者写订单抛异常后没回滚 Redis。解决是把「数据库扣库存 + 写订单」放同一@Transactional,Redis 预扣放在事务外并在 catch 里显式回滚。更稳的做法是引入本地消息表,扣减成功后发一条消息,由消费者补偿。

坑二:JWT token 里塞了权限列表,改权限后旧 token 还能用。现象是运营取消了某角色权限,但该角色用户不重新登录依然能操作。原因是权限判断读的是 token 里的旧数据。解决是 token 只放身份,权限每次从 Redis 取,角色权限变更时删缓存。代价是每次请求多一次 Redis 查询,但换来权限实时生效。

坑三:日期字段用java.util.Date导致跨天判断出错。现象是晚上 11 点买的次日票,核销时提示「非当日票」。原因是Date带时区偏移,和数据库DATE类型比较时被转成了前一天。解决是参观日期统一用LocalDate,数据库用DATE,前后端传输用yyyy-MM-dd字符串,中间不做任何时区转换。

坑四:核销接口没加锁,同一张票被两个检票口同时核销。现象是核销日志里同一订单出现两条成功记录。原因是两个请求同时读到「已支付」状态,都执行了更新。解决是select ... for update加行锁,或者用UPDATE ... WHERE status = 1的条件更新,靠影响行数判断是否抢到。

坑五:报表直接查订单表,旺季把数据库拖垮。现象是每天上午财务一出报表,下单接口就变慢。原因是报表的GROUP BY全表扫描和下单请求抢资源。解决是建日汇总表,报表走汇总,并且把跑批任务放在凌晨低峰期,跑批时限制并发。

6. 进阶验证:用压测和影子库确认系统真的扛得住

功能跑通只是及格线,能不能上线要看压测数据。我一般用 JMeter 或 k6 对下单接口做阶梯加压,观察三个指标:TPS 拐点、库存一致性、订单状态正确率。压测脚本模拟 200 个并发用户,每人下 10 单,总库存设 1000,跑完检查sold_stock是否正好等于实际成功订单的票数总和。

# k6 压测脚本片段:模拟并发下单 import http from 'k6/http'; import { check } from 'k6'; export const options = { stages: [ { duration: '30s', target: 50 }, // 30秒爬到50并发 { duration: '60s', target: 200 }, // 1分钟到200并发 { duration: '30s', target: 0 }, // 降下来 ], }; export default function () { const payload = JSON.stringify({ ticketTypeId: 1, visitDate: '2025-06-01', quantity: 1, idempotentKey: `${__VU}-${__ITER}-${Date.now()}`, }); const res = http.post('http://localhost:8080/api/order/create', payload, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TOKEN}` }, }); check(res, { 'status is 200 or 400': (r) => r.status === 200 || r.status === 400 }); }

跑完后用 SQL 核对:SELECT SUM(quantity) FROM ticket_order WHERE status IN (1,2)应该等于SELECT sold_stock FROM ticket_stock。如果不等,说明有订单写成功但库存没扣,或者反过来。这个核对脚本我建议固化成 CI 的一部分,每次改库存逻辑都跑一遍。

影子库验证是另一个手段:把生产库的读流量复制一份到影子库,在影子库上跑新版本的库存逻辑,对比两边结果。这个成本较高,适合大版本升级前做。小团队用压测 + 对账脚本就够了。

最后说个我自己的习惯:任何涉及库存和金额的接口,我都会在本地写一个「并发 100 线程各下 1 单」的单元测试,跑 10 遍,确认库存扣减和订单数完全对得上才提交。这个测试写起来不到 50 行,但帮我挡掉过至少三次上线事故。系统能不能扛住,不靠感觉,靠这种笨办法一遍遍验证。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询