Spring Boot旅游订票系统开发实战:从表设计到部署上线全解析 去年有段时间我在帮人看一个“Springboot旅游景点订票系统”的课程设计项目。说实话这类项目的代码在网上并不少见但真正要把它从压缩包变成一台能演示、能讲解、后续还能继续加功能的系统远不是“跑起来就行”那么简单。旅游订票和普通的增删改查有一个很大的不同它涉及到库存、支付、核销三个环节任何一个环节断了整套系统在现实中都用不了。所以我打算从这次完整开发调试部署过程出发把需求拆解、表结构设计、业务代码、打包上线这条线完完整整捋一遍给正在做类似系统的朋友一点参考。1. 这个项目的来龙去脉一个旅游订票系统到底要解决什么问题1.1 传统售票方式的核心痛点为什么要把痛点放在最前面因为痛点直接决定了系统的数据结构。传统售票方式通常是在景区门口排队买票或者通过电话预留售票员手工登记游客信息和人数。这里有三个绕不开的问题第一余票数量完全不透明游客只能到了现场才知道还有没有票第二景区无法提前掌握预约情况热门日期容易超售冷门日期又大量空置第三人工核验纸质票效率低无法快速判断重复使用和伪造门票的风险。所以这个订票系统表面上做的是“订票”本质上做的是“余票管理”和“履约闭环”。明白了这一点就不会把精力都花在页面美化上而是会认真思考订单状态应该怎么流转、库存怎么扣减才算安全。1.2 功能边界哪些必须做哪些可以不做动手前要先画一张功能地图把系统分成前台和后台两条线。前台面向游客必须包含注册登录、景点列表、景点详情、票型选择、余票查询、提交订单、支付、查看订单、获取电子凭证。后台面向景区管理人员必须包含景点信息管理、票型与价格管理、每日余票库存设置、订单查询、支付记录、核销验票。至于景区公告、短信通知、会员等级、营销活动、多语言这些需求就看项目周期和定位了。对于课程设计或者内部演示系统先把“用户能下单、管理员能核销”这个闭环做扎实远比堆功能重要。把边界划清楚之后后续开发能省掉大量反复改表的时间也方便别人接手时快速理解系统能力。1.3 为什么选用Spring Boot而不是老一套框架选型这个问题我几乎每次都会被问到。如果只是做一个几百行代码的Demo用什么都不重要但如果要做成能演示、能扩展、能部署到服务器上的系统技术栈的成熟度和开发效率就非常关键。我这次选择Spring Boot而不是更早期的SSH、SSM最重要的原因是约定优于配置带来了极低的启动成本。用户只需要引入spring-boot-starter-web一个main方法就能启动内嵌的Web容器不需要外置Tomcat不需要繁琐的web.xml和一堆XML配置。再配合MyBatis-Plus做数据访问几乎不用写复杂的基础CRUD使用JWT做登录态比Session更契合前后端分离的架构Hutool工具包可以搞定日期、加密、随机数这些琐碎逻辑。这套组合在中小型项目里已经很成熟遇到问题很容易找到参考对单人开发或者小组协作都比较友好。关于前端形态这个系统我建议按前后端分离来做后端只提供JSON接口前端使用Vue或者普通HTML页面均可。这样做的理由很直接如果使用Thymeleaf这类服务端渲染订单状态更新时需要不断刷新整页体验很差分离后前端可以单独部署在Nginx上后端的Spring Boot应用只面对/api请求也方便维护。后面要加微信小程序端只需要复用后端接口不需要改Java代码。2. 开发环境与工程结构从零搭起一套能跑的骨架很多项目最后卡在环境不一致的问题上明明代码一样在这台电脑能跑换一台就报错。下面是我实际使用的环境组合并附上理由方便直接照做。2.1 环境版本的选择我使用的版本组合是JDK 1.8、Maven 3.6.3、MySQL 5.78.0也可以但连接驱动和时区配置略有区别、IDEA 2021以后的版本。Spring Boot版本选了2.7.18没有直接上3.x。原因很实际Spring Boot 3.x要求JDK 17很多教学资料和网友分享的代码都是基于javax.servlet的写法换成3.x之后变成jakarta.servlet对没有经验的开发者来说光是处理包名迁移就是个不小的坑。而2.7.x停在javax时代网上资料最多兼容性也最稳。MyBatis-Plus选3.5.x版本它对Spring Boot 2.x支持得很好分页插件、条件构造器都能直接使用。下面是一个可以直接用的核心依赖片段。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.19.4/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.26/version /dependency /dependencies这里补充一个很多人容易忽略的点MySQL连接驱动不要用老旧的com.mysql.jdbc.Driver要使用com.mysql.cj.jdbc.Driver驱动包版本尽量与数据库实例大版本接近。不然可能出现连接成功但中文乱码、时区错误等诡异问题。如果使用MySQL 8.0连接URL中还需要额外加allowPublicKeyRetrievaltrue否则某些安全连接场景会报“Public Key Retrieval is not allowed”错误。2.2 工程目录分层为什么这么分包结构建议这样规划不要所有类都堆在一个包下面。下面这个分层是我比较推荐的。src/main/java └── com.example.ticket ├── TicketApplication.java ├── common // 公共返回结果、异常处理、常量 ├── config // 配置类如WebMvcConfig、MybatisPlusConfig ├── controller // 接收前端请求 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端传入参数对象 ├── vo // 返回给前端的视图对象 └── utils // JWT、日期等工具类为什么这么分核心原因是让每一层的职责变得明确。Controller只负责参数校验和结果包装业务判断全部放到ServiceMapper只做数据访问Entity对应数据库表DTO和VO避免把数据库实体直接暴露给前端。这样做的好处有两个一是排查问题时能快速定位比如订单金额不对直接去Service里找计算逻辑而不是在Controller里翻二是当接口改造时不会牵一发而动全身。比如管理员给订单增加一个“导出Excel”的功能只需要在Service里增加处理方法Controller和Mapper的改动非常小。2.3 初始化数据库与application.yml配置要点先把数据库创建好新建数据库时注意字符集。我在MySQL中执行的语句是CREATE DATABASE ticket_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集必须用utf8mb4不要只写utf8否则用户输入生僻字或者表情符号时后台会报字符集不支持的错误。接着把第三部分的建表SQL按顺序执行再插入一个初始管理员账号和几个测试景点数据。配置文件通常拆成application.yml、application-dev.yml、application-prod.yml三份开发环境连本地数据库生产环境连远程数据库启动时通过spring.profiles.active切换。下面是dev环境的核心配置。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/ticket_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里特别提醒两件事。第一serverTimezoneAsia/Shanghai千万不能省否则数据库连接时因为时区问题报错的情况非常常见。第二数据库密码写在配置文件里只是本地开发方便部署到服务器后建议改用环境变量方式注入避免把密钥提交到代码仓库。具体做法在部署部分会详细说。另外逻辑删除字段我用了deleted它可以避免用户或景点被物理删除后导致历史订单查不到关联信息但要注意查询时所有Mapper默认会带deleted0条件如果将来要统计已删除数据需要额外写自定义SQL。3. 数据库设计表结构怎么定才能少踩坑这个项目能不能稳定跑起来表结构占了六成决定因素。我见过太多人把订单表设计成只有订单号和总金额两个字段结果后面要加退票、加核销记录时根本无从下手。下面讲一下我最终使用的核心表结构以及每张表为什么需要这些字段。3.1 用户、景点、票型三张基础表用户表不需要太复杂但手机号和密码字段一定要预留足够长度。密码前期不推荐用明文至少用BCrypt或者MD5加盐项目演示时可以省去很多安全审查方面的尴尬。景点表的核心字段包括封面图、开放时间、简介、上下架状态。票型表用于表达同一个景点下的成人票、儿童票、学生票等不同价格和库存。CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL, password varchar(128) NOT NULL, phone varchar(20) DEFAULT NULL, nickname varchar(64) DEFAULT NULL, create_time datetime NOT NULL, deleted tinyint NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE scenic_spot ( id bigint NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL, address varchar(256) DEFAULT NULL, intro text, open_time varchar(64) DEFAULT NULL, cover_url varchar(512) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE ticket_type ( id bigint NOT NULL AUTO_INCREMENT, scenic_id bigint NOT NULL, type_name varchar(64) NOT NULL, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, version int NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_scenic_id (scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意我在ticket_type上加了version字段这不是业务字段是后续做乐观锁更新库存用的。很多初学者直接在scenic_spot上放库存这样做的问题是同一个景点有多个票型时根本没法管理所以必须要拆出票型表。还有一个细节用户名加了唯一索引这样注册接口就可以在数据库层面拦截重复用户不需要每次先查一次再插入也减少了并发注册时重复数据出现的风险。3.2 订单、支付流水、核销记录三张业务表订单表记录每次购买行为支付流水表记录支付通道的每笔流水核销表记录电子凭证什么时候被使用。三张表的关系是一笔订单对应一条支付流水核销记录则是对应一次凭证使用。具体建表SQL如下。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint NOT NULL, ticket_type_id bigint NOT NULL, quantity int NOT NULL, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0, pay_status tinyint NOT NULL DEFAULT 0, verify_code varchar(16) DEFAULT NULL, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE payment_record ( id bigint NOT NULL AUTO_INCREMENT, payment_no varchar(32) NOT NULL, order_no varchar(32) NOT NULL, channel varchar(16) DEFAULT mock, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0, callback_time datetime DEFAULT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_payment_no (payment_no), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE verify_record ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, verify_code varchar(16) NOT NULL, verify_time datetime NOT NULL, operator_id bigint DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_verify_code (verify_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我把订单号和支付流水号都设计成独立业务编号而不是直接暴露自增主键主要为了两个目的一是避免别人通过订单ID猜测业务量二是给后续对接第三方支付平台留好字段支付平台要求的商户订单号通常是独立的前端订单号。订单状态和支付状态我拆开是因为“待支付”和“已取消”之间支付回调可能延迟到达拆开处理更符合真实场景。verify_code放在订单表里并给核销记录表也单独存一份是为了线下核销时能快速定位真实项目如果要做套票拆分更建议拆卡券表但在这个规模下订单表加字段已经够了。3.3 容易被忽略的索引、字段与状态设计踩过几次坑后我总结了几个容易忽略的细节。第一状态字段建议用tinyint对应关系写在常量类或枚举里不要用中文“已支付”“未支付”直接入库否则后续做统计聚合很痛苦。第二金额字段一律用decimal(10,2)不要图省事用float或double浮点运算的精度问题在支付场景下是不能接受的。第三order_no、payment_no、verify_code加唯一索引既是性能优化也是业务兜底。第四订单查询必然带创建时间所以create_time字段上要建索引否则订单量一大后台列表就会越来越慢。下面是我推荐的状态定义可以直接参考。字段取值含义orders.status0待支付orders.status1已支付orders.status2已核销orders.status3已取消orders.status4已退款orders.pay_status0未支付orders.pay_status1支付成功orders.pay_status2已退款如果以后接真实支付平台退款还需要在payment_record中记录退款流水号字段可以再加refund_no和refund_time。前期不要过度设计但这几个关键状态位必须预留。状态字段宁可多留两个值也不要后面需要新增状态时去改表结构因为改表结构在外网运营阶段成本非常高。4. 核心业务逻辑从选票到出票的关键实现数据库设计好了接下来是业务代码。旅游订票系统最核心的逻辑不是景点列表而是“查询余票、扣库存、生成订单、支付、核销”这条链路。下面按照链路顺序讲实现思路重点说清楚每一步为什么这样写。4.1 余票查询联表而不是多次查询前台页面需要展示景点名称、票型名称、价格、剩余数量这些数据分散在scenic_spot和ticket_type两张表里。如果不用联表容易写出N1查询先查景点列表再循环查每个景点的票型虽然功能能跑但性能和代码可维护性都不好。我用的是MyBatis-Plus条件构造器加VO对象返回核心还是SQL的JOIN。实际查询可以简化为这样。SELECT t.id AS ticketId, s.name AS scenicName, t.type_name AS typeName, t.price, t.stock FROM ticket_type t LEFT JOIN scenic_spot s ON t.scenic_id s.id WHERE s.status 1 AND t.status 1 ORDER BY s.id, t.price;在Service层只需要把查询结果映射到TicketVO不需要来回访问数据库。对于这个规模的项目全表联查也没有问题但最好还是带上分页订单列表、景点列表、票型列表这些管理端接口都需要分页避免后面数据量上来再返工。分页用MyBatis-Plus的Page对象配置一个分页插件代码量很小。4.2 库存扣减与下单一条Update保住底线库存扣减是整个系统最危险的地方不夸张地说这里写错了节假日一开放售票就会超卖。很多初学者容易写成“先查库存判断大于购买数量再减库存”这在单用户测试时完全正常但一旦有几个人同时下单两个请求都可能读到同一个库存快照最后累计扣减超过总库存系统就超卖了。正确解法是使用数据库的原子更新把“判断库存足够”和“扣减库存”放到同一条SQL里完成。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, Long ticketTypeId, Integer quantity) { // 1. 原子扣减库存 int rows ticketTypeMapper.reduceStock(ticketTypeId, quantity); if (rows 0) { throw new BusinessException(库存不足或票型已下架); } // 2. 生成订单 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTicketTypeId(ticketTypeId); order.setQuantity(quantity); order.setAmount(calculateAmount(ticketTypeId, quantity)); order.setStatus(OrderStatus.WAIT_PAY); order.setPayStatus(PayStatus.UNPAID); orderMapper.insert(order); // 3. 创建支付记录模拟或发起真实支付 paymentService.createPayment(orderNo, order.getAmount()); return OrderVO.from(order); }对应的Mapper方法是自定义SQLUpdate(UPDATE ticket_type SET stock stock - #{quantity}, version version 1 WHERE id #{ticketTypeId} AND stock #{quantity} AND status 1) int reduceStock(Param(ticketTypeId) Long ticketTypeId, Param(quantity) Integer quantity);这段代码的精髓是stock #{quantity}这个条件。如果库存不够这次更新影响行数为0Service里拿到返回值后会抛出异常事务回滚不会产生新订单。我第一次处理并发问题时也想过用Redis分布式锁后来想想对于这种单库单表的业务一条原子SQL在绝大多数场景下已经够了没必要为了“高并发”强行引入Redis增加部署和维护成本。真实高并发的抢票系统当然要更复杂但这个项目的定位是一套可用、能讲清楚原理的系统。4.3 支付回调与核销状态流转的闭环订单创建后是待支付状态支付完成后要更新订单状态并生成核销码。这里讲两个实现方式一个是本地演示用的模拟支付另一个是对接真实支付通道时的回调处理思路。模拟支付其实就是一个接口比如/api/payment/mock前端点击“立即支付”后会传入订单号后端校验金额和状态后直接标记支付成功并生成一个8位核销码。代码如下。Transactional(rollbackFor Exception.class) public void mockPay(String orderNo) { Order order orderMapper.selectByOrderNo(orderNo); if (order null || order.getStatus() ! OrderStatus.WAIT_PAY) { throw new BusinessException(订单不存在或状态不正确); } order.setStatus(OrderStatus.PAID); order.setPayStatus(PayStatus.SUCCESS); order.setPayTime(LocalDateTime.now()); order.setVerifyCode(RandomUtil.randomNumbers(8)); orderMapper.updateById(order); paymentRecordMapper.updateStatus(orderNo, PayStatus.SUCCESS, LocalDateTime.now()); }真正对接第三方支付平台时核心点在于回调接口必须做签名校验并且要用“支付平台通知中携带的金额”而不是直接信任本地数据库金额去更新订单。这样做能防止回调被篡改或重复通知导致金额不一致。模拟支付省掉签名校验是为了演示方便如果上线必须补上。核销流程也很简单后台输入核销码查询订单如果状态是已支付且核销码匹配就改为已核销同时写入一条验票记录。核销属于线下操作要防止验票员在弱网环境下重复点击提交所以核销记录表的verify_code加了唯一索引第二次提交会报重复直接返回“该凭证已被使用”。超时未支付的订单怎么处理我这里推荐一个简单方案某个定时任务定期扫描超过15分钟仍未支付的订单先把库存加回再把订单状态改成已取消。注意加回库存和取消订单也要放在同一个事务里并且要判断当前状态仍然是待支付否则已经支付的订单被定时任务误取消就麻烦了。还可以用延迟队列、Redis过期监听这些方案但就这个项目而言一个定时任务配合状态判断已经非常够用。5. 调试部署全流程本地跑完、打包、服务器部署很多人在本地把系统跑通之后就以为结束了实际部署阶段的问题往往更多。这个章节按我自己的操作顺序来写把容易掉进去的坑并列出来。5.1 本地启动最常见的三个报错本地启动是最先暴露问题的地方我整理了三类高频报错。报错信息原因解决办法java.sql.SQLException: The server time zone value...数据库连接时区未设置在JDBC URL后加serverTimezoneAsia/ShanghaiPort 8080 was already in use.端口被其他进程占用找到占用进程杀掉或者修改server.portInvalid bound statement (not found)Mapper接口和XML没有绑定检查MapperScan路径、XML的namespace是否正确时区问题出现概率最高。JDK 8之后如果MySQL连接串里不指定时区驱动会尝试读取系统时区一旦不一致就会抛异常解决方式就是规范URL的写法。端口占用在Windows下用netstat -ano | findstr 8080查到PID再释放在Linux下用lsof -i:8080。Mapper绑定问题则要检查MyBatis-Plus的配置和XML位置如果是纯注解SQL更需要确认MapperScan(com.example.ticket.mapper)和启动类没写错。还有一种情况是启动类的位置不对Spring Boot默认扫描启动类所在包以及子包如果启动类放错包Controller和Service都不会被加载。5.2 Maven打包与多环境配置本地跑通后打包用Maven就可以。我习惯先执行单元测试再打包但如果你的项目有测试环境配置问题可以先跳过测试。mvn clean package -DskipTests打包完成后会在target/目录下生成ticket-0.0.1-SNAPSHOT.jar。这个jar是内嵌Tomcat的可以直接用java -jar运行不需要再装Tomcat。需要特别注意生产环境不要直接使用application-dev.yml要指定生产配置。启动命令可以这样写。java -jar ticket-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod进一步可以把数据库密码等敏感信息从配置文件中抽到环境变量里例如启动前设置DB_PASSWORD然后在配置文件中写成${DB_PASSWORD}。这样代码仓库里不会出现明文密码。为了让服务宕机后能自动重启通常给这类Java服务写一个systemd配置。[Unit] DescriptionTicket System Afternetwork.target [Service] Userroot WorkingDirectory/opt/ticket EnvironmentFile/etc/ticket.env ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar /opt/ticket/ticket-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod Restartalways RestartSec10 [Install] WantedBymulti-user.target然后还需要在/etc/ticket.env里配置好SPRING_PROFILES_ACTIVEprod、DB_PASSWORDxxxx等变量。这样部署就从一个整天盯着终端窗口的活变成了一个受systemd托管的服务。执行完systemctl daemon-reload、systemctl start ticket之后服务就在后台稳定跑着了。5.3 部署后的冒烟验证清单服务启动完成后不能直接宣布结束至少要按下面的清单走一遍。访问健康检查或登录接口确认应用正常返回JSON而不是Whitelabel Error Page。用管理员账号新增一个景点和票型设置一个较小库存再用普通用户账号完成一次注册、登录、下单、支付的完整流程。把库存设置为0尝试再下单确认系统提示库存不足并且订单表没有插入新的脏数据。使用同一个核销码连续核销两次确认第二次返回“已核销”提示。检查日志目录确认没有堆栈报错并确认数据库连接池配置没有被耗尽。如果项目里有Nginx作为反向代理可以加一段简单配置把/api/请求转发到Spring Boot应用前端静态文件放在Nginx站点目录下。server { listen 80; server_name example.com; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里example.com只是占位域名实际部署时替换成自己的域名或服务器IP。还有一个容易掉进去的坑如果服务器上同时有防火墙记得放行端口否则外部访问不到。部署完成后建议把application-prod.yml中的日志文件路径配到独立目录比如/var/log/ticket/方便后续排障。6. 我在这类项目里总结的经验教训最后这部分不讲具体代码讲几个我认为比代码本身更重要的判断。遇到过很多次接手项目之后跑不起来、改不动、不敢动的情况大部分原因都可以归结到下面这几条。6.1 状态字段别用“魔法值”我见过不少项目订单状态直接用0、1、2散落在代码各个角落新接手的人看得一头雾水。更好的做法是单独定义一个OrderStatus常量类或枚举将状态含义沉淀在代码里。比如前面用到的OrderStatus.WAIT_PAY、OrderStatus.PAID在代码里看到名字就能明白业务含义。对于团队项目这不仅是代码规范问题更是业务理解问题。与其让每个成员都去数据库表备注里翻状态含义不如让状态定义成为代码里唯一的事实来源。我甚至建议在状态流转方法的前面写清楚“从什么状态可以到什么状态”这样将来加退票功能时直接对着状态机检查就行。6.2 并发扣库存必须用数据库原子操作这个问题我在前面提过这里再强调一遍。只要目标是真实可用的订票系统并发下的库存一致性就不能妥协。先查再改的写法在演示时看不出毛病遇到集中抢票就必然出事故。原子更新的本质是让数据库保证判断和扣减的原子性而不是在Java代码里做两步操作。我记得给项目演示时有人问“为什么不先加Redis锁再查库存”答案是这个项目根本没到那种并发量级一条带条件的UPDATE已经能保证不超卖加Redis反而复杂化了。做技术选型时永远要考虑当前阶段最合适的复杂度而不是把大厂方案硬套到小系统上。6.3 给维护者留下什么比代码更值钱课程设计、毕设或者企业内部小系统最后会被别人接手或者被老师检查。这时候一份简单的部署说明、接口列表、数据库初始账号比任何花哨的代码都更能体现项目的完整度。我一般会在项目根目录放一个README.md写清楚三件事如何初始化数据库、如何启动项目、默认管理员账号是什么。再在API层面加一套Swagger注解或者导出一份Markdown格式的接口文档后面的人上手会轻松很多。这是我踩了无数次“接手项目三天没跑起来”的坑之后养成的习惯。文档不需要长篇大论但至少要有接口路径、请求参数、响应结构、异常情况这四类信息。6.4 关于时间与金额处理的两个小细节额外再说两个容易被忽略的小细节。第一项目里所有时间字段建议统一使用LocalDateTime并且数据库字段也是datetime前端传参和返回值统一格式化为yyyy-MM-dd HH:mm:ss。有的项目用到java.util.Date在不同时区环境下显示会偏移8小时排查起来很浪费时间。第二金额计算建议把“元”为单位的分量计算改成更稳妥的方式数据库用decimal(10,2)Java里用到BigDecimal不要用double避免出现“0.1 0.2 0.30000000000000004”这种让景区财务抓狂的问题。如果只是教学项目这些细节可能不会被当场发现但一旦上线对账每个都会变成真正的坑。如果非要说还有什么心得那就是做这类系统的时候别急着写代码先把“谁在用、用几步、每一步会导致什么状态变化”想明白。订单状态机、库存扣减、支付回调这条链路想清楚了后面写代码多半是水到渠成的事。真正出问题的地方永远在你想得不够清楚的地方。