ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java旅游管理系统:Spring Boot 3 + Redis实现订单状态机与库存预扣

Java旅游管理系统:Spring Boot 3 + Redis实现订单状态机与库存预扣 简介一份基于JSP的旅游管理系统设计与实现文档面向具备Java Web基础的技术人员及高校毕业设计、课程设计参考。系统采用B/S架构前端使用JSP后台选用SqlServer2012数据库开发环境为MyEclipse8.5与Tomcat6.0功能覆盖旅游景点管理、旅游线路管理、在线预订、网站论坛与公告管理并区分管理员、会员两类用户平台突出权限控制与模块化设计。资源包内含1个docx文件大小约934KB为完整的设计与实现说明涵盖需求分析、系统设计、数据库结构及核心模块实现可帮助读者快速理解旅游管理系统整体架构与开发流程也可作为同类项目文档撰写的参考模板。目前已有67人学习浏览适合需要参考旅游信息化项目方案、学习B/S结构Java Web开发的读者。1. 基于 Java 的旅游管理系统核心不是 CRUD 而是订单状态旅游管理系统是 Java 后端项目里出镜率最高的一类景区信息、门票、酒店、线路、订单看起来就是标准增删改查。但真动手就会发现难度不在 CRUD而在「门票库存怎么扣」「订单状态怎么流转」「超时未支付怎么处理」这三个业务问题上。这篇文章按常见的 Spring Boot 3 MyBatis-Plus MySQL Redis 技术栈从数据库设计讲到订单状态机再给出一套能直接跑的最小实现和部署命令适合正在做课程设计、毕业设计或者要给小景区做线上售票后台的 Java 开发者参考。2. 技术栈与数据建模Spring Boot 3 MyBatis-Plus 的选型理由这套系统最常见的组合是 Spring Boot 3 MyBatis-Plus MySQL Redis。Spring Boot 3 要求 JDK 17这套组合也是当前 Java 后端成长路线里的标准配置java 面试八股文里常问的自动装配、starter 机制都能在项目里对上号。MyBatis-Plus 相比原生 MyBatis 少写大量 XML 映射分页插件、逻辑删除、字段填充开箱即用适合业务方法多但查询并不复杂的系统。Redis 用来做景区详情的缓存和下单时的库存预扣第 4 章会具体展开。2.1 登录鉴权选 JWT前后端分离才不别扭先看最小依赖清单pom.xml 里核心就这几项dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencymybatis-plus-spring-boot3-starter 是针对 Spring Boot 3 的专用包旧版 mybatis-plus-boot-starter 在 Boot 3 里会直接启动失败这个坑几乎每个第一次升级的人都会踩。jjwt 拆成了 api、impl、jackson 三个包impl 是运行期才需要的解析和生成时还要靠 jjwt-jackson 把 Claims 做 JSON 序列化只引 api 会在运行时报 ClassNotFoundException。登录态选 JWT 而不是 Session核心原因是前后端分离后接口不再依赖 Cookie。传统 Session 在集群部署时要解决会话共享要么粘滞会话要么把 Session 塞进 RedisJWT 把用户标识和过期时间写进 token后端完全不存状态接口层拿到 token 解析一下就完事。代价是 JWT 无法主动失效用户退出登录或者被封号token 在过期前依然能用所以要靠短过期时间兜底一般后台系统设 30 分钟比较合理。2.2 景区、订单、用户三张核心表怎么建库存不要直接写死在景区表里。旅游系统按日期售票同一个景区今天的票和下周的票剩余量完全不同库存必须落到「景区 日期」维度。用户表、景区表、每日库存表、订单表四张表是底线线路、酒店、评论都是后加的业务扩展。2.2.1 建表 SQL 与字段说明CREATE TABLE t_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL COMMENT BCrypt 密文, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE t_scenic ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, address varchar(200) DEFAULT NULL, description text, price decimal(10,2) NOT NULL DEFAULT 0.00, cover varchar(255) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景区表; CREATE TABLE t_scenic_stock ( id bigint NOT NULL AUTO_INCREMENT, scenic_id bigint NOT NULL, ticket_date date NOT NULL, total_stock int NOT NULL DEFAULT 0, sold_stock int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_scenic_date (scenic_id, ticket_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT每日库存表; CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint NOT NULL, scenic_id bigint NOT NULL, ticket_date date NOT NULL, quantity int NOT NULL DEFAULT 1, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已使用 3已取消 4超时关闭, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_create (user_id, create_time), KEY idx_status_create (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;sold_stock 表示已售数量剩余可售数用 total_stock - sold_stock 算。t_scenic_stock 的联合唯一键 uk_scenic_date 保证同一个景区同一天只有一行数据这是防止库存记录重复的兜底约束。订单表建了两个二级索引idx_user_create 支撑「我的订单」列表按时间倒序idx_status_create 专门给第 4 章的超时取消定时任务用避免任务每次全表扫。字符集统一 utf8mb4景区介绍里偶尔会有特殊字符utf8 会直接报错。2.3 字段设计里容易埋雷的三个点金额统一用 decimal(10,2)不要用 float 或 double。二进制浮点存 0.1 就是近似值java 面试题里常考 BigDecimal 和浮点精度问题落到数据库就是 decimal代码里对应 BigDecimal两边口径要一致。订单号用 varchar(32) 存代码生成时即使加了时间戳和随机数高并发下也可能撞唯一索引是最后一道防线插入报 DuplicateKeyException 时再换号重试。create_time 这类审计字段交给 DEFAULT CURRENT_TIMESTAMP不要由应用层传入否则漏传字段就会出现脏数据。提示version 乐观锁字段建议先留在 t_scenic_stock 表里虽然本文库存方案走 Redis 预扣但后续要做人工对账或补偿任务时version 能帮你实现安全的重试更新。3. 用户注册登录与 JWT 鉴权的最小实现鉴权是旅游管理系统的第一个入口也是面试里最常被追问的部分。很多课程设计项目用 MD5 加盐存密码这种做法在真实场景里已经不够用了。这一章从密码加密、登录接口、拦截器三个层面把最小实现写完整。3.1 BCrypt 密码加密而不是 MD5MD5 的问题在于计算速度太快配合彩虹表常见弱密码秒破加盐虽然能挡彩虹表但盐的生成和存储全靠自己写容易出错。BCrypt 把盐内嵌在密文里每次加密结果都不一样而且可以通过 cost 参数控制计算耗时暴力破解成本成倍上升。Spring Security 的 crypto 模块可以单独引入不需要把整个 Security 框架拉进来。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency3.2 登录接口与 JWT 工具类的代码注册和登录的 Service 层逻辑核心是 encode 和 matches 两个方法Service public class AuthService { private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(10); public void register(RegisterReq req) { Long count userMapper.selectCount( new LambdaQueryWrapperUser() .eq(User::getUsername, req.getUsername())); if (count 0) { throw new BizException(用户名已存在); } User user new User(); user.setUsername(req.getUsername()); user.setPassword(encoder.encode(req.getPassword())); userMapper.insert(user); } public String login(LoginReq req) { User user userMapper.selectOne( new LambdaQueryWrapperUser() .eq(User::getUsername, req.getUsername())); if (user null || !encoder.matches(req.getPassword(), user.getPassword())) { throw new BizException(用户名或密码错误); } return JwtUtil.createToken(user.getId(), user.getUsername()); } }BCryptPasswordEncoder 构造参数是 strength默认 10表示 2 的 10 次方轮迭代。这个值不是越大越好10 到 12 是常见区间调到 12 后单次登录验证大约几十毫秒对用户体验影响不大但明显拖慢批量撞库。selectCount 用来做注册查重依赖 t_user 表的 uk_username 唯一索引兜底两个请求同时注册同一个用户名时数据库会拒绝后插入的那个。JWT 工具类用 jjwt 0.11.5 的 APIpublic class JwtUtil { private static final byte[] KEY_BYTES travel-secret-key-please-change-0123456789.getBytes(StandardCharsets.UTF_8); private static final SecretKey KEY Keys.hmacShaKeyFor(KEY_BYTES); private static final long EXPIRE_MS 30 * 60 * 1000L; public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(uid, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MS)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }subject 放用户名uid 放进自定义 claimexpiration 设 30 分钟。HS256 要求密钥至少 32 字节短了会在生成 token 时直接抛 WeakKeyException。密钥在真实项目里要通过 Value 从配置文件读写死在代码里等于把签名公之于众。jjwt 的版本坑要单独说0.9.x 的解析写法是Jwts.parser().setSigningKey(key).parseClaimsJws(token)0.11.x 改成了 parserBuilder 链式调用网上老帖子代码直接粘过编译都过不了。Controller 层只负责参数接收和转发业务逻辑全部下沉到 ServiceRestController RequestMapping(/api/auth) public class AuthController { PostMapping(/login) public ResultString login(RequestBody LoginReq req) { return Result.ok(authService.login(req)); } }统一返回 Result 包装类成功和失败都走同一个结构前端拦截器判断 code 字段即可不要成功返回对象、失败直接抛页面异常前后端联调会很难受。3.3 拦截器配置与放行规则JWT 的校验放在 HandlerInterceptor 里每个请求进来先取 Authorization 头解析失败直接返回 401public class JwtInterceptor 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); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(uid)); return true; } catch (Exception e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\未登录或token过期\}); return false; } } }handler instanceof HandlerMethod的判断是为了放行静态资源和 CORS 预检请求这些请求不走 Controller 方法不需要校验。注册配置类时把游客可访问的接口显式排除Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /api/scenic/list, /api/scenic/detail/** ); } }放行规则按业务分两类登录注册必须放景区列表和详情是游客也能看的放。管理后台的接口建议单独走 /api/admin/** 路径由另一个拦截器校验角色不要和 C 端接口混在一个拦截器里不然每个方法都要写一遍权限判断。提示token 放在 Authorization 头而不是 URL 参数里避免出现在 Nginx 访问日志和浏览器历史中。4. 门票预订与订单状态机核心业务怎么才不写乱订单模块是整个系统最容易写乱的环节。同一个景区同一天的门票是热点数据直接把库存写在景区的 update 语句里也能防超卖但每次请求都打数据库高并发下 MySQL 行锁竞争很快成为瓶颈。常见做法是 Redis 先原子预扣数据库落单做兜底两个数据源之间没有事务所以回补逻辑必须跟上。4.1 下单接口Redis 预扣库存 数据库兜底下单接口分为三步Redis 扣减预占库存、数据库扣减真实库存、插入订单。注意 Redis 里的库存要在每日零点或景区上架时预热初始化用 setIfAbsent避免重复覆盖Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderRequest req) { String stockKey scenic:stock: req.getScenicId() : req.getTicketDate(); Long left redisTemplate.opsForValue().decrement(stockKey, req.getQuantity()); if (left null || left 0) { redisTemplate.opsForValue().increment(stockKey, req.getQuantity()); throw new BizException(当日门票已售罄); } try { int rows scenicStockMapper.deductStock( req.getScenicId(), req.getTicketDate(), req.getQuantity()); if (rows 0) { throw new BizException(库存不足请更换日期); } Order order buildOrder(req); orderMapper.insert(order); return toVO(order); } catch (Exception e) { redisTemplate.opsForValue().increment(stockKey, req.getQuantity()); throw e; } }对应的 Mapper 更新语句update iddeductStock UPDATE t_scenic_stock SET sold_stock sold_stock #{quantity} WHERE scenic_id #{scenicId} AND ticket_date #{ticketDate} AND sold_stock #{quantity} total_stock /updateRedis 的 decrement 是原子操作返回值小于 0 说明已经超卖要立刻回补并拒绝下单。数据库的 where 条件里带上sold_stock #{quantity} total_stock这是防止 Redis 和数据库数据不同步时的最后防线影响行数为 0 说明数据库侧库存已经不够。因为 Redis 操作和数据库事务不是一回事所以 catch 里必须回补 Redis否则会出现数据库下单失败但库存被扣走的情况。buildOrder 里生成订单号时把状态初始化为 0金额从景区表查出单价后乘以数量不要信任前端传过来的金额字段。4.2 订单状态机的流转定义订单状态不要用魔法数字散落在业务代码里定义成常量类或枚举每个状态只允许走固定的几条边状态值含义触发行为可流转到0待支付下单成功1 已支付、4 超时关闭1已支付支付回调2 已使用、3 已取消退款2已使用景区验票终态3已取消退款完成终态4超时关闭定时任务终态支付回调更新状态时SQL 必须带上当前的 status 条件int rows orderMapper.pay(order.getId(), order.getOrderNo());update idpay UPDATE t_order SET status 1, pay_time NOW() WHERE id #{id} AND order_no #{orderNo} AND status 0 /update这里的关键是status 0这个条件。支付平台可能会重复回调或者用户手快点了两次支付没有状态条件就会把已支付订单再更新一遍。影响行数 rows 为 1 说明是第一次支付成功为 0 说明订单状态已经不是待支付直接忽略重复请求。退款流程反向操作库存先把订单状态从 1 改成 3再回补 t_scenic_stock 的 sold_stock最后把 Redis 里的预扣库存加回来顺序不能反先改库存后改订单状态会导致取消失败时库存已经回去了。4.3 超时未支付订单的定时取消用户下单后 15 分钟不支付订单要自动关闭并把库存释放。用 Spring 的 Scheduled 每分钟扫一次Component public class OrderTimeoutTask { Scheduled(cron 0 */1 * * * ?) public void cancelExpiredOrders() { Date deadline new Date(System.currentTimeMillis() - 15 * 60 * 1000L); ListOrder orders orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, 0) .lt(Order::getCreateTime, deadline) .last(limit 500)); for (Order order : orders) { int rows orderMapper.cancel(order.getId(), order.getOrderNo()); if (rows 0) { scenicStockMapper.rollbackStock( order.getScenicId(), order.getTicketDate(), order.getQuantity()); String stockKey scenic:stock: order.getScenicId() : order.getTicketDate(); redisTemplate.opsForValue().increment(stockKey, order.getQuantity()); } } } }需要在启动类或配置类上加 EnableScheduling 开关。cron 表达式0 */1 * * * ?是每分钟的第 0 秒执行一次扫描间隔和订单超时时间要匹配15 分钟超时配 1 分钟扫描精度足够。limit 500 防止某次积压了大量超时订单一次处理太多把数据库拖垮。orderMapper.cancel 里同样要带status 0条件因为扫描和支付回调可能并发用户刚好在第 15 分钟支付成功取消语句就不能生效rows 为 0 时跳过库存回补。提示查询条件用 status create_time 联合索引也就是 t_order 表里的 idx_status_create否则每分钟一次全表扫描订单量上来后 CPU 会持续飙高。5. 部署验证与性能优化上线前的检查清单和常用命令5.1 打包部署的最小命令本地验证通过后服务器上的部署流程尽量收敛成两条命令mvn clean package -DskipTests nohup java -jar target/travel-0.0.1-SNAPSHOT.jar \ --spring.profiles.activeprod app.log 21 -DskipTests 跳过测试执行但保留测试编译想再快一点就换 -Dmaven.test.skiptrue。nohup 加 保证退出 ssh 后进程不挂日志写到 app.log启动失败第一件事就是 tail -f app.log 看堆栈。常见的翻车点有两个JAVA_HOME 环境变量配置不对导致 mvn 命令找不到 Java本机是 JDK 8 而 Spring Boot 3 编译直接报错。部署前先java -version和mvn -v两边对一遍版本一致再打包。5.2 接口压测与慢 SQL 定位景区列表这种读多写少的接口上线前用 ab 简单压一下ab -n 1000 -c 100 http://localhost:8080/api/scenic/list-n 是总请求数-c 是并发数。重点看 Failed requests 和 Requests per second 两个指标失败数不为 0 或吞吐量低于预期先查代码再查数据库。订单列表查询慢时用 EXPLAIN 看执行计划mysql EXPLAIN SELECT * FROM t_order WHERE user_id 1 ORDER BY create_time DESC;type 列出现 ALL 说明全表扫描回表确认 idx_user_create 索引是否建上。订单表是持续写入的表索引不是越多越好联合索引 (user_id, create_time) 能覆盖「查某人最近的订单」这个高频场景就够用。5.3 Redis 缓存热点景区的参数设置景区详情被反复读取适合 cache-aside 模式先查缓存再查库String key scenic:detail: id; String json redisTemplate.opsForValue().get(key); if (json null) { Scenic scenic scenicMapper.selectById(id); if (scenic ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(scenic), 15, TimeUnit.MINUTES); } }缓存参数的常用设置key / 参数取值说明scenic:detail:{id}TTL 15 分钟景区详情缓存过期后自动回源scenic:stock:{id}:{date}不设 TTL库存预扣键由下单和取消逻辑维护EXPIRE_MS30 分钟JWT 有效期管理端可减到 15 分钟管理员修改景区信息后要主动删除对应缓存 key否则用户要等 TTL 自然过期才能看到新数据。验证缓存是否生效可以看 TTL 的变化redis-cli TTL scenic:detail:1返回 -2 表示 key 不存在说明缓存没写进去或已被删除-1 表示 key 存在但没有过期时间正数就是剩余秒数。TTL 在正常递减说明缓存链路是通的。本文还有配套的精品资源点击获取
返回列表