☰
SpringBoot+Vue3旅游管理系统全栈开发实战与部署复盘
2026/10/14 12:37:33 网站建设 项目流程

先交代一下背景:手头这套旅游管理系统,不是课堂作业那种玩具项目,而是我按实际商业需求做的前后端分离项目。技术栈选的是 SpringBoot 2.7 + Vue 3 + MyBatis-Plus + MySQL 8.0,配套完整开发文档。整体覆盖了用户端下单、后台管理、线路管理、订单流转、评价反馈这些核心业务。这篇文章我会从项目拆解、技术选型、数据库设计、前后端实现、部署排错几个维度完整复盘一遍,把开发过程中做了哪些取舍、踩过哪些坑、为什么要这样设计都讲清楚。想拿这个项目练手、做毕设、或者打算在此基础上二次开发的,都可以直接参考。

很多人在做类似的 Web 项目时,最容易犯的毛病是拿到需求就写代码,最后发现数据库字段缺、接口对不上、前端组件改来改去。这套项目本身结构相对完整,适合用来学习“一个真实系统是怎么从零到一搭起来的”。下面进入正题,我会按照实际开发顺序来说,尽量还原当时的决策过程。

1. 项目整体设计与技术选型思路

1.1 为什么是 SpringBoot 2.7 而不是 Spring Boot 3.x

很多初学者会有疑问:现在新项目为什么不直接上最新版?我当时选型时有几个实际考虑。

SpringBoot 2.7 是 2.x 系列的最终版本,生态成熟度极高。当时团队里面已经有不少积累的代码片段、第三方组件封装都基于 2.x 编写,直接升级到 3.x 意味着要处理 javax 到 jakarta 命名空间迁移,还有 Spring Security 配置方式的变化。对于业务系统来说,稳定压倒一切。如果你的应用不涉及响应式编程、GraalVM 原生镜像等新特性,2.7 依然是非常合理的选择。

从实际开发体验来说,2.7 配合 MyBatis-Plus 3.5.x 版本非常顺手。MyBatis-Plus 的自动填充、分页插件、乐观锁插件在 2.7 环境下跑得很稳。还有一个细节——很多老牌中间件、云厂商的 SDK 对 SpringBoot 3.x 的兼容公告当时还不全面,如果项目涉及对接第三方短信、支付、OSS 等能力,选 2.7 可以避开很多“官方还没适配”的尴尬期。

1.2 为什么用 Vue 3 而不是 Vue 2

前端选 Vue 3,核心原因是 Composition API 带来的逻辑复用能力。旅游管理系统的后台管理端功能虽然不算极度复杂,但存在不少“列表 + 搜索 + 表单弹窗 + 状态切换”的模式化页面。用 Composition API 可以把搜索参数、加载状态、分页逻辑抽成公共 composable,几个页面共用一套逻辑,维护成本直线下降。

同时 Vue 3 的响应式系统基于 Proxy 重写,在处理深层嵌套的对象时不再有 Vue 2 那些响应式劫持的坑。比如订单对象里面有用户信息、线路信息、支付记录,在 Vue 2 里如果不小心直接给嵌套对象新增属性,很容易出现“数据变了,页面没反应”的问题。Vue 3 基本上不会遇到这类情况。

配套选了 Element Plus 作为 UI 组件库,原因也很简单:组件全、文档清晰、后台管理系统场景高度契合。表格、表单、弹窗、日期选择器、树形控件都有现成的,能节省大量样式开发时间。

1.3 MyBatis-Plus 和 MySQL 8.0 的组合优势

MyBatis-Plus 解决的是“单表 CRUD 不想写 SQL”的痛点。旅游管理系统里,用户表、收藏表、评论表、订单表这类单表操作占了大概六成,用 BaseMapper 提供的 selectById、selectPage、insert 等方法直接搞定。代码量少,且不容易出错。

不过这里要说一句:MyBatis-Plus 虽然方便,但别滥用。多表关联查询、复杂统计报表还是老老实实写 XML 里的自定义 SQL。这个项目里订单列表需要关联用户表和线路表,我全部用的自定义 SQL,加 @Select 注解或者 XML 文件维护。MyBatis-Plus 的分页插件对单表分页友好,但一旦涉及 join 后的 count 查询,性能损耗就上来了,自己写 SQL 能精准控制。

