资讯动态

SpringBoot+Vue3+MyBatis旅游指南系统开发实践与踩坑总结

发布时间:2026/9/30 19:35:57 来源:尧图企业网站定制
1. 为什么选 SpringBootVue3MyBatis 这套组合做旅游指南系统先说结论旅游出行指南这类系统在国内中小型项目里几乎是 SpringBoot Vue3 MyBatis MySQL 的“标准答案”。我接手过好几个类似的业务项目从最早的传统 JSP 单体应用到后来前后端分离的微服务架构踩了一圈回来发现这套技术栈不是最花哨的但一定是最稳妥、最好招人、最好维护的。为什么这么说先看业务本身。旅游出行指南系统的核心功能无非是景点信息展示、旅游路线推荐、攻略文章发布、用户收藏评论、行程规划、后台管理这些。它没有高并发秒杀场景没有海量数据实时计算需求也没有复杂的分布式事务。这类业务的特点是CRUD 密集、权限模型清晰、数据一致性要求中等、开发周期紧。SpringBoot 在这类场景下最大的优势就是“开箱即用”——内嵌 Tomcat、自动配置、起步依赖少写大量配置代码。我见过太多团队为了“技术先进”硬上微服务结果一个旅游项目拆出七八个服务光是服务间调用链排查就耗掉一半工时。没必要。Vue3 这边组合式 API 带来的代码组织方式确实更适合中后台页面。景点详情、攻略列表、个人中心、管理后台这些页面之间有很多共享逻辑比如用户登录态、收藏状态、行程草稿用 setup 语法把逻辑按功能聚合比 Options API 按选项分散要清晰得多。而且 Vite 的开发体验比 Webpack 时代快了一个量级改完代码热更新基本是秒级。团队里有接触过 Vue2 的成员迁到 Vue3 的曲线也比较平缓。MyBatis 的选择就更实际了。旅游系统的查询逻辑非常灵活——景点列表要根据地区、分类、评分、价格、季节多个维度组合筛选攻略搜索要支持标题模糊匹配和标签过滤这些 SQL 的形态差异很大。MyBatis 的动态 SQL 在这种场景下几乎是天然适配的。如果你用 MyBatis-Plus分页插件和条件构造器还能把单表查询的开发量再压缩一截。另一个隐形好处是招人容易国内 Java 后端面试必问 MyBatis大多数候选人上手就能写沟通成本低。数据库选 MySQL 没什么好争议的。旅游数据的体量单表撑到几百万条已经算很多了InnoDB 引擎配合合理的索引设计完全扛得住。用 MySQL 8.0 的话窗口函数、JSON 类型、公共表表达式这些特性在处理景点标签、攻略富文本数据时也很顺手。唯一要注意的是字符集必须用 utf8mb4不然用户评论里带个 emoji 就写入报错——这个问题我后面会详细说属于“能跑但迟早爆雷”的典型坑。这套组合还有个容易被忽视的好处前后端分离以后接口文档可以完全交给后端维护前端按契约开发。旅游指南类项目经常需要跟第三方地图、天气、票务接口对接后端统一封装外部接口、对外输出规范 JSON前端不直接接触第三方 SDK权限控制和数据清洗都在服务端完成整体安全性也好很多。我在实际项目里习惯用 Apifox 维护接口文档后端写完接口自动生成文档前端直接在文档上 mock 数据联调效率比传统的 Word 文档高出好几个档次。2. 数据库设计旅游业务的核心表结构与建模思路旅游出行指南系统的数据库设计决定了下游所有功能的开发效率。我见过太多项目先写代码后补表结构结果做到一半发现“用户收藏”和“用户点赞”分成了两张表或者景点表和攻略表之间没有关联字段只能靠代码里循环查询凑数据。这些问题的根源都是建模阶段没有把业务关系理清楚。2.1 核心表结构设计我在这类项目里比较常用的核心表有这几张用户表member、景点表scenic_spot、攻略文章表article、收藏表favorite、评论表comment、行程规划表trip_plan、旅游路线表route、标签表tag以及景点与标签的关联表。标签表的设计尤其重要景点分类、季节推荐、主题玩法亲子游、摄影游、美食游都可以用标签体系来承载比在每个景点表里加一堆布尔字段灵活得多。拿景点表举个例子我一般这样设计CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, name VARCHAR(100) NOT NULL COMMENT 景点名称, location VARCHAR(255) NOT NULL COMMENT 所在地区, latitude DECIMAL(10, 7) NOT NULL COMMENT 纬度, longitude DECIMAL(10, 7) NOT NULL COMMENT 经度, description TEXT COMMENT 景点简介, cover_image VARCHAR(500) COMMENT 封面图URL, ticket_price DECIMAL(10, 2) DEFAULT 0.00 COMMENT 门票价格, open_time VARCHAR(100) COMMENT 开放时间, rating DECIMAL(2, 1) DEFAULT 0.0 COMMENT 综合评分, visit_count BIGINT DEFAULT 0 COMMENT 访问量, status TINYINT DEFAULT 1 COMMENT 状态0下架1上架, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_location (location), KEY idx_rating (rating) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点信息表;这里有几个容易忽略的点。经纬度字段务必用 DECIMAL 而不是 FLOATFLOAT 的精度误差会导致地图展示时景点位置漂移虽然看起来就差那么零点几秒但用户在高德或百度地图上一对标就能发现不对。搜周边景点的时候地理范围查询用的是经纬度运算精度太差查出来的范围圈就是歪的。访问量这个字段我建议直接在景点表上加一个计数器列。每次用户访问景点详情页就 UPDATE visit_count visit_count 1不要单独建一张访问日志表去统计。旅游项目访问量数据的实时性要求不高但展示频率很高单表计数是最省事的方案。等以后数据量真的大了再考虑引入 Redis 或者 ClickHouse 做异步统计前期完全没必要过度设计。2.2 用户与收藏、评论的关系建模用户表单独拆出来是肯定的但要注意第三方登录的兼容设计。旅游指南系统一般会接微信登录我习惯在一开始就预留 openid 和 unionid 字段哪怕第一期只做账号密码登录。否则后面接微信授权的时候要么在用户表上加字段做迁移要么重新建一张第三方绑定表都很麻烦。注册来源 register_source 这个字段也建议加上方便区分自然注册用户和第三方拉新用户运营那边做数据报表经常要看这个维度。收藏表和评论表的设计关键在唯一约束。收藏表的业务逻辑是“一个用户对同一个景点只能收藏一次”所以唯一索引必须建在 (user_id, target_type, target_id) 上。target_type 用来区分收藏的是景点还是攻略文章这样一个通用收藏表就同时覆盖了两种业务场景。如果没有这个唯一约束前端多点几次收藏按钮后端接口又没有做幂等处理的话收藏表里就会刷出好几条重复记录。评论表的设计要考虑层级评论和点赞数。我用的方案是 parent_id 字段记录父评论 ID为 0 表示顶级评论。点赞数用冗余字段存储每次点赞通过 UPDATE 语句原子自增。这里有一个性能细节查询某个景点评论列表的时候如果直接用递归查询所有子评论数据量大以后会非常慢。实际项目里通常只查两级——顶级评论和它的直接回复再深的层级就折叠处理够用就好。2.3 行程规划与路线的表结构行程规划是旅游指南系统里比较有特色的功能。一张行程单包含多天每天包含多个景点或活动这在关系型数据库里是一个典型的一对多嵌套结构。我一般拆三张表行程主表 trip_plan记录行程名称、开始日期、天数、创建人、行程明细表 trip_plan_item记录某天某个时间段去哪个景点、以及行程与路线的关联。CREATE TABLE trip_plan_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL COMMENT 行程主表ID, day_number INT NOT NULL COMMENT 第几天, spot_id BIGINT NOT NULL COMMENT 关联景点ID, visit_time VARCHAR(20) COMMENT 建议游玩时段如上午/下午, duration_hours DECIMAL(3,1) DEFAULT 2.0 COMMENT 建议停留小时数, note VARCHAR(500) COMMENT 备注, sort_order INT DEFAULT 0 COMMENT 同一天内的排序, KEY idx_plan_day (plan_id, day_number, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT行程明细表;行程明细表的核心是排序字段。用户在 Web 端拖拽调整景点顺序前端传一个有序的景点 ID 数组后端批量更新 sort_order。这个字段用 INT 类型每次拖拽保存时按顺序生成 10、20、30 这种间隔值方便以后在中间插入新项目而不用重排整个列表。路线表则相对简单主要是预置的推荐路线比如“三日经典游”“亲子两日游”每条路线关联多个景点ID用逗号分隔存储或者用关联表都行。我倾向用关联表因为路线里的景点顺序要通过 sort_order 明确表达逗号分隔字符串虽然简单但要改顺序就得整个字符串重写关联表扩展性和可查询性都好很多。3. 后端核心模块拆解从登录鉴权到景点推荐接口3.1 项目结构与登录鉴权实现SpringBoot 项目的包结构我习惯按业务模块划分而不是按技术层划分。很多新手喜欢建 controller、service、mapper 三个顶级包结果业务一复杂每个包下几十个文件找个类都要翻半天。按模块划分的话顶层是 system用户、角色、权限、spot景点、评论、收藏、article攻略、plan行程、common公共工具和配置每个模块内部再分 controller、service、mapper 分层。这样无论是开发时定位代码还是以后拆分微服务边界都很清晰。登录鉴权这块旅游指南系统的典型方案是 JWT Spring Security 或者 JWT 拦截器。我的经验是如果后台管理端和用户端是同一个后端服务直接用 Spring Security 比较规范因为它天然支持不同角色不同权限的路径配置如果两个端完全分离拦截器方案反而更轻量。这里给一个参考的 Security 配置思路Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /api/spot/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }JWT 令牌的生成和校验有几个细节值得注意。签名密钥不要硬编码在代码里放配置中心或者环境变量里防止源码泄露后令牌被伪造。令牌有效期要区分用户端和后台管理端——我通常把用户端有效期设为 7 天管理端设为 2 小时因为后台口令泄露的风险更高短过期能减少损失。刷新令牌的接口要注意做设备指纹校验至少比对一下 IP 和 User-Agent防止令牌被跨设备滥用。3.2 登录接口的完整链路登录接口看起来简单实际落地时涉及好几个容易被忽略的点。POST /api/auth/login 接收账号密码校验通过后返回 JWT 令牌和用户基本信息。在 Service 层我习惯在登录成功后把用户脱敏信息昵称、头像、ID写入 Redis键为 token 的某个稳定标识方便后续接口快速获取当前用户而不需要每次都查数据库。Redis 里再做一层过期时间控制用户注销或改密码时直接删掉 Redis 里的会话比黑名单方案简单可靠。密码存储必须用 BCrypt。SHA-256 加盐虽然也能用但 BCrypt 内置盐值且哈希速度可调暴力破解成本高得多。Spring Security 自带的 BCryptPasswordEncoder 直接用即可注意不要自己发明加密算法。我曾经见过一个项目用 MD5 连续加密两次当密码存储被脱库以后用户在其他平台的账号也遭殃了这种教训代价太大。3.3 景点推荐与搜索接口的动态 SQL 设计旅游指南系统的核心查询接口集中在景点列表和搜索上。这里就是 MyBatis 动态 SQL 的主场。搜索条件通常是关键词景点名称或简介模糊匹配、地区、标签、价格区间、评分阈值、排序方式。如果用 Java 代码逐层 if 拼接 SQL或者用 QueryWrapper 硬写条件构造器复杂场景下可读性很差。MyBatis 的 XML 映射文件反而最直观select idsearchSpots resultTypecom.example.vo.SpotVO SELECT s.*, GROUP_CONCAT(t.tag_name) AS tagNames FROM scenic_spot s LEFT JOIN spot_tag_rel r ON s.id r.spot_id LEFT JOIN tag t ON r.tag_id t.id where if testkeyword ! null and keyword ! AND (s.name LIKE CONCAT(%, #{keyword}, %) OR s.description LIKE CONCAT(%, #{keyword}, %)) /if if testlocation ! null and location ! AND s.location #{location} /if if testminPrice ! null AND s.ticket_price gt; #{minPrice} /if if testmaxPrice ! null AND s.ticket_price lt; #{maxPrice} /if if testtagId ! null AND EXISTS (SELECT 1 FROM spot_tag_rel r2 WHERE r2.spot_id s.id AND r2.tag_id #{tagId}) /if /where GROUP BY s.id choose when testsortType ratingORDER BY s.rating DESC/when when testsortType price_ascORDER BY s.ticket_price ASC/when otherwiseORDER BY s.visit_count DESC/otherwise /choose /select这个 SQL 的关键点在于标签过滤用了 EXISTS 子查询而不是 JOIN 后 WHERE tag.id #{tagId}。如果直接 JOIN同一个景点关联多个标签时会出现重复行必须配合 DISTINCT 或 GROUP BY 去重性能损耗不小。EXISTS 子查询在 tag 表命中索引的情况下效率更高而且语义更清晰只要存在一条关联记录满足条件就算命中。排序用 choose 标签做多条件分支比在 Java 代码里拼接 ORDER BY 字符串更安全——那种方式很容易被 SQL 注入。实际项目里我见过有人直接把前端传的 sortField 拼进 ORDER BY这绝对是高危操作。排序字段必须白名单校验前端只能传约定的枚举值。分页用 PageHelper 或 MyBatis-Plus 的分页插件都行。注意分页插件必须配置合理的数据库方言MySQL 下它会自动生成 LIMIT 语句。另外统计总数这条 SQL 在大数据量下可能比较慢PageHelper 默认会执行 COUNT 查询如果发现列表接口响应慢可以手动指定 countSql 或者关闭自动 COUNT用估算值代替。3.4 第三方接口对接与数据缓存旅游指南系统免不了对接地图、天气、票务类第三方接口。后端统一封装第三方调用的原则是第三方接口的 URL、密钥、超时时间全部放配置文件通过 ConfigurationProperties 注入到专门的 Client 类中。超时时间要根据第三方接口的响应特性单独设置地图服务通常 3 秒左右天气服务可以放宽到 5 秒但必须做降级——超时或异常时返回兜底数据不能让第三方故障拖垮整个景点详情页。天气数据这种时效性强但不要求实时的内容非常适合缓存。我的做法是第一次请求某城市的天气时调用第三方接口结果写入 Redis 并设置 30 分钟过期过期后重新拉取。这样第三方接口的调用量能减少九成以上。缓存更新策略还有一个细节不要直接在代码里写死 30 分钟做成配置项方便运营根据季节和第三方限流策略调整。景点详情的热门数据和攻略列表同样适合缓存。热点景点详情页 QPS 高如果每次请求都穿透到 MySQL压力会很大。用 Redis 缓存详情 JSON设置 10 到 15 分钟过期配合缓存空值防止缓存穿透基本就能扛住日常流量。不过要记住涉及用户个性化数据的接口比如“当前用户是否已收藏这个景点”不能简单地整体缓存必须把用户维度的信息剥离出来单独查询否则会把别人的收藏状态串给当前用户。这个坑我实际踩过后面前后端联调的时候被前端同事抓了个正着。4. 前端 Vue3 落地细节页面结构、状态管理与接口对接4.1 Vite 工程搭建与基础配置Vue3 项目我直接用 Vite 创建不再走 vue-cli 的老路。初始化命令是 npm create vitelatest tourist-web -- --template vue-tsTypeScript 建议从一开始就启用旅游指南系统这种业务模型清晰的项目类型定义能给前后端对接带来很大帮助。工程创建后有几项配置要立刻处理。第一是路径别名在 vite.config.ts 里配置 指向 src 目录import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这里的代理配置特别重要。前后端分离开发最大的痛点就是跨域Vite 的 proxy 把前端请求 /api 开头的接口代理到后端地址开发环境完全不需要后端开启 CORS。记住 changeOrigin 必须设为 true否则后端拿到请求的 Host 还是前端的地址某些依赖域名的第三方回调会出问题。生产环境则通过 Nginx 配置反向代理解决跨域这个后面部署章节再说。第二是路由设计。旅游指南系统的页面分两大类用户端景点列表、景点详情、攻略列表、攻略详情、我的收藏、行程规划、登录注册和管理端景点管理、攻略管理、评论管理、用户管理、数据统计。用户端用普通路由管理端建议单独拆一个布局并配置路由守卫做登录鉴权router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.path.startsWith(/admin) !isAdmin()) { next({ path: /403 }) return } next() })4.2 Pinia 状态管理用户会话与全局数据Vue3 的状态管理我用 Pinia 不用 Vuex。Pinia 对 TypeScript 的支持更好没有 mutations 的概念直接改 state代码量少很多。旅游指南系统最核心的全局状态是用户信息和站点配置。用户会话管理建议做成独立的 store。登录成功后把 token 存 localStorage刷新页面不丢登录态把用户基本信息存 Pinia同时持久化到 localStorage 一份这样刷新页面后可以先从 localStorage 恢复用户信息再通过 /api/auth/me 接口校验 token 是否仍然有效。这个校验很关键因为 token 可能已经过期或退出登录不及时清理的话会出现“假登录”状态——页面上显示着用户头像实际访问任何需要登录的接口都是 401。// stores/user.ts export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refUserInfo | null(null) async function login(username: string, password: string) { const res await api.login({ username, password }) token.value res.token userInfo.value res.userInfo localStorage.setItem(token, res.token) localStorage.setItem(userInfo, JSON.stringify(res.userInfo)) } function logout() { token.value userInfo.value null localStorage.removeItem(token) localStorage.removeItem(userInfo) } return { token, userInfo, login, logout } })接口请求的封装同样要统一。我习惯在 src/api 目录下按业务模块建文件每个模块导出一个对象方法内部调用封装好的 request 实例。request 实例基于 axios 创建核心逻辑有两块请求拦截器里自动带上 Authorization 头响应拦截器里统一处理业务错误码和 HTTP 状态码。响应拦截器是最容易写错的地方。不要只判断 HTTP 200后端返回的数据通常是 { code: 200, data: {}, message: ok } 这种包装结构业务层面的成功与否要看 code 字段。当 code 表示业务失败时统一弹出错误提示并区分是否需要强制跳转登录页——比如 401 状态码出现时清除本地登录信息然后跳转 /login其他业务错误只提示不跳转。如果这些逻辑散落在每个页面里后期的维护成本会很高。4.3 移动端适配与地图展示旅游指南系统的用户很大比例会在手机上访问Vue3 项目必须考虑移动端适配。我通常的方案是 rem 适配配合 viewport 设置。先通过 postcss-pxtorem 插件把所有 px 自动转成 rem然后根据屏幕宽度动态设置根字号。注意详情页的大图轮播和地图区域要用 vw/vh 或者百分比布局不能用固定高度否则不同手机上会出现地图显示不完全的问题。地图展示是旅游指南系统的特色页面。Vue3 里集成高德地图或百度地图核心是动态加载 SDK。不要在 index.html 里直接引入地图 JS而是写一个加载器方法在组件挂载时按需加载。地图组件必须处理组件卸载时的销毁逻辑否则切换页面会留下地图实例内存泄漏严重。多个景点标注点时最好用信息窗体而不是弹窗组件地图自带的信息窗体在移动端滚动体验更流畅。标记点的点击事件处理要注意闭包问题循环创建标记点时把景点数据用函数传参的方式绑定直接引用循环变量会导致所有标记点点击后都弹出最后一个景点的信息。4.4 后台管理页面的表格与表单实践后台管理端页面本质上是大量的表格和表单这块用 Element Plus 组件库最省心。表格的封装我建议抽一个公共的 ProTable 组件统一处理加载状态、分页、空数据展示和列配置。列配置用数组 渲染函数的模式灵活度最高pro-table :columnscolumns :fetch-datafetchSpotList :paramssearchParams row-keyid /这背后是把分页参数current、pageSize、排序参数、搜索条件统一封装起来页面里只需要维护 searchParams 一个响应式对象搜索条件变化时表格自动重新请求数据。省下来的重复代码在景点管理、攻略管理、评论管理这些页面里非常可观。表单层的核心是校验规则。Element Plus 的表单校验用 async-validator自定义校验器可以做各种业务规则比如行程开始日期不能早于今天票价必须大于 0坐标范围必须符合经纬度范围。我强烈建议把校验规则抽到独立的 schema 文件里跟表单组件解耦这样后端接口的字段约束变了只需要改配置文件不用动组件代码。富文本编辑器在攻略发布页面是必需品推荐 wangEditor 的 Vue3 版本轻量且文档全。富文本内容存 HTML 字符串注意后端保存时要过滤危险标签并限制内容长度防止 XSS 和其他异常数据写入。5. MyBatis 踩坑实录缓存、TypeHandler 与动态 SQLMyBatis 用久了总会遇到几个绕不开的坑。我在这个项目的开发过程中就碰到了三个比较典型的单独拿出来说说希望能帮后来的人少走弯路。5.1 一级缓存导致的脏读问题MyBatis 的一级缓存默认开启作用域是 SqlSession也就是同一个会话内同样的查询条件会直接命中缓存返回上次的结果不再查数据库。听起来是好事但在 SpringBoot 集成环境下如果不小心把 SqlSession 的生命周期拉长一级缓存反而会变成脏数据源。典型场景是事务方法中先查询一个景点然后另一个线程或同一个事务内部修改了这条数据事务没提交前再查同一条记录拿到的是旧值。解决方式有两种。最保守的是在读取敏感数据的查询语句上设置 flushCachetrue强制每次都查数据库。更好的做法是了解缓存的作用域尽量让查询和更新操作分布在不同的 SqlSession 中使用默认的 SqlSessionTemplate 时每次操作都会获取新的 SqlSession影响其实有限。真正要担心的是二级缓存——默认关闭我不建议在旅游指南系统里开启因为景点数据更新频繁且缓存失效策略不好控制开启后经常出现“改了数据库但前端还是旧数据”的诡异问题。如果确实需要查询缓存直接用 Redis 做业务级缓存可控性远高于 MyBatis 的二级缓存。5.2 TypeHandler 处理 JSON 字段旅游系统里很多字段是 JSON 结构最典型的是景点标签数组和攻略文章的扩展属性。MySQL 8 支持原生的 JSON 类型但 MyBatis 默认的 JDBC 处理方式不能直接把 JSON 字符串映射成 Java 的 List String 或自定义对象。这时候需要自定义 TypeHandler。MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandlerListString { Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSONUtil.toJsonStr(parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? new ArrayList() : JSONUtil.toList(value, String.class); } // getNullableResult 的其他重载方法省略 }定义好 TypeHandler 后在实体类的对应字段上标注TableField(typeHandler StringListTypeHandler.class) private ListString tags;如果你用了 MyBatis-Plus注意查询返回时如果 TypeHandler 不生效多半是没配 mapUnderscoreToCamelCase 或者 XML 里的 resultMap 没指定 typeHandler。这类问题排查起来比较费神建议在建表阶段就想清楚哪些字段会用 JSON 类型提前把 TypeHandler 定义好并写进公共配置而不是遇到一个写一个。5.3 动态 SQL 的拼接陷阱动态 SQL 用多了以后我总结出几条硬性纪律。第一where 标签比直接用 WHERE 11 好得多where 标签会自动去掉第一个多余的 AND 或 ORSQL 更干净。第二foreach 批量插入、批量更新时集合参数为空必须提前判断否则生成出来的 SQL 是 INSERT INTO xxx VALUES () 这种语法错误。第三动态 SQL 里的比较运算符要转义比如小于号要写成 不然 XML 解析直接报错。批量更新 sort_order 这种场景我推荐用 case when 拼接update idbatchUpdateSortOrder foreach collectionitems itemitem open close separator; UPDATE trip_plan_item SET sort_order #{item.sortOrder} WHERE id #{item.id} /foreach /update这种用分号分隔的多条 UPDATE 在 MySQL 连接参数里必须加 allowMultiQueriestrue否则会被拦截。如果不想开这个参数就用 case when 的写法拼成单条 SQL性能更好update idbatchUpdateSortOrder UPDATE trip_plan_item SET sort_order CASE id foreach collectionitems itemitem WHEN #{item.id} THEN #{item.sortOrder} /foreach END WHERE id IN foreach collectionitems itemitem open( separator, close) #{item.id} /foreach /update6. 前后端联调、环境配置与上线部署6.1 应用配置与环境隔离SpringBoot 项目从开发到上线环境配置必须分层管理。我在 application.yml 里使用 spring.profiles.active 区分 dev、test、prod 三套环境每套环境对应一个 application-{profile}.yml 文件。数据库连接、Redis 地址、第三方接口密钥、日志级别都在对应环境文件里配置。最忌讳的是把生产环境的数据库密码写在默认 application.yml 里并提交到 Git 仓库密码一旦进过版本历史即使后来删除也仍然存在泄露风险。生产环境密钥建议用环境变量或配置中心注入本地 application-prod.yml 只放占位符 ${DB_PASSWORD}。数据库连接串上额外注意几个参数。characterEncodingutf8 和 useSSLfalse 要显式配置尤其是 MySQL 8 默认开启 SSL 连接不关闭的话启动时会报警告甚至直接报 ssl 连接错误。serverTimezoneAsia/Shanghai 必须配否则日期类型的数据会比北京时间差 8 个小时——这个坑在前后端联调时非常隐蔽前端显示时间总是偏移查了半天发现是 JDBC 时区问题。6.2 常见的联调问题与排查链路前后端联调阶段有几个高频问题我按出现概率排个序。接口 404大概率是路径问题。前端请求 /api/spot/list后端 Controller 映射是 /spot/list差了一层 /api。解决方案是后端统一在 application.yml 配置 context-path: /api或者前端代理时统一 rewrite 掉 /api 前缀。我建议后端配 context-path这样后端接口文档里显示的路径和前端访问路径完全一致不用各记一套。接口 400通常不是参数错误而是 JSON 序列化问题。前端传的时间字符串格式和后端 LocalDateTime 的解析格式不匹配。统一做法是后端在 JSR310 模块里配置全局的 LocalDateTime 序列化格式为 yyyy-MM-dd HH:mm:ss前端 axios 请求也统一用这个格式传时间。接口 401 但不是没带 token要查请求拦截器是否在跨域预检请求OPTIONS时也带上了 Authorization。部分浏览器对带 Authorization 的跨域请求会先发 OPTIONS 预检后端如果没放行 OPTIONS 请求前端看到的表象是“明明带了 token 还是 401”。Spring Security 里要对 OPTIONS 请求直接放行.authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() ... )排查这些问题有个固定的套路。先用 Postman 或 Apifox 直接调后端接口确认后端自身是否正常再用浏览器的 Network 面板看请求和响应详情关注请求头、响应状态码和响应体最后查后端日志重点看异常堆栈的前三行。按链路一层层排除比盲改代码高效得多。我在联调阶段习惯给后端 Service 层的核心方法加上简明的日志输出入参打出关键字段出参打出结果条数这些日志在排查问题时就是最直接的线索。6.3 生产环境部署Docker Compose 方案小型旅游指南系统的生产环境我推荐用 Docker Compose 做一键部署比手动装 JDK、MySQL、Nginx 省心太多。整个部署方案包含四个容器后端服务、MySQL、Redis、Nginx 前端。一个 docker-compose.yml 就能描述完整拓扑version: 3.8 services: mysql: image: mysql:8.0 container_name: tourist-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: tourist_db ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci backend: build: ./backend container_name: tourist-backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - 8080:8080 nginx: image: nginx:1.24-alpine container_name: tourist-nginx ports: - 80:80 - 443:443 volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ./dist:/usr/share/nginx/html depends_on: - backend volumes: mysql_data:这里有两个细节。MySQL 容器的 command 参数显式指定字符集确保建库建表都是 utf8mb4比建表语句里逐个表指定更保险。Nginx 容器把前端构建产物 dist 目录挂载进容器每次前端发版只需要替换宿主机上的 dist 目录并执行 nginx -s reload 即可不需要重新构建镜像。Nginx 配置的反向代理是上线成败的关键server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend: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 / { try_files $uri $uri/ /index.html; } }try_files 那一行是 history 路由模式的标配它把所有不存在的路径都指向 index.html由 Vue Router 自己去匹配路由否则用户在浏览器里刷新 /spot/123 这个地址会直接 404。这个配置漏了前端的所有二级页面在刷新后都会白屏是部署阶段最容易踩的坑。6.4 MySQL 初始化与备份策略init.sql 放的是建库建表的初始化脚本但我要提醒一句不要把测试数据也放进去。生产环境初始化只需要表结构和基础字典数据比如标签表、公告表测试数据应该由后端启动时的数据初始化逻辑或者单独的 seed 脚本处理不然运维人员会看到一堆莫名其妙的演示数据。备份策略我特别强调一下。旅游指南系统的核心资产是运营产生的攻略内容和用户收藏、行程数据这类数据丢失后基本无法恢复。至少配置每天凌晨的 mysqldump 全量备份保留最近 7 天。更稳的做法是 binlog 开启并定期归档这样即使某天的备份文件损坏也能通过 binlog 将数据恢复到最近的时间点。备份文件不要跟数据库放在同一台服务器上用 cron 脚本把备份文件同步到对象存储或者另一台机器否则服务器宕机、磁盘损坏时备份也跟着没了。我在实际项目里还养成了一个习惯上线前先在预发布环境完整跑一遍初始化流程从空的 MySQL 实例开始执行 init.sql启动后端检查日志有没有报错再验证关键接口的数据返回。这一步能发现很多环境依赖问题——比如某张表的字段类型在不同 MySQL 版本的默认行为有差异或者某个字符集相关的配置只在生产环境才报错。预发布环境跑通了线上才有底气。最后再分享一个小经验。旅游指南系统这类项目功能边界很清晰、业务模型稳定非常适合作为前后端分离的练手或者二次开发基座。拿到源码之后我的建议是先别急着改代码而是先把数据库表结构和主要接口的请求响应字段梳理清楚画一张“页面 — 接口 — 数据表”的对应关系图。这张图花不了多少时间但对后续任何二次开发都有决定性帮助因为大部分需求改动都绕不开这三层的联动。我在这个项目上就是靠这张图把团队几个新人的上手周期从两周压缩到了三天。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