
简介这是一套面向高校计算机专业学生与Java Web开发初学者的景区管理系统实战项目可作为毕业设计、课程作业或SpringBootVue全栈练手参考。系统围绕景区业务展开涵盖用户注册登录与角色权限管理、景点信息与分类维护、门票发布定价与在线销售、游客在线预订与旅游攻略、举报反馈、销售统计与用户行为分析以及日志管理和系统参数配置等模块功能链路较为完整。资源包共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。用户登录成功后签发 tokentoken 里放userId、roleCode、dataScope不放权限点列表——权限点太多会让 token 膨胀而且改权限后旧 token 不失效。权限点每次请求从 Redis 缓存里取缓存 key 是perm:role:{roleCode}角色权限变更时主动删缓存。// JWT 生成只放身份和角色不放权限明细 public String createToken(LoginUser user) { MapString, 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(); // 从缓存取角色权限集合缓存未命中回源数据库 SetString 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() { ListTicketStock 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。解决是把「数据库扣库存 写订单」放同一TransactionalRedis 预扣放在事务外并在 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 行但帮我挡掉过至少三次上线事故。系统能不能扛住不靠感觉靠这种笨办法一遍遍验证。希望帮到你。本文还有配套的精品资源点击获取