数据库选 MySQL 8.0,主要是看中窗口函数和 CTE 公共表表达式。旅游线路的销量排名、用户消费排行这类统计需求,用 ROW_NUMBER() 窗口函数能一条 SQL 搞定。另外 8.0 默认字符集是 utf8mb4,存储 emoji(比如用户昵称带表情)不会出现乱码。这一点对于真实上线的系统非常重要,如果还在用 5.7 且字符集没选对,用户在 Nickname 里存一个笑脸符号,接口就直接报错了。

2. 数据库设计与核心模块拆解

2.1 数据库设计在动手写代码之前完成

我在开始任何模块开发之前,足足花了两天时间打磨数据库表结构。这套系统一共设计了 10 张核心表:用户表、管理员表、旅游线路分类表、旅游线路表、订单表、收藏表、评论表、轮播图表、支付记录表和日志表。这里重点讲几张最关键的表。

用户表字段看着简单,但坑不少。除了常规的 id、username、password、nickname、avatar、phone、email、gender 这些字段之外,我专门加了 status 字段做账号状态(正常/锁定)。为什么要这个字段?因为管理员后台要有拉黑用户的入口。没有状态字段,后面想做账号管理功能就得改表结构,迁移成本很高。

旅游线路表是这套系统的核心,字段设计时考虑得比较细致。基础字段包括线路名称 title、线路编号 code(业务编号,方便人工识别和线下对账)、出发城市 departure_city、目的地 destination、行程天数 days、价格 price、原价 original_price(用于展示划线价促销)。这些字段在后端校验和前端展示时都有各自用途。比如价格字段我用的 DECIMAL(10,2),而不是 DOUBLE。浮点数在 Java 里面做运算可能产生精度问题,涉及金额必须用 BigDecimal 映射。

订单表的设计比重最大,因为它承担了状态流转的核心逻辑。主要字段包括订单号 order_no(唯一索引)、用户ID user_id、线路ID line_id、下单数量 quantity、总金额 total_price、联系人 contact_name、联系电话 contact_phone、订单状态 status。状态值用 TINYINT 存储,定义如下:0-待支付、1-已支付/待出行、2-已完成、3-已取消、4-已退款。用数字存状态的好处是扩展方便,比如以后新增“待审核”状态直接加一个值,而不用改字段结构。

考虑到订单号在用户端展示的频率很高,我在 order_no 上做了唯一索引。订单号生成策略是“时间戳 + 用户ID后四位 + 随机数”。虽然理论上存在碰撞的概率,但实测下来在公司内部项目并发量下完全够用。如果你对唯一性要求更高,可以用雪花算法,不过在本项目中属于锦上添花。

收藏表和评论表相对简单。收藏表要注意联合唯一索引 (user_id, line_id),防止同一个用户对同一条线路重复收藏。这是新手很容易忽略的细节,没有唯一索引就只能靠代码里先查询再插入,既多一次 IO 又有并发问题。评论表除了评论内容、评分字段外,我还加了回复父ID parent_id,用一棵简单的树结构实现“评论 - 回复”的二级结构,不需要引递归查询库。

2.2 数据访问层的细节处理

数据库连接配置中,有几点值得单独说。首先是连接池。SpringBoot 2.7 默认使用 HikariCP,配置里我显式设置了 maximum-pool-size=10 和 minimum-idle=5。为什么是 10?这套系统预估并发场景是高峰时段 100 个在线用户同时操作,一个请求通常会占用一个数据库连接,连接数设置太大没有意义,反而加大 MySQL 压力。HikariCP 官方也强调过连接数不宜过大。

时区问题必须处理。MySQL 8.0 连接串里的 serverTimezone 如果配置不对,拿出来的时间会差 8 小时,典型的低级错误但很多人都会踩。我统一在 JDBC URL 里加了 serverTimezone=Asia/Shanghai 和 useUnicode=true&characterEncoding=utf8。这里有个小建议——urL 参数中的 useSSL=false 也要加上,因为本地开发环境没有配置 SSL 证书,不减这个参数,MySQL 8.0 默认会尝试 SSL 握手,增加不必要的开销,甚至在某些环境直接报错。

