ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue旅游订票平台实战:从库存扣减到支付回调的完整设计

SpringBoot+Vue旅游订票平台实战:从库存扣减到支付回调的完整设计 今年年初接了一个旅游行业的项目用 SpringBoot Java 做后端、Vue 做前端的旅游线路景点展示与订票平台。项目不大但业务链路完整从线路展示、景点详情到下单支付、库存扣减、后台管理该有的模块全都有。做完最大的体会是这类“展示交易”双核心的项目技术难度不在某个单点而在于把展示效率和交易准确性同时兼顾好。这篇文章把这套系统的设计思路、关键实现和踩过的坑完整梳理一遍给准备做同类型平台的同学直接“抄作业”。1. 项目定位与需求拆解1.1 这个平台到底解决什么问题旅游平台的核心不是“能展示景点这么简单”而是要把一条线路从“看见”到“成交”整条链路跑通。拆解下来用户侧关注三件事信息全面、下单流畅、售后可控平台侧也关注三件事线路可管理、库存不超卖、订单可追溯。这套系统面对的核心场景是用户浏览旅游线路和景点介绍按需筛选线路比如按目的地、游玩天数、价格区间选定线路后选择出发日期和班次填写出行人信息并下单支付支付成功后在个人中心查看订单、申请退改。管理员在后台维护线路、景点、班次库存、价格策略和订单管理。所以我把系统拆成四个核心功能域内容展示线路/景点/图片/介绍、交易链路选班次/下订单/支付/出票、用户体系注册/登录/个人中心、管理后台线路维护/库存管理/订单处理。这样的划分也直接决定了数据库设计和技术方案。1.2 为什么前端非要选 Vue 做单页应用线路展示这类页面用户操作路径短但操作频次高搜索→列表→详情→下单全程不希望刷新页面。Vue 单页应用SPA在体验上有天然优势——路由切换不刷新、组件化开发效率高、状态管理Vuex/Pinia能跨页面保存用户选择的筛选条件。更重要的一点是前后端分离以后后端只聚焦在 SpringBoot 的接口服务上把 API 设计好就行。前端团队和后端团队可以并行开发视觉更新不影响接口逻辑接口升级不阻塞页面联调这种节奏在项目工期紧的时候尤其关键。当然 SPA 也有代价首屏加载比服务端渲染慢一点、SEO 不太好做但旅游平台的流量主要来自 APP 引流和已下载应用的再次访问SEO 优先级低Vue SPA 的优势完全覆盖了它的短板。2. 技术选型与架构设计2.1 后端 SpringBoot 3.x Java 17图的是什么后端选了 SpringBoot 3.x 配合 Java 17主要图三点。第一是启动和开发效率。SpringBoot 的自动配置极大减少了配置代码原来的 XML 配置文件在绝大多数场景下都不需要了一个main方法就能起服务。配合 MyBatis-Plus 的BaseMapper和ServiceImpl单表 CRUD 基本不用手写 SQL。第二是生态成熟。做旅游交易平台涉及接口鉴权Spring Security JWT、参数校验Bean Validation、数据缓存Redis Template、定时任务xxl-job 或 Scheduled、文件上传对象存储 SDK这些组件在 Spring 体系里都有非常成熟的整合方案踩坑成本远低于自研。第三是 Java 17 的新特性确实能用上。比如record定义 DTO、sealed class限定状态类型、text block编写复杂 SQL 或模板日志代码写起来清爽不少。Switch 表达式也避免了大量旧的 if-else 分支。2.2 前端 Vue 3 Composition API Element Plus前端选 Vue 3 是当前的新项目标配。组合式 APIComposition API让状态逻辑的复用变得很直接比如“线路筛选状态”和“用户登录状态”各自封装成一个useXxx函数组件里按需调用代码结构比 Options API 清晰很多。组件库选了 Element Plus表格、表单、弹窗、日期选择器这些后台管理页面最常用的组件都是现成的一套下来页面风格统一不需要前端单独设计。移动端适配方面Vite Rem 布局配合媒体查询在手机上也能正常浏览下单。另外一个细节是路由守卫。前端必须在每个受保护页面的路由上配置beforeEach判断本地是否存有 JWT没有就强制跳转登录页。这个虽然不能替代后端的真正的鉴权但能极大提升用户体验避免用户操作到一半才被接口 401 弹回登录页。2.3 数据库、缓存和中间件选型存储选 MySQL 8.xInnoDB 引擎事务和行级锁是这个项目最依赖的能力。缓存用 Redis 做两块事情一是热点线路的详情缓存二是库存预扣的原子操作。文件存储没有额外引入对象存储服务直接本地磁盘存储 Nginx 映射静态资源后续量大了再平滑迁移到云存储。消息中间件这个项目没有强依赖。订单量级还没到需要削峰填谷的程度支付回调直接用接口轮询补偿即订单支付超时后由定时任务关闭未支付订单并释放库存。只有在秒杀类场景才需要引入 MQ 来扛瞬时流量目前系统容量完全可以支撑贸然引入中间件反而增加运维复杂度。整个架构就是标准的单体应用Nginx前端静态资源 反向代理 API 前端Vue3 Vite Element Plus 后端SpringBoot 3.x MyBatis-Plus Spring Security JWT 存储MySQL 8.x Redis 6.x 本地文件存储这个架构的好处是简单可靠没引入分布式事务、微服务等复杂概念部署成本和故障排查成本都在可控范围内。等到订单量真正大了再按模块拆服务也不迟。2.4 API 设计规范前后端协作的基石前后端分离之后API 设计就是双方的“合同”。这个项目里我定了几个规约。统一返回结构{ code: 0, message: success, data: { ... } }code 0表示成功其他值表示业务错误。前端在 axios 拦截器里统一处理不成功就弹出 ElMessage 提示。URL 命名遵循资源语义/api/travel/line线路列表、/api/travel/line/{id}线路详情、/api/travel/order创建订单、/api/travel/order/{id}/pay发起支付。HTTP 方法也严格区分查询用 GET新增用 POST修改用 PUT删除用 DELETE。分页参数统一pageNum、pageSize作为查询参数后端返回{ total, records }结构。前端表格组件直接对接不需要每个接口单独定义分页格式。3. 数据库设计与核心模型3.1 六张核心表一张都不能少数据库设计直接决定后面业务逻辑好不好写。我按“人、货、单”三个维度来建模用户相关t_user账号、密码、昵称、手机号、状态内容相关t_destination景点/目的地、t_travel_line旅游线路、t_line_schedule班次/出发日期、t_schedule_stock班次库存交易相关t_order主订单、t_order_item订单明细、t_payment_record支付记录线路表t_travel_line是最核心的一张表字段包括线路名称、所属目的地、游玩天数、价格原价/现价、封面图、线路介绍、线路状态上下架。为了减少多表关联我把“线路标签”直接用一个 JSON 字符串字段存类似[海岛,亲子,含酒店]查询时用 LIKE 匹配。这种设计牺牲了一定的规范化但换来的是查询超快在整体字段大概率不会用作关系关联的情况下是划算的取舍。班次表t_line_schedule记录每条线路在哪天可出发属于线路表的子表。库存字段stock直接放在班次表里下单预扣时先检查剩余库存是否充足。3.2 库存扣减悲观锁还是 Redis 原子操作库存控制是整个订票系统最重要的技术点。最开始我用了数据库的乐观锁方案UPDATE t_schedule_stock SET stock stock - 1 WHERE schedule_id #{scheduleId} AND stock 0;这个 SQL 能保证不超卖但因为执行更新时会对行加锁并发高时后到的请求会排队阻塞。实测模拟 100 个用户同时抢同一个班次数据库连接池很快就出现等待接口 TPS 掉得很明显。后来改成了 Redis 预扣方案。下单前先通过 Lua 脚本原子扣减 Redis 中的库存扣减成功才落库订单支付超时或用户取消时回补库存每天定时把 Redis 库存持久化回 MySQL。这样做接口响应从平均 120ms 降到 20ms 左右并发能力提升了一个量级。具体实现后面下单流程里会细说。3.3 订单状态机设计不要让状态失控订单状态我用了严格的状态机不允许跳变待支付0→ 已支付1→ 已出行2 待支付0→ 已取消3 已支付1→ 申请退款4→ 已退款5 待支付0→ 支付超时关闭6每个状态变更都记录在t_order_log表中。后期如果用户发起投诉或需要财务对账可以根据日志完整还原订单的生命周期。状态机的核心价值在于“每个转换都有明确的前置条件和触发动作”不是前端传一个状态过来就盲目更新。比如“已支付”这个状态只有支付回调成功才能置位绝对不能由前端请求直接修改。4. 核心模块实操拆解4.1 线路展示模块让用户“逛”得顺心线路展示分三个层级条线路列表页、线路详情页、班次选择弹窗。列表页默认按综合权重排序权重 0.4 * 热度 0.3 * 评分 0.3 * 销量后台可以手动调整置顶位。前端用 Vue 的无限滚动加载每次请求 10 条滑动加载下一页用户体验比传统分页更自然。列表页的关键技术点是条件筛选这里我做了个组合筛选接口支持多参数同时生效// 线路查询条件 public class LineQueryDTO { private String keyword; // 关键词 private Long destinationId; // 目的地ID private Integer daysMin; // 最少天数 private Integer daysMax; // 最多天数 private BigDecimal priceMin; // 最低价格 private BigDecimal priceMax; // 最高价格 private Integer sortType; // 排序方式0综合 1销量 2价格升序 3价格降序 }Controller 里的实现很直接——利用 MyBatis-Plus 的 LambdaQueryWrapper 动态拼接条件不需要手写任何 SQLLambdaQueryWrapperTravelLine wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(query.getKeyword()), TravelLine::getLineName, query.getKeyword()) .eq(query.getDestinationId() ! null, TravelLine::getDestinationId, query.getDestinationId()) .ge(query.getPriceMin() ! null, TravelLine::getPrice, query.getPriceMin()) .le(query.getPriceMax() ! null, TravelLine::getPrice, query.getPriceMax()) .orderByDesc(TravelLine::getHeat) .orderByDesc(TravelLine::getSales);线路详情页要展示的信息量很大图片轮播、线路亮点、费用说明、行程安排、用户评价。这块最大的优化是“详情缓存”——把组装好的详情对象缓存到 Rediskey 为line:detail:{id}过期时间 10 分钟线路价格调整时主动删缓存。实测从数据库直查到缓存命中的响应时间从 150ms 降到 15ms。这里提醒一个容易忽略的细节详情的阅读量要异步更新不要和详情查询强耦合在同一个事务里。我专门起了个队列用定时任务每 5 秒批量把阅读数刷回到数据库避免每次浏览都触发一次 UPDATE。4.2 订票下单流程每一步都要严谨完整下单流程可以拆成六个步骤每一步都有明确的校验和落库操作用户选择线路前端传到后端的是scheduleId后端查询班次校验线路是否在售、班次是否有效通过 Redis Lua 脚本预扣库存创建主订单状态待支付同时创建订单明细和订单日志生成支付参数返回给前端前端拉起支付页面等待支付回调Redis 预扣库存的 Lua 脚本核心逻辑是local stock redis.call(get, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return -2 end redis.call(decrby, KEYS[1], ARGV[1]) return 1脚本本身保证判断和扣减是原子操作不会出现同事扣库存还超卖的问题。落库填订单时用的是 MySQL 普通 UPDATE因为 Redis 已经拦住超卖数据库这层不需要再做额外的并发控制。创建订单和服务号日志必须在同一个事务里避免订单建了但日志丢失。支付参数用支付宝沙箱环境对接后端生成支付页所需的form字符串前端收到后直接提交表单跳转。真实环境只需替换网关地址和密钥配置代码逻辑完全一样。4.3 异步支付回调处理与订单状态同步支付回调是这个项目最容易踩雷的环节。回调接口必须保证幂等——同一笔支付通知可能因为网络重试到达多次处理结果必须一致。回调接口的伪代码逻辑public String handleCallback(PayNotify notify) { // 1. 验签 if (!signCheck(notify.getSign())) return fail; // 2. 查询订单判断当前状态 Order order orderMapper.selectByOrderNo(notify.getOrderNo()); if (order null) return success; if (order.getStatus() 1) return success; // 已经支付过直接返回成功 // 3. 开启事务更新订单状态 写入支付记录 记录日志 orderTransactionService.markPaid(order, notify); return success; }第二步是关键——判断当前状态只要订单已经是“已支付”就返回成功不做任何更新。这样不管回调来了多少次结果都一样不会出现订单被重复处理的异常情况。另外要做一层兜底后端提供查询支付状态的接口前端在支付页面轮询每 2 秒一次如果 30 秒内既没收到支付成功通知也没等回调确认就主动向后端查询一次最终状态。这样做是为了防止用户交了钱前端因网络问题页面一直停在“待支付”上体验很糟糕。4.4 后台管理给运营一套顺手的工具后台管理用的是同一套 Vue 前端只是路由和按钮权限根据用户角色动态控制。管理员登录后可以看到线路管理增删改查/上下架、班次管理设置各线路每天的可出发日期和库存、订单管理订单列表/订单详情/退款审核、数据看板今日销售额/热门线路排行/客流趋势。后台管理系统里最值得说说的是“库存管理”。运营一次设置的班次库存可能多达几十条如果一条条新增非常低效。我做了一个“批量生成”功能——选择线路后设置起始日期、结束日期、每天库存量系统自动批量生成这期间的班次记录。这个功能上线后运营维护库存的时间从每天一小时缩短到十五分钟。权限控制这块用按钮级控制实现前端根据用户的权限码数组判断是否渲染按钮后端在接口上再加一层PreAuthorize(hasRole(ADMIN))注解兜底。前端控制是为了体验后端控制才是安全底线两者缺一不可。4.5 定时任务的三个典型场景项目中用 Spring 的Scheduled注解起了三个定时任务都是刚需关闭超时未支付订单每 5 分钟扫描一次订单表把创建超过 15 分钟且状态为“待支付”的订单置为“已关闭”同时回补库存同步浏览热度把内存队列中累计的阅读量批量写入数据库库存数据持久化每天凌晨将 Redis 中的库存快照同步回 MySQL定时任务的代码很简单但要关注一个细节——如果服务是集群部署多个实例同时跑相同的任务会在每个实例上都执行一遍可能导致重复处理。这个项目的量级还不用担心但你在做的时候如果上多实例记得引入分布式锁如 Redis SETNX或者升级为 xxl-job 这类带调度的框架。5. 开发全程踩过的坑与排查记录5.1 跨域问题前端联调第一道拦路虎前后端分离开发时前端跑在localhost:5173后端跑在localhost:8080跨域是必现的。我后端配置了全局跨域过滤器Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); // 开发环境放开线上需严格限制 config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意两点一是allowCredentials(true)时allowedOrigin不能设置为*必须用allowedOriginPattern(*)二是线上环境要把addAllowedOriginPattern改成具体的域名列表否则任意网站都能调用你的接口存在安全隐患。5.2 图片上传后访问 404静态资源配置问题本地存储图片之后前端访问上传的文件地址报 404。排查后发现是 SpringBoot 默认的静态资源路径没有覆盖我自定义的上传目录。解决办法是在配置文件中加上资源映射spring: web: resources: static-locations: file:/www/wwwroot/files/,classpath:/static/同时前端上传接口返回的 URL 存的是相对路径/api/files/xxx.jpg真正访问时由 Nginx 将/api/files/前缀映射到对应目录。这里提示一下存数据库的路径一定要写相对路径不要写C:/a/b/c.jpg之类的本地绝对路径。因为部署到服务器后目录结构完全变了写死绝对路径等于给自己挖坑。5.3 JWT 过期后的体验处理JWT 默认有效期设的是 2 小时用户操作到一半 token 过期前端所有请求都返回 401页面直接弹回登录页体验极差。我的处理方案是“双 token 静默刷新”登录时同时返回accessToken2 小时有效和refreshToken7 天有效。用户操作过程中如果 accessToken 过期前端 axios 拦截器捕获到 401 后自动调用刷新接口换取新令牌然后重试原请求。如果 refreshToken 也过期了才真正强制跳转登录页。实现的核心逻辑在 axios 拦截器里axios.interceptors.response.use( (response) response, async (error) { const { config, response } error; if (response response.status 401 !config._retry) { config._retry true; try { const { data } await axios.post(/api/auth/refresh, { refreshToken }); localStorage.setItem(accessToken, data.accessToken); config.headers.Authorization Bearer ${data.accessToken}; return axios(config); } catch (e) { router.push(/login); } } return Promise.reject(error); } );注意config._retry true这个标记防止刷新后的请求再次 401 时进入死循环重试。5.4 前端下载资源过大首屏加载 3 秒变 12 秒项目上线前压测最严重的问题是首屏加载时长。原因很简单把 Element Plus 全量引进了图标库也全量引用了打包后的主 JS 达到 5MB。优化方案分两步。第一步按需引入组件import { ElButton, ElTable, ElForm } from element-plus; // 使用时局部注册或挂到 app.config.globalProperties第二步用 Vite 的构建配置把打包文件拆分包build: { rollupOptions: { output: { manualChunks: { vue-vendor: [vue, vue-router, pinia], element-plus: [element-plus] } } } }这套组合拳打下来首屏从 12 秒降到 2.8 秒Gzip 后总包 800KB 左右用户流失率明显下降。顺带说一句图片一定要用 WebP 格式如果运营上传的是 JPG后端加一层格式转换典型的 200KB JPG 转成 WebP 后大约只剩 60KB页面加载效果立竿见影。5.5 高峰期抢票库存超卖线上事故级 Bug这个 Bug 是压测出来的线上也真实发生过一次。场景是某个爆款线路放出来 20 张团票10 秒内被抢光但订单统计里发现卖出了 21 单凭空多出来一单。根因是我最早直接用 MySQL 的 boardSELECT stock FROM t_schedule_stock WHERE schedule_id #{id} -- 代码里判断 stock 0再执行 UPDATE stock stock - 1这个“先查后改”的操作在并发场景下是必然有竞态的。两个请求同时查到了 stock 1都认为可以购买先后执行 UPDATE结果库存变成 -1超卖一单。修复方案就是前面提到的库存扣减完全交给 Redis 原子操作MySQL 库存只保留初始值并定时从 Redis 同步。Redis 挂了怎么办启动时做库存预热把数据库的值加载到 RedisRedis 宕机期间下单入口直接熔断返回“系统繁忙”这个取舍在库存成百上千的旅行场景下是合理的总比超卖要安全得多。5.6 订单列表查询越来越慢索引设计补课订单量达到 10 万条以后后台订单列表按用户查询时接口响应从 200ms 涨到 2 秒。通过EXPLAIN看到 SQL 走了全表扫描。原因是我在订单表上只建了主键索引查询条件user_id和order_no都没有命中索引。补上两个联合索引后查询耗时回到了 80msALTER TABLE t_order ADD INDEX idx_user_status (user_id, status); ALTER TABLE t_order ADD INDEX idx_order_no (order_no);索引不是越多越好我见过有人把表所有字段都加上索引结果写入时索引维护成本比查询收益还高。这个项目的原则是“让查询条件走索引让索引数量控制在 4-5 个以内”。6. 前后端打包与线上部署6.1 前端 build 配置和后端 Jar 打包前端打包直接用 Vitenpm run build产出目录在dist/里面包含index.html、assets/等静态文件。后端打包需要跳过测试测试脚本依赖本地数据库在 CI 上跑可能失败mvn clean package -DskipTests打完的 Jar 包在target/目录下直接java -jar就能启动。生产环境建议用nohup或 systemd 守护进程避免 SSH 会话关闭导致进程被杀。6.2 Nginx 双职责静态服务器 API 反向代理Nginx 在这个项目里承担了两个职责服务前端静态资源、把 API 请求反向代理到后端服务。这是我比较推荐的部署方式前后端完全不用处理跨域因为浏览器看到的请求都是同源路径。关键配置server { listen 80; server_name travel.example.com; # 前端静态资源 root /www/wwwroot/travel/dist; index index.html; # 路由回退到 index.html location / { try_files $uri $uri/ /index.html; } # API 代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 图片资源映射 location /files/ { alias /www/wwwroot/files/; } }try_files这行是为了支持 Vue Router 的 history 模式。如果不加用户刷新页面www.example.com/line/list时Nginx 找不到对应文件会返回 404加上后所有路径都回退到index.html交给 Vue Router 处理。6.3 数据库连接池调优SpringBoot 默认的 HikariCP 连接池性能不错但要按实际并发调整参数。我在配置里做了以下设置spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000maximum-pool-size设置 50 是基于压测数据200 并发请求下单时MySQL 有效查询并发在 30-40 之间留出缓冲到 50。设得过高比如 200 连接反而会成为灾难——创建连接本身有开销还没到使用就耗尽了系统内存。6.4 Redis 缓存清理策略Redis 里除了库存数据还缓存了线路详情、目的地列表等热点数据。库存相关的 Key 永不设置过期时间因为需要跨天持续操作详情类缓存统一设置 10 分钟 TTL。后台修改线路信息时主动删除对应缓存 Key这样最长 10 分钟就能看到更新后的效果运营反馈“基本是改完就能看到”。启动时做了一个缓存预热 Component把当日所有在售班次的库存从 MySQL 加载到 Redis库存为 0 的记录也要加载因为 0 也是有效状态需要知道已被清空。7. 性能优化与并发能力实录7.1 压测结果优化前后对比这是我最满意的一组数据。用 JMeter 模拟 200 个线程并发下单优化前后差异明显场景优化前优化后线路列表接口 TPS3201800线路详情接口 TPS180全部查库2100Redis 缓存下单接口 TPS60SQL 行锁排队350Redis 预扣下单 P95 响应时间800ms120ms核心优化就三个点Redis 缓存热点数据、Redis Lua 预扣库存、批量接口合并比如详情页同时需要线路数据、评价数据、周边景点一个接口返回避免前端 5 个请求排队。7.2 扩容方向预留这个架构在业务量增长到一定阶段时可以做两个方向的升级而不必推翻重来。第一是 Redis 缓存层继续加厚把列表页的搜索结果也缓存起来配合布隆过滤器减少缓存穿透第二是读写分离MySQL 开一个从库后台管理类和统计类查询全部走从库主库只承担交易写入。更高量级的抢购场景就需要引入 MQ 消峰了用异步队列把下单请求串行化处理。但这些都是“未来可能”目前单机加缓存顶住几千的并发完全没问题架构演进应该跟着业务走不要为想象中的量超前设计。8. 项目里的反思与优化空间8.1 之前做得不够好的地方前面提到的几处坑都是后来补上的但有些设计缺陷在重构时仍要重视。第一是线路详情页的评价模块最开始没有设计“评价审核”这个状态。运营看到用户提交的评价直接展示在页面上有个别用户提交了不当内容才发现问题。后来在评价表里加了审核状态字段后台审核通过才对外展示这是一次运营倒逼的迭代。第二是退改流程。最初只支持用户申请退款没有区分“部分退款”和“全额退款”的业务场景。但现实中用户可能购买了 3 人出行套餐只有 1 人临时改期这就涉及部分退款。目前记录的是物流退款逻辑整单退款后续如果要支持“按出行人退款”订单表需要拆得更细。8.2 未来可以扩展的方向市面上成熟的旅游平台还有很多功能这套系统如果要做商业级产品优先扩展的方向有三个一是线路的 PDF 行程单生成。用户下单后需要一份正式的行程单用于签证或出差报备后端用 Java 的 PDF 库根据线路模板动态生成文件这个功能实现不算难但对用户价值很大。二是日历价格日历。按日期展示价格用户能直观看到旺季和淡季的价格差异利于决策。实现上只需要在前端用日历组件按天展示后端返回的价格数组。三是基于 Redis 的地理位置功能实现“按距离推荐附近景点”。Redis 的 GEO 类型可以直接存储景点坐标查询以当前用户位置为中心半径内的景点响应时间极快。8.3 坚持沿用下来的代码习惯最后分享几个我觉得值得保留的编码习惯它们帮助我减少了很多 bug。Controller 层不要写业务逻辑只做参数接收和结果封装。业务逻辑全部下沉到 Service 层用事务注解控制原子性。所有自定义状态、枚举值不要出现裸数字。订单状态、支付方式、线路状态全部定义成枚举类避免魔法数字散落各处。写接口时同步写好 SQL 日志设置mybatis-plus.configuration.log-impl输出完整 SQL。在排查问题时能直接看到执行的 SQL 和入参定位问题效率翻倍。我做完这个项目的最大感受是旅游平台的难点不在某一个具体的算法或框架特技而是很多个普通技术点在正确约束下的组合。把 JWT、缓存、事务、索引这些基本功吃透了这个类型的系统完全可以从零到一搭起来并且稳定运行。如果在做同类系统时碰到具体问题欢迎留言交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进