1. 项目概述与核心需求拆解
1.1 旅游指南系统到底在做什么
这段时间我利用业余时间完整整理并复现了一套基于Spring Boot + Vue的旅游指南系统,交付形态包括后端源码、前端页面、数据库初始化脚本和配套说明文档。简单说,它不是一个只展示景点的静态网站,而是一个带有用户注册登录、景点浏览、线路推荐、攻略文章、收藏、评论、后台管理等功能的完整业务系统。旅游指南系统这类项目,在实际场景里通常被用在旅游信息门户、地方文旅宣传平台、高校课程设计或者旅行社内部系统。
最开始接到这个需求时,我并没有急着写代码,而是先在纸上把“用户端要做什么、管理端要做什么”列清楚。这类系统最大的难点不在功能多少,而在信息结构是否清晰。景点、线路、攻略、用户、评论、收藏之间天然存在一堆一对多和多对多的关系。如果一开始不把数据库关系想透,后面进到前后端联调阶段必然要反复返工。我的做法是先梳理概念模型,再转成物理表结构,最后才开始搭接口和页面。
另一个容易被低估的问题是“演示数据”。很多人拿到一份能跑的项目,结果数据库里只塞了两条测试数据,景点列表看起来又空又没有说服力。真正优秀的一套源代码交付,应该是数据库里带有接近真实场景的演示数据,这样你打开首页就能看到轮播图、景点卡片、攻略列表、评论记录,而不是面对一张张空表。这也是我把“源码+数据库+文档”三件套并列的原因:代码只是一部分,能不能让接手的人快速跑起来,才是关键。
1.2 技术选型:为什么是Spring Boot + Vue
很多人在做类似系统时纠结过:后端用SSM还是Spring Boot,前端用JSP还是Vue。我的体会是,想在短时间内交付一套看得见、摸得着的系统,Spring Boot + Vue的组合目前是最稳妥的路线之一,没有严重短板,社区资料也足够多。
Spring Boot的价值在于自动配置和快速启动。它和MyBatis、MySQL、拦截器、跨域配置这些东西都有非常成熟的集成方案,基本不需要手动维护一堆XML配置。对旅游指南这种以CRUD为主、附带部分查询统计的业务系统来说,Spring Boot配合MyBatis-Plus能把样板代码压到最低。单表增删改查交给通用Mapper处理,复杂一点的统计查询用自定义SQL或QueryWrapper条件构造器就能完成,不需要额外引入重量级框架。
前端这边我选择Vue + Element-UI组合。Element-UI的表格、表单、分页、轮播、弹窗组件非常齐全,做管理后台和内容展示类页面效率极高。如果现在新起项目,直接用Vue 3 + Element Plus也可以,差别主要体现在响应式API和组件注册方式,核心设计思想不变。旅游指南系统的大部分前端工作集中在卡片列表、详情页面、表单提交和路由跳转上,正好是Vue组件化思想的舒适区。
1.3 这类系统适合哪些人来复现
如果你是刚入门前后端分离开发的人,这套旅游指南系统是非常好的练手素材。功能点覆盖了用户模块、内容展示模块、用户交互模块,但又不会难到让人劝退。你准备拿来做毕业设计或项目实战案例,同样合适。它不像电商系统那样需要处理复杂的订单状态机,也不像社交系统那样要考虑高并发,但在登录鉴权、图片上传、分页搜索、评论回复这些关键点上,该有的基本全都有。
复现一遍这条路走下来,你对Spring Boot后端如何组织接口、Vue前端如何调用接口、MySQL表结构如何设计,都会建立整体认知。我建议不要只是把代码跑起来,而是动手改一两个功能点,比如加一个“景点标签”字段,或者把首页改成按地域筛选景点。只有亲手改过一遍,你才知道哪些地方是写死逻辑,哪些地方有良好扩展点,这种判断力比代码本身值钱得多。
2. 整体设计思路与数据建模
2.1 前后端分离的架构怎么搭
整套系统在架构上拆成三个部分:Vue前端应用、Spring Boot后端应用、MySQL数据库。前端通过Axios发起HTTP请求,后端暴露RESTful接口,数据以JSON格式返回。这样做的好处是前后端各管一摊:前端专注页面交互,后端专注接口稳定和数据安全。实际部署时,前端打包成静态资源交给Web服务器,后端打包成Jar包独立运行。
搭建目录结构时,我习惯约定优于配置。整个项目按以下结构组织:
travel-guide-frontend/ # Vue前端工程 travel-guide-backend/ # Spring Boot后端工程 travel-guide-database/ # SQL脚本与初始化数据后端内部再按controller、service、mapper、entity、config、common六个包划分。这套结构非常常规,但正是这种常规让后期维护和交接几乎不需要额外解释。很多人项目翻车不是因为代码写不出来,而是目录乱到连自己三天后再看都不知道某个方法放在哪里。保持清晰的包结构,把配置类放到config包里统一管理,把返回消息封装类放到common包里,后续加功能时会发现思路非常顺畅。
2.2 数据库表设计:核心表字段与关系
数据库是旅游指南系统的地基,我在这一块花的时间也最多。核心表主要有六张:用户表、景点表、线路表、攻略表、评论表、收藏表,外加一张用于配置首页分类的类目表。表关系大概是:一个分类下面有多条景点,一条景点可以被多个用户收藏,一个用户可以收藏多个景点,评论挂在景点或者攻略下。用户与景点之间是多对多关系,通过收藏中间表解耦;用户与评论之间是一对多关系。
核心表结构可以简化为这样:
CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(255) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), phone VARCHAR(20), role TINYINT DEFAULT 0 COMMENT '0-普通用户 1-管理员', create_time DATETIME ); CREATE TABLE t_scenic ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category_id BIGINT, cover VARCHAR(255), summary TEXT, content LONGTEXT, address VARCHAR(255), open_time VARCHAR(50), ticket_price DECIMAL(10,2) DEFAULT 0, view_count INT DEFAULT 0, status TINYINT DEFAULT 1 );其他表的思路类似:攻略表有一个author_id字段指向用户表,景点的主体内容用LONGTEXT来存富文本;评论表用target_type和target_id区分评论的是景点还是攻略;收藏表把user_id和scenic_id做成唯一索引,防止重复收藏。
这里重点提醒一下:不要把景点、线路、攻略全塞进一张大表,那样虽然省事,但字段会非常混乱,查询统计也不好写。分表设计初看费事,后期却会省心很多。所有时间字段统一用datetime,布尔状态统一用tinyint,金额统一用decimal,这些约定一定要提前定好,否则不同人写的代码风格会非常割裂。
2.3 接口规划与权限控制
接口层我分成四大块:用户相关、景点内容相关、用户交互相关、后台管理相关。用户相关有登录、注册、登出、获取当前用户信息;内容相关有景点分页列表、景点详情、攻略列表、线路推荐;交互相关有评论列表、发表评论、收藏与取消收藏;后台管理相关则是对景点、攻略、分类、用户的管理接口。
权限控制没有引入Spring Security这类重型框架,只是用登录拦截器加注解的方式。核心思路是:用户登录成功后签发一个Token,前端把Token放在请求头里,后端拦截器校验Token是否存在以及是否过期。后台管理接口再额外校验用户角色,要求必须是管理员。这种轻量级方案做旅游指南系统足够了,毕竟它不是高敏感的金融业务,不需要引入复杂的权限模型。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (!tokenService.check(token)) { response.setStatus(401); return false; } return true; }有个极其容易踩的坑,就是预检请求。前后端分离环境下,浏览器发复杂请求之前会先发一个OPTIONS预检请求,这个请求不会带Token,如果拦截器没有提前放行OPTIONS,前端所有带自定义Header的请求都会失败。我在第一次联调时就吃过这个亏,排查了半天才发现是拦截器把预检请求拦掉了。
3. 核心功能模块的落地实现
3.1 后端骨架:实体、Mapper、Service、Controller
Spring Boot后端的主类、配置文件、启动流程都比较固定,我用MyBatis-Plus来写数据层。实体类用@TableName注解映射表名,字段上用@TableId和@TableField处理主键和列名不一致的问题。下面这段是景点实体的典型写法:
@Data @TableName("t_scenic") public class Scenic { @TableId(type = IdType.AUTO) private Long id; private String name; private Long categoryId; private String cover; private String summary; private String content; private String address; private String openTime; private BigDecimal ticketPrice; private Integer viewCount; private Integer status; }查询条件的封装是列表页的重点。景点列表通常需要按分类筛选、按关键词搜索、按浏览量或价格排序。用LambdaQueryWrapper拼条件时,最关键的是eq和like方法前面的布尔参数,它能把前端传的空值自动忽略掉,避免拼出多余SQL条件。实际联调时我遇到过不少次前端传了空字符串,导致SQL条件异常,用这个写法可以一劳永逸。
public IPage<Scenic> pageScenic(int page, int size, Long categoryId, String keyword) { Page<Scenic> p = new Page<>(page, size); LambdaQueryWrapper<Scenic> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Scenic::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Scenic::getName, keyword) .orderByDesc(Scenic::getViewCount); return scenicMapper.selectPage(p, wrapper); }Controller层我习惯保持薄Controller模式,只做参数接收、调用Service、返回统一结果这三件事。不要为了省事把业务逻辑全堆在Controller里,否则后期加一个权限校验或者改一个查询逻辑,就要动好几个方法。统一返回结果我会封装一个Result类,包含code、message、data三个字段,前端响应拦截器根据code判断业务成功还是失败,这样接口返回格式在整个项目里保持一致。
3.2 前端骨架:Vue项目结构、Axios封装、页面组合
前端用Vue CLI创建项目后,按views、components、router、api、utils几个目录组织。api目录下建议按业务模块拆文件,比如user.js、scenic.js、comment.js,每个文件里放对应的请求函数。这样做的好处是页面组件里不会到处散落axios调用,接口一旦有调整,只改一个文件。
Axios封装是前端少不了的环节。我在utils/request.js里设置baseURL、请求超时、请求和响应拦截器。请求拦截器负责把Token加到Header,响应拦截器负责统一处理业务状态码和401跳转。
import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = token; return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { if (res.code === 401) { window.location.href = '/login'; } return Promise.reject(new Error(res.message)); } return res; }, error => { return Promise.reject(error); } ); export default service;页面组合方面,用户端首页用轮播图展示推荐景点,下面按分类展示景点卡片;列表页用分页组件和筛选条件联动;详情页用路由参数加载景点详情,同时拉取评论列表;管理端用后台模板布局,左侧菜单分类管理、景点管理、攻略管理、用户管理,右侧用router-view切换页面。表单提交之前用rules做一次前端校验,可以减少后端错误提醒的次数,用户体验会好很多。
3.3 联调阶段的数据流与传参规范
前后端联调阶段最容易出的问题,除了跨域就是参数类型不一致。比如后端分页接口的参数是page和size,前端传的是pageNum和pageSize,接口就会一直查不出数据。我后来统一约定:分页参数固定用page和size,时间参数统一用字符串,金额统一用数字,布尔值统一用0和1。联调之前先整理一份简单接口文档,前端按文档来传参,问题能少一大半。
图片上传的处理需要单独说。景点封面、用户头像这类图片,后端要提供一个统一的上传接口,保存到服务器指定目录,返回一个可访问的URL。数据库里只存URL或相对路径,不要存二进制流。前端在提交表单时先上传图片拿到URL,再把URL放进表单数据提交,整个流程会更顺畅。如果遇到图片上传后刷新页面无法显示,优先检查上传目录是否有读写权限,以及静态资源映射是否配置正确。
浏览量也是一个容易被忽略的点。景点详情页每打开一次,view_count要加1。不要用先查询再更新这种方式,应该直接执行原子更新:UPDATE t_scenic SET view_count = view_count + 1 WHERE id = ?。这样能避免并发环境下丢更新。越是简单的逻辑,越要在细节上做对,这就是“看着简单、实际有坑”的典型例子。
4. 部署上线与实测记录
4.1 本地跑通项目的完整步骤
本地跑通整套系统的步骤比较标准,我整理下来大概五步。第一步,准备好环境基础组件:JDK 8或17、MySQL 8、Node.js 14以上、Maven用于构建后端。第二步,在数据库里执行init.sql,自动建库建表并插入演示数据。第三步,修改后端application.yml里的数据库用户名和密码,启动Spring Boot应用,确认8080端口能访问。第四步,进入前端目录执行npm install,修改src/utils/request.js里的baseURL,指向后端地址,然后执行npm run serve。第五步,浏览器访问前端地址,注册一个普通用户,测试首页景点列表、详情、收藏、评论这些功能是否能正常走通。
这段话读起来平淡,实际执行时最大的坑反而集中在环境版本上。比如MySQL 8默认的认证插件是caching_sha2_password,某些老版本驱动连接时会报认证错误,需要把驱动版本升级到8.x。又比如Node版本过高,Vue CLI某些老版本会报OpenSSL错误,需要设置NODE_OPTIONS = --openssl-legacy-provider。这类情况属于“环境问题占八成”,代码本身不一定有问题,但会让人排查得心力交瘁。
4.2 部署到服务器之后遇到的关键问题
本地跑通只是第一步,部署到服务器后我遇到了几个很典型的坑。
第一个是跨域配置失效。本地开发时前端是localhost:8080,后端是localhost:9000,属于不同端口,必须依赖跨域配置。上线后如果把前端打包文件放到后端同目录下,由同一端口提供服务,跨域问题自然消失。但如果前端静态资源部署到独立端口,就必须确认后端跨域配置允许的Origin包含线上域名,而不是只写localhost。
第二个是静态资源404。前端build之后生成dist目录,资源路径默认相对当前域名根目录。如果部署到子路径,比如http://ip:8080/travel/,需要在vue.config.js里配置publicPath。最初我没配,导致刷新页面后所有JS和CSS都404,页面直接白屏。这个坑不是代码逻辑问题,而是构建配置问题,很多人特别容易忽略。
第三个是MySQL连接失败。服务器上数据库的账号权限、防火墙端口、连接串里的serverTimezone和useSSL参数,任何一个不对都会导致连接异常。我最后把连接串稳定为下面这种写法:
jdbc:mysql://localhost:3306/travel_guide?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai这里整理一个排查速查表,遇到类似问题可以直接对照:
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 前端所有接口返回401 | 拦截器没放行OPTIONS预检 | 拦截器中对OPTIONS请求直接返回true |
| 页面刷新后JS/CSS 404 | publicPath配置不对 | vue.config.js里设置publicPath为子路径 |
| 后端启动时报数据库连接失败 | 驱动版本或时区参数不对 | 升级MySQL驱动,连接串加serverTimezone |
| 图片上传成功但访问404 | 上传目录没有静态资源映射 | 配置资源映射器,把本地目录暴露为URL |
| 前端跨域报错 | 允许的Origin不对 | 在CORS配置中加上实际的线上域名 |
4.3 运维层面的常见坑
再补充几个关于数据和日志的实操经验。第一,数据库备份不能只靠手动导出。我见过有人把数据放在一台没做备份的服务器上,结果一次误删表,只能凭记忆补数据。旅游指南项目虽然数据量不大,上线后依然建议配置每天定时备份,或者至少每周导出一次SQL文件。真出问题的时候,备份就是救命稻草。
第二,日志一定要分级。后端不要把所有日志全堆在控制台,要把错误日志单独写到文件里,方便排查问题。可以在logback配置里对ERROR级别单独输出,并带上请求参数和异常堆栈。很多部署阶段的问题,比如用户反馈登录失败,一看ERROR日志就能快速定位,而不是靠猜。上线系统不是“能跑就行”,而是要让你在出问题时能用最短时间找到原因。
第三,图片存储目录要单独规划。如果后端把图片写到应用根目录下的upload文件夹,那么重新部署Jar包时可能会把图片一起覆盖或删除。比较稳妥的做法是配置一个独立上传目录,再通过静态资源映射接口对外提供访问。这样升级代码和图片数据互不影响。
5. 项目交付与文档组织的个人经验
5.1 源码、数据库、文档三件套怎么整理
这个项目从一开始我就给自己定了目标:不只是把代码跑通,而是把整个项目整理成“源码+数据库+文档”三件套的可交付状态。太多人写项目只关注“能不能跑”,到交付时临时凑文档,效果非常差。我更建议把文档写作平摊到开发过程中,每天结束前花20分钟记录当天做了什么、改了哪些接口、数据库有哪些变化。这样项目做完时,文档主体内容已经齐了,不用返工。
交付时我会准备三样东西。第一份是系统介绍文档,说清楚项目背景、技术栈、主要功能、运行环境和启动步骤。第二份是数据库说明文档,把每张表的用途、核心字段含义、表关系画清楚,附上初始化SQL。第三份是接口文档,列出主要接口的请求方式、路径、参数和返回示例。这三份文档不需要写成学术论文,但要把“怎么把它跑起来”和“每个模块怎么改”写明白。
特别要强调数据库文档的价值。接手代码的人往往第一件事就是打开数据库看表结构,如果每张表没有注释,字段含义靠猜,理解成本会成倍增加。SQL脚本里尽量给每个字段加上COMMENT,表名和字段名用统一的命名规范。这套习惯放在任何数据库项目里都适用。
5.2 给做同类型系统的人的建议
踩过几轮坑之后,我的个人建议可以浓缩成三条。第一条,建表前先画关系图,不要在写代码过程中频繁改表。改一次表,意味着实体类、Mapper、Service、前端页面都要跟着动,工作量是放大倍数而非加法。第二条,前端先搭好Axios和路由骨架,再去做页面细节。骨架确定后,所有页面都共用同一套请求封装和错误处理逻辑,哪怕别人接手也能很快看懂请求是怎么发出去的。第三条,测试数据要有代表性。旅游指南系统如果只有两条景点数据和一条评论数据,列表页分页效果根本测不出来。数据尽量模拟真实场景,比如十个分类、每个分类几篇攻略、用户评论和收藏相互交叉,这样大概率在联调阶段就能发现隐藏Bug。
最后再分享一个小经验:把这个系统从零搭起来并不难,真正有价值的部分是数据建模、权限控制和部署排错这套完整思路。我虽然只是做了一个案例,但中间积累的很多细节,放在其他管理类系统上同样适用。做项目最好的方式不是一口气写完所有代码,而是分阶段验证,每个阶段都跑通一遍再继续,后面就会发现整个过程顺畅很多。