☰
SpringBoot+Vue3+MyBatis旅游指南系统开发实践与踩坑总结
2026/9/30 19:35:48 网站建设 项目流程

1. 为什么选 SpringBoot+Vue3+MyBatis 这套组合做旅游指南系统

先说结论:旅游出行指南这类系统,在国内中小型项目里几乎是 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点信息表';

这里有几个容易忽略的点。经纬度字段务必用 DECIMAL 而不是 FLOAT,FLOAT 的精度误差会导致地图展示时景点位置漂移,虽然看起来就差那么零点几秒,但用户在高德或百度地图上一对标就能发现不对。搜周边景点的时候,地理范围查询用的是经纬度运算,精度太差查出来的范围圈就是歪的。

访问量这个字段,我建议直接在景点表上加一个计数器列。每次用户访问景点详情页就 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 id="searchSpots" resultType="com.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 test="keyword != null and keyword != ''"> AND (s.name LIKE CONCAT('%', #{keyword}, '%') OR s.description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="location != null and location != ''"> AND s.location = #{location} </if> <if test="minPrice != null"> AND s.ticket_price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND s.ticket_price &lt;= #{maxPrice} </if> <if test="tagId != 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 test="sortType == 'rating'">ORDER BY s.rating DESC</when> <when test="sortType == 'price_asc'">ORDER BY s.ticket_price ASC</when> <otherwise>ORDER 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 vite@latest tourist-web -- --template vue-ts,TypeScript 建议从一开始就启用,旅游指南系统这种业务模型清晰的项目,类型定义能给前后端对接带来很大帮助。

工程创建后有几项配置要立刻处理。第一是路径别名,在 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 = ref<UserInfo | 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 :columns="columns" :fetch-data="fetchSpotList" :params="searchParams" row-key="id" />

这背后是把分页参数(current、pageSize)、排序参数、搜索条件统一封装起来,页面里只需要维护 searchParams 一个响应式对象,搜索条件变化时表格自动重新请求数据。省下来的重复代码在景点管理、攻略管理、评论管理这些页面里非常可观。

表单层的核心是校验规则。Element Plus 的表单校验用 async-validator,自定义校验器可以做各种业务规则,比如行程开始日期不能早于今天,票价必须大于 0,坐标范围必须符合经纬度范围。我强烈建议把校验规则抽到独立的 schema 文件里,跟表单组件解耦,这样后端接口的字段约束变了,只需要改配置文件,不用动组件代码。富文本编辑器在攻略发布页面是必需品,推荐 wangEditor 的 Vue3 版本,轻量且文档全。富文本内容存 HTML 字符串,注意后端保存时要过滤危险标签并限制内容长度,防止 XSS 和其他异常数据写入。

5. MyBatis 踩坑实录:缓存、TypeHandler 与动态 SQL

MyBatis 用久了总会遇到几个绕不开的坑。我在这个项目的开发过程中就碰到了三个比较典型的,单独拿出来说说,希望能帮后来的人少走弯路。

5.1 一级缓存导致的脏读问题

MyBatis 的一级缓存默认开启,作用域是 SqlSession,也就是同一个会话内,同样的查询条件会直接命中缓存返回上次的结果,不再查数据库。听起来是好事,但在 SpringBoot 集成环境下,如果不小心把 SqlSession 的生命周期拉长,一级缓存反而会变成脏数据源。典型场景是事务方法中先查询一个景点,然后另一个线程(或同一个事务内部)修改了这条数据,事务没提交前再查同一条记录,拿到的是旧值。

解决方式有两种。最保守的是在读取敏感数据的查询语句上设置 flushCache="true",强制每次都查数据库。更好的做法是了解缓存的作用域,尽量让查询和更新操作分布在不同的 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 BaseTypeHandler<List<String>> { @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSONUtil.toJsonStr(parameter)); } @Override public List<String> 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 List<String> tags;

如果你用了 MyBatis-Plus,注意查询返回时如果 TypeHandler 不生效,多半是没配 mapUnderscoreToCamelCase 或者 XML 里的 resultMap 没指定 typeHandler。这类问题排查起来比较费神,建议在建表阶段就想清楚哪些字段会用 JSON 类型,提前把 TypeHandler 定义好并写进公共配置,而不是遇到一个写一个。

5.3 动态 SQL 的拼接陷阱

动态 SQL 用多了以后,我总结出几条硬性纪律。第一,where 标签比直接用 WHERE 1=1 好得多,where 标签会自动去掉第一个多余的 AND 或 OR,SQL 更干净。第二,foreach 批量插入、批量更新时,集合参数为空必须提前判断,否则生成出来的 SQL 是 INSERT INTO xxx VALUES () 这种语法错误。第三,动态 SQL 里的比较运算符要转义,比如小于号要写成 <,不然 XML 解析直接报错。

批量更新 sort_order 这种场景,我推荐用 case when 拼接:

<update id="batchUpdateSortOrder"> <foreach collection="items" item="item" open="" close="" separator=";"> UPDATE trip_plan_item SET sort_order = #{item.sortOrder} WHERE id = #{item.id} </foreach> </update>

这种用分号分隔的多条 UPDATE 在 MySQL 连接参数里必须加 allowMultiQueries=true,否则会被拦截。如果不想开这个参数,就用 case when 的写法拼成单条 SQL,性能更好:

<update id="batchUpdateSortOrder"> UPDATE trip_plan_item SET sort_order = CASE id <foreach collection="items" item="item"> WHEN #{item.id} THEN #{item.sortOrder} </foreach> END WHERE id IN <foreach collection="items" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>

6. 前后端联调、环境配置与上线部署

6.1 应用配置与环境隔离

SpringBoot 项目从开发到上线,环境配置必须分层管理。我在 application.yml 里使用 spring.profiles.active 区分 dev、test、prod 三套环境,每套环境对应一个 application-{profile}.yml 文件。数据库连接、Redis 地址、第三方接口密钥、日志级别都在对应环境文件里配置。最忌讳的是把生产环境的数据库密码写在默认 application.yml 里并提交到 Git 仓库,密码一旦进过版本历史,即使后来删除也仍然存在泄露风险。生产环境密钥建议用环境变量或配置中心注入,本地 application-prod.yml 只放占位符 ${DB_PASSWORD}。

数据库连接串上额外注意几个参数。characterEncoding=utf8 和 useSSL=false 要显式配置,尤其是 MySQL 8 默认开启 SSL 连接,不关闭的话启动时会报警告甚至直接报 ssl 连接错误。serverTimezone=Asia/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-server=utf8mb4 --collation-server=utf8mb4_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 版本的默认行为有差异,或者某个字符集相关的配置只在生产环境才报错。预发布环境跑通了,线上才有底气。

最后再分享一个小经验。旅游指南系统这类项目,功能边界很清晰、业务模型稳定,非常适合作为前后端分离的练手或者二次开发基座。拿到源码之后,我的建议是先别急着改代码,而是先把数据库表结构和主要接口的请求响应字段梳理清楚,画一张“页面 — 接口 — 数据表”的对应关系图。这张图花不了多少时间,但对后续任何二次开发都有决定性帮助,因为大部分需求改动都绕不开这三层的联动。我在这个项目上就是靠这张图,把团队几个新人的上手周期从两周压缩到了三天。

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

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

立即咨询