
1. 从零到上线文旅网站项目定位与技术选型接到“七彩云南文化旅游网站”这个需求的时候我第一反应不是“云南好玩”而是“这类网站的信息架构怎么组织才不混乱”。做过内容型站点的人都知道文旅网站最大的难点根本不是技术而是“景点、线路、游记、攻略”这些内容之间怎么发生关系用户进来之后怎么逛、怎么留、怎么找。先说结论这是一个典型的前后端分离项目后端用 SpringBoot 2.7 MyBatis MySQL 8.0前端用 Vue3 Vite Pinia Element Plus两者通过 JSON 接口通信开发时用 Vite 代理解决跨域部署时后端打 jar 包、前端打包为静态资源。这套组合在中小型内容型项目里几乎是“标配级”方案成熟、稳定、出活快也非常适合拿来当毕业设计、自学项目或者中小型文旅企业的信息展示平台。它解决的问题很明确第一把云南各地州的景点、民族文化、民俗节庆、旅游线路等信息集中展示第二让游客能浏览景点、收藏路线、发布游记、评论互动第三给管理员一个后台能够维护景点数据、审核游记、管理用户。说白了就是一个“内容展示 用户互动 后台管理”三位一体的系统。如果你正准备做类似的项目这篇文章会把整个系统的设计思路、数据库表结构、后端接口组织、前端页面搭建、前后端联调以及我实际开发中踩过的坑全部展开讲一遍。无论你是新手学生还是有一定经验的开发者照着这个思路走能省下不少试错时间。1.1 为什么文旅网站必须做内容结构设计很多人拿到这种需求第一件事是建表景点表、用户表、评论表然后就开始写 CRUD。说实话这样做出来的网站会非常散用户进来之后不知道该看什么。我在动手前先梳理了信息架构。这个网站的用户其实分两类一类是游客想看哪里好玩、怎么去、吃什么、住哪里另一类是管理方想维护信息、审核内容、看访问情况。游客的路径是“首页推荐 → 景点详情 → 攻略游记 → 收藏/评论”管理员的路径是“登录后台 → 维护景点/线路 → 审核游记 → 管理用户”。基于这个路径我把系统拆成了前台门户和后台管理两大模块然后才去设计数据库。这一步做扎实了后面写代码就是流水线作业基本不用返工。1.2 技术栈选型不是炫技是权衡这里聊一下为什么最终定了 SpringBoot Vue3 MyBatis 这套组合而不是其他方案。后端用 SpringBoot是因为它对 MyBatis、MySQL、RESTful API 的集成非常典型。你几乎不需要配置什么 XML 文件加几个注解就能把 Controller 和 Service 串起来。相比 SSHStruts Spring Hibernate那套老古董SpringBoot 的开发效率高得不是一点半点。前端用 Vue3 而不是 Vue2是因为 Vue3 的 Composition API 在组件逻辑复用上更舒服而且生态已经非常成熟了。Vite 做开发服务器比 Webpack 快得多几乎是秒开热更新。MyBatis 和 MyBatis-Plus 之间我纠结了一下。最终选了原生 MyBatis一个原因是我需要完全掌控 SQL特别是景点和游记这种多表联查、动态条件筛选的场景XML 里写 SQL 更直观另一个原因是这个项目如果用 MyBatis-Plus 的 LambdaQueryWrapper 也能做但对新手来说反而容易“知其然而不知其所以然”。用原生 MyBatisresultMap 映射、动态 SQL、一对多嵌套查询这些核心技能都能练到。MySQL 8.0 没悬念免费、稳定、社区活跃。用 utf8mb4 字符集避免中文和 emoji 存储问题。1.3 功能模块的最终清单在开始设计表结构之前我先列出功能清单每个功能模块都能对应到具体的页面和接口模块功能点角色用户模块注册、登录、个人信息维护、收藏管理游客/管理员景点模块景点列表分页筛选搜索、景点详情、景点图片展示游客线路模块旅游线路推荐列表、线路详情游客游记模块发布游记、游记列表、游记详情、审核游客/管理员评论模块景点评论、游记评论、回复游客后台管理景点管理、线路管理、游记审核、用户管理、数据概览管理员功能清单确定之后数据库表设计基本就有谱了。这一步如果跳过后面写接口的时候一定会发现表少字段、表缺关联回头再改就是噩梦。2. 数据库设计文旅系统的信息骨架数据库设计是整个项目的地基。地基打不好后面所有楼层都会歪。我在这个项目里一共设计了 9 张表不算多但每张表都有它存在的道理字段也不是随手乱加的。2.1 核心表结构一览先给一张总览表你可以对照着看表名用途关键字段user用户表id, username, password, avatar, role, statusscenic_spot景点表id, name, summary, content, images, region, category, view_counttravel_route线路表id, title, days, price, spots, image, descriptiontravel_note游记表id, user_id, spot_id, title, content, images, statuscomment评论表id, user_id, target_type, target_id, content, create_timefavorite收藏表id, user_id, target_type, target_id, create_timebanner轮播图表id, image, link, sortadmin_user管理员表id, username, password, last_login_timeregion地区表id, name, parent_id, sort这里面有个设计细节值得展开讲。景点表里我用了 region 字段存储地州信息比如某州、某市但同时设计了独立的 region 表。为什么这么做因为一个用户可能想按“大理”“丽江”“香格里拉”这样的地州维度筛选景点独立建表之后后续要做地区维度的聚合统计、导航联动都是现成的数据结构。如果只是在景点表里存一个字符串后面做筛选就得 LIKE %丽江%效率低且容易出错。再比如收藏表 favorite我用了 target_type 和 target_id 这种“多态关联”设计。一个收藏动作可能收藏的是景点也可能是线路。单独建两张收藏表spot_favorite 和 route_favorite虽然查询起来简单但用户“我的收藏”页面就需要合并数据。用 target_type 区分类型一个接口就能搞定代码也干净。2.2 字段设计的几个实战原则这里我必须强调几个原则都是踩坑总结出来的。第一图片字段不要存路径列表字符串。很多新手喜欢把多张图片地址用逗号拼成一个字段比如 images url1,url2,url3。这种做法在展示时确实方便但之后想做“图片数量统计”“删除某张图片”都会变得很别扭。我的方案是景点图、游记图用 JSON 数组字符串存储因为通常是整体读取、整体替换分离出独立表反而过度设计。第二文本字段用 TEXT别用 VARCHAR。景点介绍、游记正文这种长文本VARCHAR 最多 65535 字节但实际意义不大而且 MyBatis 映射和前端富文本编辑器对接时TEXT 类型更稳。我用的是 TEXT 和 LONGTEXT足够用。第三所有表必须有 create_time 和 update_time。这不仅仅是“通用字段”的习惯问题。做内容管理系统后台要按时间排序、要筛选“本月新发布的景点”这些字段是必须的。我统一用 datetime 类型默认值设 CURRENT_TIMESTAMP。第四删除用逻辑删除不要物理删除。我加了 deleted 字段0 正常1 已删除。因为用户的收藏、评论、游记这些数据有关联关系物理删除会导致“收藏了不存在的景点”这种脏数据。后台管理的“删除”按钮本质上是 UPDATE deleted 1而不是 DELETE。2.3 建表 SQL 实战片段给大家看几张核心表的建表语句方便直接参考CREATE DATABASE IF NOT EXISTS yunnan_travel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE yunnan_travel; CREATE TABLE scenic_spot ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 景点名称, region_id bigint DEFAULT NULL COMMENT 所属地州ID, category varchar(50) DEFAULT NULL COMMENT 景点类型古镇/自然/民族风情等, summary varchar(500) DEFAULT NULL COMMENT 一句话简介, content text COMMENT 详细介绍, images json DEFAULT NULL COMMENT 图片列表, view_count bigint DEFAULT 0 COMMENT 浏览量, recommend tinyint DEFAULT 0 COMMENT 是否推荐首页, status tinyint DEFAULT 1 COMMENT 状态 1-上架 0-下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint DEFAULT 0, PRIMARY KEY (id), KEY idx_region (region_id), KEY idx_category (category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表; CREATE TABLE travel_note ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 作者ID, spot_id bigint DEFAULT NULL COMMENT 关联景点, title varchar(200) NOT NULL COMMENT 标题, content longtext COMMENT 正文, images json DEFAULT NULL COMMENT 图片列表, status tinyint DEFAULT 0 COMMENT 0-待审核 1-已发布 2-驳回, like_count bigint DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint DEFAULT 0, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_spot (spot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT游记表;这里的 region_id 关联独立的 region 表相当于把“地州”从景点的普通属性提升成了可维护、可扩展的维度数据。首页导航栏“按地州找景点”这个功能就是直接查 region 表然后联查景点一行代码都没多写。3. 后端开发SpringBoot MyBatis 的工程化落地数据库设计好之后后端开发就变成了一件“体力活”但是怎么让这份体力活干得漂亮、干得规范还是有不少门道。3.1 项目结构与分层组织我习惯用一个 Maven 单模块项目后端结构如下src/main/java/com/example/yunnantravel ├── controller // 接口层接收参数、返回结果 ├── service // 业务层处理业务逻辑 │ └── impl ├── mapper // MyBatis Mapper 接口 ├── entity // 数据库实体类 ├── dto // 数据传输对象避免直接把实体暴露给前端 ├── vo // 视图对象针对页面定制返回结构 ├── config // 配置类比如跨域、拦截器 ├── common // 通用类Result、异常处理、分页返回 └── util // 工具类Controller 层只做三件事接收参数、调用 Service、包装返回结果。所有参数校验不放在 Controller 里而是放在 Service 中统一处理。Service 层负责业务逻辑Mapper 层负责 SQL 交互。这个分层的好处是后续如果要加缓存Redis、加消息队列改动都集中在 Service 层不会波及接口层。实体类和 DTO 分开是我认为很多同学做得不够好的地方。比如景点实体 ScenicSpot 里有 content长文本字段列表页根本不需要返回这个字段如果直接把实体返回给前端不仅浪费带宽还可能把不该暴露的字段比如 deleted漏出去。我的做法是列表接口返回 ScenicSpotVO只包含 id、name、regionName、category、summary、images、viewCount 这些展示字段详情接口才完整返回。统一返回结果类 Result 也是必须的。我定义成public class ResultT { private Integer code; // 200 成功500 失败401 未登录 private String message; private T data; // 成功、失败、分页等静态方法 }前端 Axios 拦截器统一判断 code如果为 401 就跳登录页为 500 就弹错误提示。这样做让前后端联调非常丝滑不需要每个接口单独处理异常。3.2 景点列表分页接口的完整实现拿“景点列表”这个高频接口来说需求是支持分页、支持按地州筛选、支持按景点名称模糊搜索、支持按浏览量排序。Controller 层RestController RequestMapping(/api/spot) public class SpotController { Resource private SpotService spotService; GetMapping(/list) public ResultPageResultScenicSpotVO list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 12) Integer size, Long regionId, String keyword) { return Result.success(spotService.queryPage(page, size, regionId, keyword)); } }Service 层public PageResultScenicSpotVO queryPage(Integer page, Integer size, Long regionId, String keyword) { PageHelper.startPage(page, size); ListScenicSpotVO list spotMapper.selectSpotPage(regionId, keyword); PageInfoScenicSpotVO pageInfo new PageInfo(list); return new PageResult(pageInfo.getTotal(), pageInfo.getList()); }这里我用了 PageHelper 分页插件一行注解都不用只要在调用 Mapper 之前 startPage 即可。项目中千万注意 PageHelper 的 ThreadLocal 特性如果 startPage 之后没有紧跟 Mapper 查询分页会失效甚至串到下一个查询。我实际遇到过一次因为查询前执行了一段无关逻辑导致分页数据错乱排查了很久才定位到是 PageHelper 的代码位置问题。Mapper 接口public interface SpotMapper { ListScenicSpotVO selectSpotPage(Param(regionId) Long regionId, Param(keyword) String keyword); }XML 映射select idselectSpotPage resultMapspotVOResultMap SELECT s.id, s.name, s.category, s.summary, s.images, s.view_count, s.recommend, r.name AS region_name FROM scenic_spot s LEFT JOIN region r ON s.region_id r.id where s.deleted 0 AND s.status 1 if testregionId ! null AND s.region_id #{regionId} /if if testkeyword ! null and keyword ! AND s.name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY s.view_count DESC /select这里有两个细节值得提。第一是 LEFT JOIN region 而不是子查询这样前端能直接拿到 regionName不用额外调用一次地区接口。第二是使用了 CONCAT(%, #{keyword}, %) 而不是 %${keyword}%前者是预编译参数能防 SQL 注入后者是字符串拼接万一用户传了个 % 直接就把表给看光了。resultMap 里注意 images 字段因为数据库里存的是 JSON 数组字符串而 Java 里我用 List 接收MyBatis 默认不会自动转换。我的做法是在实体类中定义 List images然后写一个 TypeHandler 来做 JSON 和 List 的互转。SpringBoot 里注册自定义 TypeHandler 很简单用 MappedTypes 和 MappedJdbcTypes 注解标记然后在 application.yml 里配置 type-handlers-package 扫描包路径。3.3 景点详情接口与浏览量统计详情接口需要返回景点完整信息、评论列表、关联游记列表三个数据拼在一个 VO 里返回。这样首页和详情页一次请求就能拿到所有数据不需要前端再发三个请求。select idselectSpotDetail resultMapspotDetailResultMap SELECT s.id, s.name, s.summary, s.content, s.images, s.view_count, r.name AS region_name, r.id AS region_id, sc.id AS spot_favorite_count FROM scenic_spot s LEFT JOIN region r ON s.region_id r.id WHERE s.id #{spotId} AND s.deleted 0 AND s.status 1 /select浏览量统计这里我采用了一种“防刷但不过度设计”的方式详情接口每次调用都在 Service 层执行 update view_count view_count 1。这个操作在中小型项目里完全够用不需要引入 Redis 做计数也不需要用消息队列削峰。等到日均请求量真正大到 MySQL 扛不住的时候再换 Redis 计数也来得及不做无谓的提前优化。3.4 用户登录与 JWT 认证用户模块我用 JWTJSON Web Token 做认证。流程是用户提交用户名密码 → 后端校验通过 → 生成 token 返回 → 前端存到 localStorage → 后续请求在 header 中携带 token → 后端拦截器校验 token。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\,\data\:null}); return false; } Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims null) { response.setStatus(401); return false; } Long userId claims.get(userId, Long.class); request.setAttribute(userId, userId); return true; } }拦截器注册要设置路径规则放行 /api/user/login、/api/user/register、所有 GET 请求因为游客可以浏览拦截 /api/user/、/api/admin/ 等需要登录态的路径。这里有一个很常见的坑注册登录接口和景点列表接口都解析到同一个 IP如果不设置放行前端根本还没来得及登录访问首页就 401 了。我的拦截器放行规则是允许跨域预检请求OPTIONS 方法放行登录注册放行所有 GET 请求其他请求只有验证通过才放行。GET 请求放行是因为本项目的内容浏览是公开的收藏、评论、发布游记这些操作挂在 POST 上由拦截器保护。3.5 图片上传与访问映射图片上传是内容管理系统必备功能我实现了“本地存储 虚拟路径映射”的方案。ControllerPostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalName file.getOriginalFilename(); String ext originalName.substring(originalName.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) ext; String monthPath new SimpleDateFormat(yyyyMM).format(new Date()); File dir new File(uploadPath monthPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); return Result.success(/upload/ monthPath / fileName); }SpringBoot 虚拟路径映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath); } }之所以用 UUID 文件名是为了避免中文文件名和重名文件造成的问题。中文文件名在 URL 访问时要 URL 编码很容易出乱码UUID 方案能从根上规避这个问题。同时按月份分子目录避免所有图片挤在一个目录里后面做备份、迁移都比较方便。4. 前端开发Vue3 Vite 的页面实现与交互前端部分我用的是 Vue3 组合式 API 写法Vite 作为构建工具。页面不多但每个页面都有内容型站点的典型特征列表、详情、筛选、分页、富文本。4.1 前端项目初始化与工程结构用 Vite 创建项目npm create vitelatest yunnan-front -- --template vue cd yunnan-front npm install npm install vue-router4 pinia axios element-plus前端目录结构yunnan-front/src ├── api // 接口请求封装 │ ├── axios.js │ ├── spot.js │ ├── user.js │ └── note.js ├── assets ├── components │ ├── Header.vue │ ├── Footer.vue │ └── SpotCard.vue ├── router │ └── index.js ├── store │ └── user.js ├── views │ ├── home/Home.vue │ ├── spot/SpotList.vue │ ├── spot/SpotDetail.vue │ ├── note/NoteList.vue │ ├── note/NoteDetail.vue │ ├── user/Login.vue │ ├── user/Register.vue │ ├── user/Favorites.vue │ ├── admin/SpotManage.vue │ └── admin/NoteManage.vue └── App.vue4.2 Axios 封装与拦截器前端所有请求必须走统一的 Axios 实例。我的 axios.js 大概长这样import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request这里 baseURL 写的是 /api开发环境通过 Vite 代理转发到后端 8080 端口生产环境则通过 Nginx 把 /api 路径反向代理到后端服务。这样做有个好处前端代码里不需要区分开发/生产环境的接口域名统一走相对路径。Vite 代理配置// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }4.3 景点列表页筛选项 分页列表景点列表页是典型的内容列表页包含地州筛选、类型筛选、关键词搜索、分页列表。页面核心逻辑script setup import { ref, reactive, onMounted } from vue import { getSpotList } from /api/spot const filter reactive({ regionId: null, keyword: , page: 1, size: 12 }) const spots ref([]) const total ref(0) const loading ref(false) async function loadSpots() { loading.value true try { const data await getSpotList(filter) spots.value data.list total.value data.total } finally { loading.value false } } function handleSearch() { filter.page 1 loadSpots() } function handlePageChange(page) { filter.page page loadSpots() } onMounted(loadSpots) /script前端交互这里有个值得说的点筛选条件变化后必须重置页码到第一页。如果不重置用户在第 5 页选择了一个地州接口返回的是第 5 页按新筛选项查出的数据但页面可能只有 2 页展示会很奇怪。这个小细节我见过很多项目漏掉。4.4 Pinia 管理用户状态用户登录信息、token、角色信息用 Pinia 管理。之所以用 Pinia 而不是简单 localStorage是因为页面里多处组件需要响应式地感知“是否登录”“当前用户是谁”比如 Header 显示“登录/注册”还是“用户头像退出”。// store/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}) }), actions: { setLogin(token, userInfo) { this.token token this.userInfo userInfo localStorage.setItem(token, token) localStorage.setItem(userInfo, JSON.stringify(userInfo)) }, logout() { this.token this.userInfo {} localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })这里要注意一点localStorage 和 Pinia 状态需要同步更新。我见过同事只改了响应式状态、忘了写 localStorage刷新页面登录态就没了或者只写 localStorage、忘了更新 state页面导航不会立刻切换登录状态。setLogin 和 logout 两个 action 里同时做两件事就不会出这种问题。4.5 后台管理页面的路由守卫后台管理页面要求只有管理员角色才能访问。我把角色信息存在 userInfo.role 里路由守卫如下router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path.startsWith(/admin)) { if (!userStore.token) { next(/login) return } if (userStore.userInfo.role ! ADMIN) { next(/) return } } next() })如果不做路由守卫任何人都能通过地址栏访问后台管理页面。虽然后端接口也有 JWT 拦截器兜底但前端先拦截用户体验更好也减轻后端压力。5. 前后端联调与部署上线前后端分离项目联调和部署环节才是真正考验工程能力的地方。很多功能单独测没问题一连起来各种问题都冒出来了。5.1 联调前的准备工作联调开始之前我习惯先确认三件事第一后端接口返回的 JSON 结构是否统一。前端 Axios 拦截器是按“Result 结构里 code200 才算成功”来写的如果某个接口返回结构不一致拦截器就会误判。所以联调前我会先用 Postman 或 Apifox 跑一遍核心接口确认返回格式。第二接口文档要明确字段含义。比如 createTime 是字符串还是时间戳Java 的 LocalDateTime 序列化后默认是 2025-06-18T10:30:00 这种格式而前端可能期望 2025-06-18 10:30:00。我直接在 application.yml 里做了全局配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样后端返回的时间格式统一、可读性好前端不需要再做格式化。第三跨域问题要先解决。虽然开发环境有 Vite 代理但如果本地调试时前端直接用 http://localhost:5173 访问后端 8080就需要后端支持 CORS。我的做法是直接在 WebConfig 里配置跨域Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }注意 allowCredentials(true) 的时候allowedOrigins 不能用 *必须用 allowedOriginPatterns这是 Spring 对 Cookie 跨域安全的限制。5.2 Nginx 部署与静态资源托管生产环境部署我是这样规划的前端打包后的 dist 目录由 Nginx 托管Nginx 监听 80 端口后端 jar 包运行在 8080 端口Nginx 把 /api 请求反向代理到 8080。Nginx 配置片段server { listen 80; server_name your-domain.com; location / { root /opt/yunnan-front/dist; index index.html; try_files $uri $uri/ /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/yunnan-upload/; } }这里最关键的是 try_files 配置。前端用的 Vue Router 是 history 模式用户在地址栏直接访问 /spot/1 时Nginx 收到请求去找 /spot/1 这个文件找不到就会 404。try_files 的作用就是找不到对应文件时回退到 index.html由前端路由接管。5.3 环境差异问题开发正常、部署失败怎么办我遇到过好几次“开发环境一切正常部署到服务器就出问题”的情况。最常见的原因有这几个一是数据库字符集。开发库建的库是 utf8mb4服务器的库如果是默认 latin1中文就会变问号。部署前一定要检查服务器 MySQL 的 character_set_database 是不是 utf8mb4。二是文件上传路径权限。服务器上 /opt/yunnan-upload/ 目录如果没有写权限上传接口会报错。注意 SpringBoot 中 MultipartFile 写入绝对路径时目录必须存在且可写。三是 jar 包启动时指定外部配置。数据库连接信息如果在 application.yml 里写死换了环境就得重新打包。我用 SpringBoot 的多环境配置开发环境用 application-dev.yml生产环境用 application-prod.yml启动时加上 --spring.profiles.activeprod 参数。四是 Jetty 和 Tomcat 的差异。SpringBoot 内嵌默认是 Tomcat没问题。但如果你把项目打成 war 包部署到外部容器就要特别注意 Servlet 版本兼容。6. 本地部署运行指南拿到源码后怎么跑起来考虑到很多同学是拿到源码之后照着跑的我单独写一个“最小启动流程”按照这个顺序来基本不会卡壳。6.1 环境准备清单软件版本建议用途JDK1.8 或 11推荐编译运行后端Maven3.6依赖管理MySQL8.0数据存储Node.js16前端构建环境IDEA / VS Code任意开发工具6.2 后端启动步骤第一步创建数据库并导入 SQLsource yunnan_travel.sql;打开数据库工具Navicat、DataGrip、命令行均可执行 SQL 脚本确认 9 张表和初始数据都建好了。第二步修改 application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/yunnan_travel?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword注意 serverTimezone 一定要加否则 MySQL 8.0 的时间类型和 Java 时区对不上会报错。第三步启动后端在 IDEA 中直接运行 Application 类或者用 Maven 命令mvn spring-boot:run启动日志出现 “Started Application in xxx seconds” 就说明成功了。6.3 前端启动步骤cd yunnan-front npm install npm run dev浏览器访问 http://localhost:5173看到首页就说明前后端已经接通了。如果接口 404 或请求失败先检查 Vite 代理是否生效打开浏览器开发者工具看 Network 里的请求路径是 /api/spot/list 还是 http://localhost:8080/api/spot/list。如果是后者说明代理没生效检查 vite.config.js 的 proxy 配置。6.4 一键部署脚本参考后端编译mvn clean package -DskipTests在 target 目录得到 yunnan-travel.jar。前端构建npm run build在 dist 目录得到静态文件。把两者分别拷贝到服务器指定目录执行nohup java -jar yunnan-travel.jar --spring.profiles.activeprod app.log 21 至此一个前后端分离的文旅网站就算从零到一跑通了。7. 常见问题与排查技巧这部分是我实际开发过程中踩坑记录的总结按问题类型分类整理每一类都是血泪教训。7.1 数据库连接与中文乱码问题问题现象接口返回数据页面上的中文全是问号或者插入中文时报“Incorrect string value”。排查思路首先检查数据库连接 URL 是否包含 characterEncodingutf8。然后检查数据库和表的字符集执行SHOW TABLE STATUS LIKE scenic_spot看 Collation 列。最后检查连接参数是否加了 useUnicodetrue。如果都排查了还不行检查前端页面 meta 标签字符集和请求头 Content-Type 是否包含 charsetutf-8。这个问题看起来简单但往往是最容易忽略的。我建议建库 SQL 里就明确指定 utf8mb4不要依赖安装时的默认字符集。7.2 跨域请求被拦截问题现象浏览器控制台出现 “CORS policy: No Access-Control-Allow-Origin header”。排查思路开发环境优先用 Vite 代理解决不要在后端开启 CORS因为双端同时开偶尔会冲突。生产环境用 Nginx 反向代理后端也不需要开 CORS。只有本地单独测试、不走代理的时候后端才需要配置 CORS。另外一个小技巧CORS 预检请求是 OPTIONS 方法如果你的全局拦截器拦截了 OPTIONS会导致 CORS 失败。JwtInterceptor 中一定要放行 OPTIONS 方法。7.3 MyBatis 结果集映射不生效问题现象查询正常但返回的 Java 对象里某些字段是 null。最常见的原因是实体类属性名和表字段名不完全匹配。比如表字段是 create_timeJava 属性是 createTime。MyBatis 的自动映射在开启 mapUnderscoreToCamelCase 后能处理这种下划线到驼峰的转换mybatis: configuration: map-underscore-to-camel-case: true如果有些字段是联查出来的别名比如 r.name AS region_name这时候驼峰转换会把 region_name 映射到 regionName但如果实体类里是 regionName 而 resultMap 又没配就可能还是 null。我的建议是联查别名别跟字段取名绕直接用 AS 驼峰名称比如 r.name AS regionName清晰明了。7.4 数据库连接数耗尽问题问题现象系统运行一段时间后接口全部超时后台日志报 “Connection is not available, request timed out after 30000ms”。原因通常是连接池配置太小或者存在未关闭的数据库连接。MyBatis 的 SqlSession 如果没有正确关闭连接会一直占用。SpringBoot 自动管理 SqlSession 生命周期但如果自己写了一些原生操作记得放到 try-with-resources 中。调优参数参考spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000中小型项目 20 个连接完全够用不要贪大。连接数开太多反而增加数据库负担。7.5 请求参数为 null 导致 MyBatis 动态 SQL 出问题问题现象按条件查询时某个参数没传结果查出来的数据为空或不符合预期。MyBatis 动态 SQL 里的条件要用if testregionId ! null判断。但有一个细节Long 类型的参数如果前端传了空字符串 后端接到的还是 null因为 JSON 解析时空字符串转不了 Long这个好办但 Integer 类型的 page 参数如果前端没传默认值由 RequestParam(defaultValue 1) 保证不会为 null。真正容易踩的是 keyword 参数。前端输入框清空后发送的是空字符串 在if testkeyword ! null and keyword ! 判断后这个条件会被跳过不会参与查询这是对的。但如果只写了if testkeyword ! null空字符串就会导致LIKE %%查出来全表数据影响性能。7.6 文件上传大小限制问题现象上传图片提示 “MaxUploadSizeExceededException”。SpringBoot 默认上传文件大小限制是 1MB对于多图上传场景太小了。修改配置spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB这里我建议保留一定的限制别为了省事直接调成无限大。10MB 对普通图片完全足够太大了会影响页面加载速度。7.7 首页轮播图加载慢问题现象轮播图多的时候第一次打开首页加载明显卡顿。排查发现是图片没有压缩每张都是几 MB 的高清原图。我处理的方式是上传时用 Java 的 ImageIO 判断图片宽高如果宽度超过 1200px 就等比缩放后再存储。这个逻辑其实不难实现但对于内容型网站的性能提升非常明显。另外轮播图接口返回时只拿图片地址不加载图片内容本身前端直接用懒加载组件。8. 项目扩展方向与个人体会项目做到这里已经是一个能正常跑、能演示、能部署的完整系统了。但如果你还想让它更完善有几个扩展方向我觉得很值得做。第一个方向是增加 Redis 缓存。景点详情、轮播图、地区列表这些高频访问且不太变化的数据非常适合缓存。引入 Spring Data Redis 之后核心代码改动很小主要在 Service 层加缓存读写逻辑。第二个方向是引入搜索。目前景点搜索是 MySQL 的 LIKE 模糊查询数据量小的时候没问题数据到了几万条就会明显变慢。可以引入 Elasticsearch 或者轻量级的分词搜索方案把景点名称、介绍、标签建索引搜索体验会提升很大。第三个方向是后台数据统计。现在后台只有简单的管理功能如果给管理员增加访问量趋势图、热门景点 Top10、用户增长曲线这类报表系统的完整度和实用价值会高很多。前端用 ECharts 就能实现后端只需要按日期维度做聚合查询。第四个方向是移动端适配。现在的页面是响应式基础但真正体验好的文旅网站都会做移动端 H5 甚至小程序。Vue3 这套技术栈编译到小程序生态虽然有点波折但也不是不可能。我个人在实际操作中的体会是这类“内容展示 用户互动 后台管理”的系统做的过程中最大的收获不是学会了某个框架的 API而是理解了怎样从一个模糊的需求出发逐步拆解出信息架构、数据模型、接口设计、页面组织最后落成一个能跑的系统。这个思维方式在以后做任何项目都会用到。最后再分享一个小技巧:整个项目开发过程中我建议你每完成一个功能模块就 git commit 一次。不要攒到全部做完再提交。比如“景点管理模块完成”提交一次“用户登录 JWT 完成”再提交一次。这样做的好处是出问题的时候能直接定位到是哪次提交引入的回退也方便。我见过太多人项目最后崩了就全部重来其实早就该用版本管理把风险控制住。如果你正准备启动一个类似的文旅类项目希望这篇文章能帮你把设计思路理清楚少踩几个我踩过的坑。照着这个方案做下去至少能达到“做得出、跑得通、讲得清”的水平。