MyBatis-Plus 的使用上,我开启了下划线转驼峰(默认开启)、逻辑删除和自动填充。逻辑删除是个数据库设计层面的决策——业务数据不能物理删除,运营后台需要看到“已删除”的记录,以便审计和数据恢复。MyBatis-Plus 的逻辑删除配置好全局删除值(1)和未删除值(0),所有 BaseMapper 的 deleteById 都会自动变成 update 语句,非常省心。自动填充主要用于 create_time 和 update_time 字段,在插入和更新时自动填上当前时间,不用每个业务代码里手动 set,减少漏赋值的概率。

2.3 核心业务流程的状态机设计

订单模块是旅游系统的核心,状态流转逻辑我做了梳理:

待支付 → 已支付/待出行 → 已完成

待支付 → 已取消

已支付 → 已退款

这几个流转听起来简单,实现时要考虑并发问题。用户点击“取消订单”和“支付回调”恰好同时到达,怎么做?

我用两个手段解决。第一,状态更新的 SQL 语句里带上前置状态条件,例如:

UPDATE sys_order SET status = 3 WHERE id = ? AND status = 0;

这样只有当前状态是待支付时才能改成已取消。MyBatis-Plus 里对应的写法是使用 UpdateWrapper,在 set 后面接 eq("status", 0)。第二,订单状态变更统一走唯一入口——OrderStateMachine 类,里面用枚举定义所有合法流转,不合法流转直接抛异常。前端即使发起了非法操作(比如已完成订单点取消),后端也会拦截住。

3. 后端实现细节与核心环节实战

3.1 工程结构分层思路

后端代码结构上,我采用了常见的 Controller → Service → Mapper 三层架构,同时针对复杂业务引入了 manager 层。Controller 只做参数接收和结果封装,不写业务逻辑。一开始很多人图省事,把业务判断全写在 Controller 里,到后期几乎没法维护。Service 层写核心业务规则,Manager 层做跨领域聚合,比如下单操作同时涉及订单、库存扣减、支付单创建,就放在 OrderManager 里统一编排。

统一返回结果类是 Result 泛型类,定义 code、message、data 三个字段。code 为 0 表示成功,非 0 表示各类错误码。这样的好处是前端可以统一处理响应,axios 拦截器拿到 code=0 才走业务逻辑,否则进入错误提示。同时我还封装了分页结果类 PageResult,字段包含 list、total、pageNum、pageSize,前端分页组件直接对接,省去每次写转换逻辑。

全局异常处理用的 @RestControllerAdvice。业务异常通过自定义 BizException 抛出,全局捕获后转成标准错误响应。比如“线路已下架无法下单”“订单状态不允许取消”都是 BizException。系统异常则通过 @ExceptionHandler(Exception.class) 方法兜底,记录错误日志并统一返回“系统繁忙”,避免直接把异常栈抛给前端。

3.2 用户登录与鉴权机制选型

用户鉴权这里我对比了 JWT 和 Session 两种方案。考虑到前后端分离部署、后端可能水平扩展(多实例部署时 Session 不共享),最终选了 JWT。实现思路是:用户登录成功后,后端生成 Token,包含用户ID和过期时间,用 HMAC-SHA256 算法加盐签名。前端把 Token 存在 localStorage 里,每次请求在 Authorization 请求头里带上。

