今年年初接了一个旅游行业的项目:用 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 是当前的新项目标配。组合式 API(Composition API)让状态逻辑的复用变得很直接,比如“线路筛选状态”和“用户登录状态”各自封装成一个useXxx函数,组件里按需调用,代码结构比 Options API 清晰很多。
组件库选了 Element Plus,表格、表单、弹窗、日期选择器这些后台管理页面最常用的组件都是现成的,一套下来页面风格统一,不需要前端单独设计。移动端适配方面,Vite + Rem 布局配合媒体查询,在手机上也能正常浏览下单。
另外一个细节是路由守卫。前端必须在每个受保护页面的路由上配置beforeEach,判断本地是否存有 JWT,没有就强制跳转登录页。这个虽然不能替代后端的真正的鉴权,但能极大提升用户体验,避免用户操作到一半才被接口 401 弹回登录页。
2.3 数据库、缓存和中间件选型
存储选 MySQL 8.x(InnoDB 引擎),事务和行级锁是这个项目最依赖的能力。缓存用 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 动态拼接条件,不需要手写任何 SQL:
LambdaQueryWrapper<TravelLine> 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);线路详情页要展示的信息量很大,图片轮播、线路亮点、费用说明、行程安排、用户评价。这块最大的优化是“详情缓存”——把组装好的详情对象缓存到 Redis,key 为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 + 静默刷新”:登录时同时返回accessToken(2 小时有效)和refreshToken(7 天有效)。用户操作过程中如果 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 的 board:
SELECT stock FROM t_schedule_stock WHERE schedule_id = #{id} -- 代码里判断 stock > 0,再执行 UPDATE stock = stock - 1这个“先查后改”的操作在并发场景下是必然有竞态的。两个请求同时查到了 stock = 1,都认为可以购买,先后执行 UPDATE,结果库存变成 -1,超卖一单。
修复方案就是前面提到的,库存扣减完全交给 Redis 原子操作,MySQL 库存只保留初始值,并定时从 Redis 同步。Redis 挂了怎么办?启动时做库存预热,把数据库的值加载到 Redis;Redis 宕机期间下单入口直接熔断返回“系统繁忙”,这个取舍在库存成百上千的旅行场景下是合理的,总比超卖要安全得多。
5.6 订单列表查询越来越慢,索引设计补课
订单量达到 10 万条以后,后台订单列表按用户查询时接口响应从 200ms 涨到 2 秒。通过EXPLAIN看到 SQL 走了全表扫描。
原因是我在订单表上只建了主键索引,查询条件user_id和order_no都没有命中索引。补上两个联合索引后,查询耗时回到了 80ms:
ALTER 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 打包
前端打包直接用 Vite:
npm 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 个线程并发下单,优化前后差异明显:
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 线路列表接口 TPS | 320 | 1800 |
| 线路详情接口 TPS | 180(全部查库) | 2100(Redis 缓存) |
| 下单接口 TPS | 60(SQL 行锁排队) | 350(Redis 预扣) |
| 下单 P95 响应时间 | 800ms | 120ms |
核心优化就三个点: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、缓存、事务、索引这些基本功吃透了,这个类型的系统完全可以从零到一搭起来并且稳定运行。如果在做同类系统时碰到具体问题,欢迎留言交流。