SpringBoot 端配置了一个 OncePerRequestFilter(JwtAuthenticationFilter),拦截所有 /api/** 请求。具体逻辑分三步:

String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); Claims claims = JwtUtil.parseToken(token); // 解析成功则把 userId 放入 ThreadLocal }

这里有个容易踩坑的点——JWT 的过期时间不能设置太长,我设的是 24 小时。旅游系统用户不会频繁操作,单次登录保持 24 小时够用了。为了用户体验好,我在后端增加一个 Token 续期机制:当 Token 剩余有效期小于 4 小时时,重新签发一个新的 Token 放在响应头里,前端 axios 拦截器自动替换。这样用户在持续操作时始终不会突然掉线。

密码加密选的 BCrypt,这是 Spring Security 里自带的标准加密算法。BCrypt 加密后每次生成的密文不同,但校验时能正确匹配。相比 MD5 加盐,BCrypt 的安全性更高,计算耗时也更长,天然抵抗暴力破解。密码字段类型直接定义为 VARCHAR(60),因为 BCrypt 生成的密文长度固定为 60。

3.3 文件上传与存储方案

旅游线路需要上传封面图和多张详情图,这里用到了文件上传。为了开发和实际部署简单,文件存储在本地磁盘,通过静态资源映射对外访问。配置上在 application.yml 中定义上传路径 upload.dir,然后在 SpringBoot 配置类中重写 addResourceHandlers,将 /upload/** 映射到磁盘目录。

上传代码注意两点:一是文件名重命名,直接用 UUID 拼上原始后缀名,防止中文文件名和路径遍历攻击。二是文件类型白名单校验。上传接口拿到文件名后缀后判断是否在 jpg、png、jpeg、webp 集合内,图片内容则用 ImageIO.read 进一步校验,防止伪造后缀上传非法文件。同时设置了单个文件大小不超过 5MB,Spring 配置里做限制:

spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB

如果要做水平扩展,本地磁盘存储就会遇到问题(请求打到 A 服务器,图片在 B 服务器)。生产环境我一般建议换成云存储(对象存储服务),后端只存储回传的 URL。但本地文件存储对单机部署、学习演示完全够用,架构简单,也容易理解。

3.4 缓存与性能优化思路

性能优化这块,最立竿见影的是接口缓存。旅游线路的分类列表是查频繁、更新少的数据,我用 Spring Cache 的 @Cacheable 做了一层内存缓存,缓存 key 是分类 ID。线路详情页的数据也用缓存,但缓存失效策略要细致考虑:管理员在后台修改线路信息后,必须主动 evict 缓存,否则用户端看到的是旧数据。实现方式是在后台修改线路的 Service 方法上加上 @CacheEvict 注解。

登录接口和下单接口做限流。我用 Google Guava 的 RateLimiter,简单粗暴地限制单个用户的操作频率。登录接口限流为每 5 秒最多 1 次请求,防止暴力破解和恶意刷接口。下单接口限流为每 10 秒最多 3 次请求,避免用户手滑连点造成重复下单。实际开发中如果要求更严谨,可以用令牌桶算法在网关层做统一限流,但对单体应用,代码层限流已经足够。

MySQL 慢查询优化也是必须做的。开发完成后,我用 EXPLAIN 检查高频查询语句是否走索引。旅游线路列表页按价格排序 + 分类筛选,MySQL 如果没有合适的索引,数据表到了几万条时就会明显卡顿。我在线路表的 category_id 和 price 上建了联合索引 (category_id, price),排序和过滤同时命中索引,查询速度提升接近一个数量级。另外一个容易忽略的索引是订单表的 user_id,所有“我的订单”列表都靠它查询,必须有索引。

4. 前端功能实现与页面交互要点

4.1 Vue3 前端工程结构和路由设计

前端工程化用的是 Vite,相比 Webpack,开发环境下冷启动速度快得多,几乎不用等。项目初始化直接用官方 create-vite 脚手架,选 Vue3 + JavaScript(没有上 TypeScript,因为团队成员对 TS 不熟练)。

路由设计分了两套布局。用户端是一套前台布局,包含头部导航、轮播图、线路推荐、线路详情、购物流程等页面;后台管理端是另一套独立布局,包含主要导航菜单和路由视图。两套布局分开写,方便后续针对不同端做权限控制。

前台的首页、分类页、详情页都是公开页面,不用登录也能浏览。但“立即预订”按钮点击后,前端会检查 localStorage 是否有 token,没有则跳转到登录页。这个逻辑写在一个全局前置守卫里:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });

后台管理端则更严格一些,除了校验 token,还在路由 meta 中标注了 admin 标识,加载时判断当前用户角色是否为管理员,不是就踢回首页。虽然前端权限只能做展示控制,真正的权限校验必须靠后端 API 判断,但没有前端这层拦截,管理员后台的接口虽然没有暴露,用户端页面不会直接跳转,但被尝试访问管理接口的风险依然存在,所以后端接口统一做了角色校验。

4.2 用户端核心页面拆解

用户端主页,我用了一个可以配置化的轮播图,数据来自后台管理的轮播图表。通过 Element Plus 的 Carousel 组件循环渲染,管理员在后台换了图,用户端首页就实时变化。热门线路推荐部分调用了后端推荐接口,核心逻辑是取销量最高的前 6 条线路,并按评分倒序。

线路列表页是我花了比较长时间打磨的页面。左侧是分类筛选栏,顶上是价格区间、出发城市、排序方式(默认综合、销量优先、价格从低到高、价格从高到低),右侧是卡片式线路列表。分页逻辑用的是 Element Plus 的 Pagination 组件,页码切换时前端携带 pageNum、pageSize、分类 ID、价格区间、排序字段调用后端接口,重新渲染列表。移动端适配做了响应式布局,CSS 媒体查询在断点处调整卡片列数。

线路详情页的核心信息包括:名称、价格、天数、出发城市、目的地、封面大图、图文详情、用户评价列表。“立即预订”按钮点击后跳转订单确认页,页面展示线路信息摘要、需要填写的出行人信息(联系人姓名、联系电话、出行人数),底部显示总价。这里有个交互细节:出行人数用 Stepper 组件控制,人数变了总价实时重算——单价乘以人数,前端用 computed 计算属性即可完成。下单成功后跳转支付页,模拟了支付流程,前端倒计时 15 分钟,超时订单会被自动取消。

4.3 后台管理端页面详解

后台管理端用的是标准布局:左侧导航、顶部面包屑、内容区域。导航菜单包含:仪表盘、线路管理、分类管理、订单管理、用户管理、评论管理、轮播图设置。

线路管理页面是所有后台页面里功能最综合的。列表区域展示线路封面缩略图、名称、分类、价格、上架状态、操作按钮。操作按钮包含编辑、下架/上架、删除、查看评论。这里有个交互设计点:上架/下架操作不做二次弹窗确认,而是用 Popconfirm 组件让用户点击按钮后在气泡里再次点击确认,减少误操作概率。删除则是真正的危险操作,点击后不仅弹确认框,还需要输入“删除”两个字才能提交。虽然逻辑上有点小复杂,但确实防止了运营人员手滑把整条线路数据删掉。

表单区做成一个独立弹窗组件,使用 Element Plus 的 Dialog 嵌套 Form 表单。表单包含:线路名称(必填)、线路编号、分类选择(远程下拉加载分类数据)、出发城市和目的地、价格(必填,校验必须大于 0)、行程天数、出发日期、封面图上传、图文详情富文本。价格校验用规则表达式确保输入的是数字。表单提交前会跑一遍 Element Plus 的表单校验,全部通过后才允许提交到后端。

订单管理页面,表格按订单号、用户、线路名、总价、状态、下单时间展示。顶部提供状态筛选 Tab(全部、待支付、已支付、已完成、已取消、已退款),底部是分页器。具体到某一单,支持“发货/确认完成”和“退款”操作。退款操作校验更严谨——后端会把订单状态改到“退款中”,然后走异步回调模拟退款完成后变成“已退款”。 21行我决定在这里补充一句经验之谈:很多系统对“退款”这个动作想得太简单,实际上退款往往要对接第三方支付,系统内状态必须精确到退款中,才能保证对账时不留一笔糊涂账。

4.4 前端状态管理与接口封装

前端没有用 Pinia 管理复杂状态,只在用户登录后把用户昵称和头像放在 localStorage 里,顶部导航直接读取显示。真正复杂的状态比如订单流程里的临时数据,放在组件内部通过 props/emit 传递已经足够。

接口请求封装在 src/utils/request.js 中,基于 axios 创建实例。核心内容包括:

service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); service.interceptors.response.use(response => { const res = response.data; if (res.code !== 0) { if (res.code === 401) { router.push('/login'); } return Promise.reject(new Error(res.message)); } return res; });

401 状态表示 token 失效或未登录,前端直接跳转登录页。这里的 401 判断来自后端未知错误码约定:登录失效的统一错误码。封装之后,每个业务页面内只需调用 get("/line/detail", { id: xxx }),不需要重复处理 token 和错误提示逻辑。API 模块全部放在 src/api 目录下,每个功能一个文件,比如 line.js、order.js、user.js,所有 API 函数从 ui 层导入使用,这样接口列表清晰可查。

5. 部署上线与常见问题排查分享

5.1 前后端分离的构建部署流程

部署时,前端通过 npm run build 打包,产物是 dist 目录下的静态资源。我实际部署方式是:后端 jar 包跑在服务器上,前端 dist 目录里的文件由 Web 服务器(如 Nginx)直接托管。这里有两种方案,一种是把 dist 文件放在 Nginx 的 html 目录下,用 Nginx 转发 /api 请求到后端服务;另一种是把前端静态资源放在 SpringBoot 的 static 目录里,打包成同一个 jar。

我更推荐第一种,前后端分离独立维护,Nginx 配置反代理:

server { listen 80; server_name yourdomain.com; root /opt/travel-front/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { alias /opt/travel-upload/; } }

后端用 Maven 打包:mvn clean package -DskipTests,生成可执行 jar 包。启动命令是:

nohup java -jar travel-server.jar \ --spring.profiles.active=prod \ --server.port=8080 > /opt/travel-server.log 2>&1 &

生产环境的配置放在 application-prod.yml 中,数据库密码通过环境变量注入,而不是写死在配置里编码规范上。这里我个人的经验是——提前占好端口和内存,例如服务器是 2 核 4G,JVM 参数务必配上 -Xms256m -Xmx512m,否则堆内存默认值是物理内存的四分之一,1G 直接没了,前后端同时跑很容易造成内存不足直接卡死。

前端部署还要注意 history 路由模式下的配置。Vue Router 默认是 history 模式,刷新某个子路由页面时 Nginx 会直接 404,因为找不到对应的物理文件。必须在 Nginx 配置中添加:

location / { try_files $uri $uri/ /index.html; }

这行配置的含义是:请求的路径如果找不到对应文件,就回退到 index.html,由前端路由接管。很多新手部署 Vue 项目时访问子页面报 404,根因就在这里。

5.2 常见开发期问题的排查记录

做这个项目的过程中,我整理了三个印象深刻的坑,这里逐一复盘。

第一个是跨域问题。前端 dev server 跑在 5173 端口,后端接口跑在 8080,由于端口不同,浏览器会拦截所有接口请求。解决方式是在前端 Vite 配置中开启代理,把 /api 代理到后端地址。这是我推荐的方式,比在后端开启 CORS 更干净。开发环境代理配置如下:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

第二个是日期格式化问题。Java 后端返回的 LocalDateTime 默认序列化格式是带 T 的 ISO 标准格式,比如“2025-06-14T10:30:00”,前端直接展示会很难看。我在后端统一配置了 Jackson 的日期格式,并同时配置了时区:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

加一个额外提示:仅仅配置 date-format 对 LocalDateTime 类型不生效——必须额外注册 JavaTimeModule 或使用自定义序列化器。实际上很多人踩坑就踩在这里。我采用的是在启动类里配置一个 Jackson 定制 Bean,用 LocalDateTimeSerializer 统一处理。更简单的方案是挂上 jackson-datatype-jsr310 依赖的全局配置,但在 SpringBoot 2.7 中这个模块已经集成,只需配合 yml 配置即可生效。

第三个是 MyBatis-Plus 分页 Page 对象的使用错误。很多新手会直接 new Page<>(pageNum, pageSize) 传给 mapper.selectPage(),返回的对象里 getRecords() 确实有数据,但 getTotal() 默认是 0。原因是分页插件没注册。MyBatis-Plus 的分页插件必须显式加进 MybatisPlusInterceptor 配置类里:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

没有这个配置的话,selectPage 不会自动执行 count 查询。另外要注意,PaginationInnerInterceptor 要指定数据库类型,否则在分页 SQL 生成时可能无法正确识别方言。

5.3 数据初始化和演示数据准备

系统部署起来之后,空库跑起来很没意思,也不利于演示和测试。我在 resources 目录下准备了完整的初始化 SQL 脚本,包含建表语句和基础数据。基础数据里准备了几十条真实风格的线路数据,覆盖不同分类、不同价格带,以及分类数据、轮播图数据、管理员账号(admin/admin123)和测试用户账号。

为了方便交接和二次开发,文档部分我准备了完整的 Word 和 Markdown 版本说明,内容包括:

  • 项目介绍与技术栈说明
  • 环境搭建步骤(JDK、Maven、Node、MySQL 安装配置)
  • 数据库设计文档(含 ER 图、表设计说明)
  • 前后端启动步骤
  • 接口文档(按模块分组、附请求参数和返回示例)
  • 部署上线指南

这套文档花的时间不比写代码少,但对接手项目的人非常友好。我在文档里加了很多“为什么这样做”的说明,而不是单纯罗列操作步骤。比如解释为什么订单金额字段用 DECIMAL 而不是 FLOAT,为什么套餐表加 status 字段。这类详细的说明文档能节省看代码的人大量时间,尤其对学习者来说,有很多代码里看不出原因的设计细节,文档一看就明白了。

5.4 安全与数据校验的加固清单

项目虽然规模不大,但安全相关的配置没有省。以下是这个项目落地时的几个必备加固项。

SQL 注入防护基本靠 MyBatis-Plus 的参数预编译机制解决,但自定义 SQL 里如果使用 ${} 拼接参数则要格外小心。我检查了所有 XML 里的 SQL,凡涉及表名、排序字段、order by 后面的动态字段的值,我一律使用白名单映射,而不是直接拼接用户传入的值。这个习惯很重要,很多 SQL 注入漏洞就是这样产生的。

XSS 攻击防护方面,前端通过 Vue 模板插值自动转义大部分注入脚本,后端也做了一层兜底。后端通过全局拦截器,对请求参数中的字符串进行过滤,将尖括号 <> 替换为转义字符。虽然这么做可能影响一些富文本内容(比如线路详情的图文描述),但富文本编辑器提交的内容会经过白名单过滤校验,普通文本字段全部过滤处理,做到纵深防御。

接口幂等性设计,体现在下单和支付回调这两个场景。用户点击提交订单按钮,如果因为网络问题被反复提交,后端会出现重复订单。我用“订单号唯一索引 + 创建订单号时服务端生成”的方式解决。用户端提交时不传订单号,创建时服务端生成 order_no 并插入,DB 层面唯一索引兜底。第二次请求即使到达后端,插入时会因唯一索引冲突失败,前端提示“请勿重复提交”。

5.5 性能瓶颈排查经验

项目开发完成后,我模拟了较大数据量场景压测。在线路列表页和订单列表页都达到 5 万条数据时,页面响应有明显延迟。Performance 分析后定位到两个瓶颈。

第一个是订单列表接口一次性查询了所有关联字段,导致返回数据量大。优化方式是默认只查询当前页需要的字段,列表页的线路名和用户名用 join 查询带出,但详情页的富文本、描述内容不查询。SQL 中明确列出需要的列,不写 SELECT *。这一点我写进代码规范里了,所有新增 SQL 查询都必须显式声明列名,禁止星号,理由是:减少网络传输、避免拖出无用大字段、防止新增字段破坏接口返回结构。

第二个是热门线路推荐接口。原本是查所有线路,按销量倒序,这个逻辑每次请求都全表扫描再排序。优化方案是 SQL 里直接 ORDER BY sales_count DESC LIMIT 6,并在 sales_count 字段上建索引。MyBatis-Plus 中对应代码:

List<TravelLine> hotLines = travelLineMapper.selectList( new LambdaQueryWrapper<TravelLine>() .eq(TravelLine::getStatus, 1) .orderByDesc(TravelLine::getSalesCount) .last("LIMIT 6") );

加上 LIMIT 后,排序不再需要将全表数据都载入临时文件,优化效果非常直接。实测 5 万条数据耗时从约 300ms 降低到 8ms,响应时间几乎可以忽略。

6. 复盘与适用的场景边界

说说这个项目的适用范围。如果你是想学习 SpringBoot + Vue3 全栈开发的年轻人,把它拆开研究是非常好的素材——后端有优雅的代码分层、完整的状态机设计、全局异常处理;前端有完整的用户端和后台管理端页面、响应式布局、网络请求封装。这套思路用途相当广,比如民宿预订、租车服务、票务系统,都可以在它的基础上套模型。

如果你是想快速拿一个能演示的成品,那这份带文档的项目资源可以省掉你从零开始写的大量时间。这里有个建议提醒一下:二次开发前,先花半天时间把数据库表结构和核心状态流转理清楚,目标比胡乱改代码高效得多。我见过太多人下载源码后直接运行,跑不起来就开始改代码,结果越改越乱。正确姿势是先把环境跑通,再改业务逻辑。

但说句公道话,这个技术栈在极大规模高并发场景下并不占优势。如果你预期用户量巨大、有分布式事务需求、需要微服务架构,那这套单体应用并不合适。不过对绝大多数中小型业务场景、课程设计、企业内部系统来说,单体架构加上一套规范的代码结构,性价比是最高的。复杂度并不总要引入分布式才能解决,单体将核心结构做到清晰规范,实际上能省掉一大截运维和开发成本。

7. 最终实际的项目运行与演示验证

项目完成后,我重新梳理了从安装到上线的完整流程,当成一次发布演练。这里把实际执行顺序列出来,新手上路可以直接照着操作。

准备阶段,确认本机环境:JDK 1.8 及以上、Maven 3.6、Node.js 14 以上、MySQL 8.0。导入 init.sql 脚本创建数据库和表之后,在 application-dev.yml 中改数据库连接。值得注意的一点是,MySQL 8.x 的驱动类名称是 com.mysql.cj.jdbc.Driver,不是旧版 com.mysql.jdbc.Driver,写配置的时候不要照抄老项目的。

启动后端,定位到项目根目录执行 mvn spring-boot:run,看到 “Started Application in xx seconds” 即启动成功。后端启动时自动填充管理员账号和默认分类数据。

前端启动,进入 frontend 目录执行 npm install 安装依赖,然后 npm run dev 启动开发服务器,浏览器访问 http://localhost:5173,能正常看到首页和线路列表。

联合调试时注意,前端所有 /api 开头的请求会经过 Vite 代理转发到 8080。验证代理是否生效,可以在浏览器开发者工具 Network 面板里看请求 URL,如果显示的是 localhost:5173/api/xxx,但 Response 正常,说明代理生效。

运行过程中如果出现“前端能访问但接口超时”的问题,检查顺序一定是:后端端口是否监听、防火墙是否放行、代理配置是否正确、数据库连接是否正常。这个排查顺序能解决八成的问题。

点击线路详情页然后测试下单。登录测试账号,选择线路,填写出行人信息,提交订单后会看到订单状态变为待支付。前端模拟支付成功,订单状态变成已支付/待出行。在后台管理端看到这条订单并确认完成,前端“我的订单”里状态同步变成已完成。这一套流程跑通,说明前后端接口、数据库更新、状态流转全部正常。

8. 再分享几个开发中的实在心得

整个项目做下来,我最大的体会是:写代码之前,把状态流转和数据表结构想清楚,能省掉后面至少三分之一的返工时间。订单状态这个东西,一开始定义不清楚,后面每加一个功能都要回调修改,非常痛苦。以后我无论做什么项目,都会在动手前先整理一份简单的状态机文档,哪怕只是草稿级别的表格也好。

配置文件多环境隔离是个好习惯。项目里我分了 dev、prod 两套配置,数据库密码生产环境用环境变量覆盖。这个习惯一开始就坚持,避免上线前手忙脚乱地改配置。很多开发者把密码直接写死在代码里,这是一个极其危险的习惯。哪怕你的项目只是学习Demo,也养成环境变量管理敏感配置的好习惯,以后进企业开发会受益很多。

给想做二次开发的朋友一个真诚的建议:不要一上来就追求高大上的功能。先把核心闭环跑通,用户能浏览、能下单、管理员能处理订单,这套系统已经能用了。在核心功能稳定之后,再逐步加支付对接、短信通知、数据报表这些增强功能。一次塞太多需求,技术债积压到最后,很可能连核心功能都保不住。

这套旅游管理系统的开发复盘讲到这里基本上说透了。如果你正在用同一套技术栈做类似项目,或者在这个基础上二次开发遇到具体问题,欢迎按同样的思路去排查——先查数据表结构,再查接口日志,最后看前端渲染,大多数问题都能定位得很准。后面有新的实践我会再补充,就这样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询