做旅游类网站的需求这两年其实一直很稳定,很多课程设计、毕业设计、个人练手项目都会选这个方向。我也在几个月前完整开发了一个“七彩云南文化旅游网站系统”,前后端分离,后端用SpringBoot+MyBatis,前端用Vue3,数据库落在MySQL上。这套技术组合在目前的Java全栈项目里算是比较经典也比较主流的搭配,踩过不少坑,也总结了不少可以直接抄作业的细节。这篇文章就把整个项目的设计思路、核心模块、数据库建表、前后端对接、部署避坑经验全部拆开讲清楚,无论是准备自己从零写一个旅游网站,还是想读懂一套现成源码,应该都能用得上。
先说清楚这套系统到底做了什么:游客可以在网站上浏览云南各地的景点信息、查看旅游攻略、阅读文化介绍,注册登录后还能对景点进行收藏、对攻略发表评论;管理员则通过后台管理界面维护景点数据、审核评论、管理用户。整个系统包含前台展示与后台管理两条线,数据通过RESTful接口交互,前端页面由Vue3动态渲染,所有持久化数据都存放在MySQL中。
1. 项目整体设计与需求拆解
1.1 文化旅游网站的核心功能边界
开发这类系统之前,最重要的事情不是急着写代码,而是先把功能边界画清楚。很多人一上来就想做得大而全,什么VR全景、智能推荐、在线购票都往里面塞,结果项目中期就卡死在复杂度上。我给自己定的原则是:抓住一条主业务线,把闭环做到位,再考虑扩展。
这个项目的主业务线是“游客找内容、管理员管内容”,围绕这条线拆解出以下几组功能:
- C端(游客/注册用户):景点列表展示、景点详情、旅游攻略浏览、文化资讯查看、用户注册登录、收藏景点、评论攻略。
- B端(管理员):后台登录、景点信息增删改、攻略发布与修改、评论审核删除、用户账号管理。
- 公共能力:图片上传、分页查询、关键词搜索、统一异常处理、接口权限校验。
对比那些一上来就做“多端适配+支付+地图导航”的项目,这套范围已经是完整可交付的水平,同时工作量控制在一个人能按时完成的程度。
1.2 为什么选择前后端分离而不是传统模板渲染
我见过不少Java Web项目还在用Thymeleaf或者JSP做服务端页面渲染。那种方式不是不能用,但对于“旅游文化展示”这种重页面交互、重视觉呈现的需求来说,体验差距非常明显。举个最简单的例子:景点列表的筛选条件一变化,传统页面渲染需要整个页面刷新,而前后端分离模式下只需局部请求数据,前端自己更新DOM,体感上完全是两个时代。
再从工程角度说,前后端分离让接口可以独立测试。我后端写接口的时候直接用Postman调试,前端用Vite起本地服务开发页面,两边互不阻塞。最后通过代理配置把请求转发到后端地址,联调效率比模板渲染高很多。
这套架构的选型也是当前业界最主流的方式,长远来看对个人技术成长的帮助更大。哪怕以后转岗做别的项目,SpringBoot+Vue3这套组合的经验也是直接通用的。
1.3 项目目录规划与团队协作视角
既然选择了前后端分离,目录结构就必须一开始就分好两个顶层目录,避免后续越写越乱。前端、后端、数据库脚本,这三块建议严格隔离。
project/ ├── frontend/ # Vue3前端工程 ├── backend/ # SpringBoot后端工程 ├── database/ # SQL初始化脚本 └── README.md后端内部按照标准的三层架构分包:controller、service、mapper。前端则按照页面维度组织:home、attraction、strategy、user、admin。这样做的好处是,别人拿到源码后不需要问“某个功能代码在哪”,只要按照约定就能快速定位,减少沟通成本。
2. 技术栈选型与核心架构解析
2.1 后端SpringBoot的版本与集成配置
SpringBoot版本我选择的是2.7.x系列。有人可能会问,为什么不直接用SpringBoot 3.x?原因很简单,3.x要求Java 17,同时部分MyBatis集成组件和旧教程的写法存在兼容差异,对于以学习和稳定交付为目标的项目来说,2.7.x搭配Java 1.8反而是更稳妥的选择。
核心依赖就四个模块:spring-boot-starter-web提供Web能力,mybatis-spring-boot-starter负责数据库访问,mysql-connector-java负责MySQL驱动,lombok减少实体类冗余代码。如果需要做参数校验,可以再加spring-boot-starter-validation。
配置文件中需要重点说明的是连接池参数。我使用HikariCP(SpringBoot 2.x默认集成),配置如下:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/yunnan_travel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000这里有个非常容易忽略的点:URL里的characterEncoding=utf8一定要写,否则哪怕表结构是utf8mb4,接口返回的中文也可能在特殊场景下出现乱码。serverTimezone也必须指定,否则高版本MySQL驱动会报时区错误。
2.2 前端Vue3工程化的构建配置
前端使用Vite而不是Webpack作为构建工具。实际体验下来,Vite在开发环境下的冷启动速度确实快,保存代码后的热更新几乎是即时响应,对开发体验的提升非常明显。
Vue3的组合式API(Composition API)是这个项目的核心写法。相比Vue2的选项式API,组合式API在逻辑复用层面更灵活,同一个功能相关的数据和方法可以集中放在一起维护。比如景点列表页的“加载数据”“loading状态”“错误提示”这三者可以写在一个区域,代码的可读性直线提升。
前端路由采用Vue Router 4,状态管理使用Pinia。可能有人觉得一个展示类网站用不到状态管理,但实际操作下来,用户登录后的用户信息、后台管理的菜单折叠状态这些数据,放在全局Store里确实比到处传递方便得多。
2.3 MySQL数据表与索引设计的整体规划
整个系统的数据全部落在MySQL,表数量在八张左右。设计的核心准则是“先按业务对象分表,再按查询需求建索引”。
表清单如下:
| 表名 | 职责 | 核心字段 |
|---|---|---|
| user | 用户账号 | id, username, password, nickname, avatar, role |
| attraction | 景点 | id, name, location, description, cover, images, culture, opening_hours |
| strategy | 旅游攻略 | id, title, content, author_id, attraction_id, cover, views |
| article | 文化资讯 | id, title, content, cover, category, publish_time |
| comment | 评论 | id, user_id, strategy_id, content, status, create_time |
| favorite | 收藏 | id, user_id, attraction_id, create_time |
| category | 分类 | id, name, description |
| admin_log | 操作日志 | id, admin_id, action, create_time |
关于索引,我的经验是不要贪多。每张表核心查询字段加索引就够了,比如attraction表的name字段、strategy表的author_id和attraction_id、comment表的strategy_id。index建太多不但影响写入性能,还容易导致查询优化器选错索引。
3. 后端核心模块实现细节
3.1 统一返回格式与异常处理
接口开发第一步不是写具体业务,而是定义一个统一的响应结构。我定义了Result类,包含code、message、data三个字段,所有接口一律返回这个格式。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }配合全局异常处理器@RestControllerAdvice,把业务异常和数据校验异常统一转换成Result格式返回。这样一来,前端axios拦截器只需要判断code是否为200即可,不需要每个接口单独处理错误分支,减少大量重复代码。
3.2 登录认证与权限控制方案
登录模块我采用了轻量级的JWT方案,不引入Spring Security这种重框架。原因很现实:系统角色就两种(普通用户、管理员),用Spring Security需要配置大量过滤链规则,对学习项目来说成本偏高。
JWT的实现思路是这样的:用户登录成功后,后端根据userId和role生成一个有效期24小时的Token,返回给前端。前端拿到Token后存入本地存储,并在axios请求拦截器中自动附加到Header的Authorization字段。后端则通过一个自定义拦截器解析Token,校验通过后把用户信息放入ThreadLocal,供后续业务方法直接获取。
这里要提醒一个实操中的细节:JWT的密钥千万不要硬编码在业务代码里,要放在配置文件并通过环境变量注入,方便后续更换。
权限控制只需要在管理端接口上做一层管理员校验,通过一个自定义注解@RequireAdmin实现,这样比在每一个Controller方法里都写权限判断逻辑要优雅得多。
3.3 景点模块的列表、详情与搜索
景点模块是网站的核心,接口设计上主要做三件事:列表分页、条件搜索、详情数据聚合。
列表接口使用MyBatis的分页插件PageHelper,前端只需传递current和size两个分页参数。条件搜索支持按景点名称模糊查询、按所属区域筛选。这里我用动态SQL处理可选条件,MyBatis的<if>标签非常适合这种场景:
<select id="selectPage" resultType="com.example.entity.Attraction"> SELECT * FROM attraction <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="region != null and region != ''"> AND region = #{region} </if> </where> ORDER BY id DESC </select>详情接口则不止返回景点基础信息,还要把该景点关联的攻略数量、收藏数量统计出来,供前端在详情页展示热度。这个聚合查询可以写一条带子查询的SQL完成,不需要查询多次再在Java代码中合并,减少一次网络往返。
3.4 评论与收藏模块的并发安全处理
评论和收藏这两个功能虽然简单,但藏着并发问题。以收藏为例:用户连续快速点击收藏按钮,可能会触发两次请求,如果代码没有做唯一性校验,就会插入两条重复记录。
我的解决方案是在favorite表上建立联合唯一索引(user_id, attraction_id),超过一层的数据库约束兜底。同时在Service层先执行一次查询判断是否已存在,从业务层做拦截。两道关口,基本可以保证数据不会重复。
评论模块则需要考虑内容安全。虽然接入专业的内容安全服务是一个方案,但项目内做了一个简单实现:敏感词过滤工具类,加载一个敏感词列表,如果评论内容命中敏感词,直接返回“内容包含违规词”。再配合管理后台的评论审核功能,已发布评论也可以随时下架。
4. 前端Vue3页面开发与接口对接
4.1 前端工程结构与路由设计
前端页面按照功能模块拆分成若干视图,路由配置遵循“前台和后台分离”的思路。前台路由包括:
/首页:展示推荐景点、热门攻略、文化资讯入口/attraction景点列表:支持条件筛选和分页/attraction/:id景点详情/strategy/:id攻略详情/login登录页面/register注册页面
后台路由统一挂在/admin前缀下,通过路由守卫检查用户角色,非管理员访问时自动跳转到登录页。这样做的好处是,两个模块的导航、布局完全独立,开发时思路更清晰。
4.2 Axios封装与接口模块化管理
如果没有统一的请求封装,前端代码中会到处出现重复的axios调用,而且处理错误的方式不统一,非常容易出问题。我的做法是建立一个utils/request.js文件,在创建axios实例时一次性完成三件事:
第一,设置基础URL为/api,后续所有请求都从这个前缀发出,避免每个接口都写全路径;第二,请求拦截器中从本地存储取出Token,存在就自动添加到请求头;第三,响应拦截器中统一处理返回结果,如果code不是200,直接弹出错误提示,并判断是否需要跳转登录页。
接口模块化管理同样重要。我把所有请求按业务模块拆分到不同文件,如api/attraction.js、api/user.js。每个文件只导出本模块相关的接口函数,页面组件只需引入对应模块函数即可。这种结构带来的好处是,新增接口只需在对应的API文件中添加一行,页面代码保持干净。
4.3 景点列表页的筛选联动实现
景点列表页是我觉得前端开发中最典型的交互场景。页面顶部有搜索框、区域筛选下拉框,下面是对应列表数据。当用户选择不同区域时,列表数据需要自动刷新。
组合式API处理这个场景非常顺手:
import { ref, watch } from 'vue' import { getAttractionPage } from '@/api/attraction' const currentPage = ref(1) const pageSize = ref(10) const keyword = ref('') const region = ref('') const tableData = ref([]) const total = ref(0) async function loadData() { const params = { current: currentPage.value, size: pageSize.value, name: keyword.value, region: region.value } const res = await getAttractionPage(params) tableData.value = res.data.records total.value = res.data.total } watch([keyword, region], () => { currentPage.value = 1 loadData() }) loadData()所有筛选条件都是响应式变量,通过watch同时观察多个条件,条件一变就自动重置页码并重新请求数据。理解了这个模式,基本上所有“列表+筛选”的场景都可以套用。
4.4 图片上传组件的实现要点
旅游网站的景点内容天然离不开图片。我在前端封装了一个图片上传组件,基于Element Plus的el-upload实现。要注意的是上传接口的地址需要指向后端的上传接口,而上传接口返回的应该是图片的访问URL,前端再把URL存入表单数据中,而不是把文件本身存入表单。
后端这边,上传接口的核心逻辑是:接收MultipartFile,生成唯一文件名(避免中文名和重复名),保存到服务器指定目录,然后把该目录对应的访问路径返回给前端。生产环境如果部署在同一台服务器上,可以把图片放到与项目独立的静态目录,通过Nginx映射出去,这样图片访问不经过Java应用,性能更好。
4.5 管理后台页面的增删改查闭环
后台管理页面是另一个重点。这里有一个我强烈建议的细节:所有后台表格页面都遵循“搜索区+表格区+分页区+表单弹窗”这样的布局,实现一套增删改查后,后续页面复制架构再替换字段就可以快速完成。
以景点管理为例,列表页表格展示景点基础信息,操作列包含编辑、删除按钮。新增和编辑共用同一个表单弹窗组件,通过是否有id判断当前是新增还是编辑模式。删除操作需要二次确认弹窗,避免误操作。表单提交成功后,调用列表页的loadData方法刷新当前页数据,同时重置页码到第一页,保证用户看到的是最新内容。
5. 数据库设计与数据初始化
5.1 建表语句与字段类型设计要点
数据库是整个系统的地基,字段类型设计直接影响查询效率和存储成本。以景点表为例,我在实际开发中是这样设计字段的:
CREATE TABLE `attraction` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(100) NOT NULL COMMENT '景点名称', `region` VARCHAR(50) DEFAULT NULL COMMENT '所属区域', `description` TEXT COMMENT '景点描述', `cover` VARCHAR(255) DEFAULT NULL COMMENT '封面图URL', `images` TEXT COMMENT '详情图JSON数组编码', `culture` TEXT COMMENT '文化历史背景', `opening_hours` VARCHAR(100) DEFAULT NULL COMMENT '开放时间', `ticket_price` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '门票价格', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_region` (`region`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点信息表';有几个字段设计细节需要特别说明。第一,images字段用TEXT类型存储JSON数组编码的图片URL列表,读取后在Java代码中解析成List传给前端,这样做避免了创建一张景点图片子表,在图片数量有限的情况下大幅简化了代码;第二,ticket_price用DECIMAL而不是FLOAT,避免浮点数精度问题导致价格显示异常;第三,所有表都加上created_at和updated_at两个字段,排查问题时非常有用。
5.2 初始化数据的内容生产思路
旅游网站的初始数据直接影响网站首次打开时的观感。我花了大量时间在准备初始数据上,而不是随便生成几条测试数据。
每个景点数据都尽量包含完整的描述、文化背景、开放时间和门票价格。攻略内容更是要贴近实际,比如写一条“大理三日游攻略”,就要有每天的行程安排、住宿建议、餐饮推荐,这样前端页面渲染出来才像一个真实的旅游网站,演示效果好。
数据量方面,我的建议是景点数据20到40条、攻略数据20条左右、文化资讯10条左右。这个数量既能保证每页都有内容展示,又不会因为数据太多导致首次加载变慢。
5.3 防止SQL注入与SQL语句性能优化
MyBatis本身通过预编译机制已经解决了一大部分SQL注入问题,但这不意味着可以随意编写SQL。有两个容易忽略的地方:
第一,动态SQL中如果有排序字段,要避免直接拼接外部传入的排序参数。比如ORDER BY ${sortField}这种写法就有注入风险,需要做一个白名单校验,只允许传入预定义的字段名。
第二,分页查询时如果只需要若干字段,不要写SELECT *。尤其是在攻略列表这类需要展示摘要的场景,用LIMIT配合关键字段查询可以显著减少数据传输量。详情页再按ID查询完整内容,体验完全不受影响。
6. 部署上线与常见问题排查
6.1 本地开发环境的完整配置流程
拿到源码后第一步是搭建本地环境。我总结了一套标准操作流程,按这个步骤走基本不会卡壳:
首先安装并配置环境:JDK 1.8、Maven 3.6+、Node.js 14+、MySQL 5.7及以上版本。然后创建数据库:登录MySQL后执行项目中的sql脚本,脚本内包含了建库、建表、插入初始化数据的完整语句。
后端启动前,修改application.yml中的数据库账号密码和端口号。确认无误后在backend目录下执行:
mvn spring-boot:run看到Tomcat started的日志就说明后端起成功了。
前端启动前,在frontend目录下执行:
npm install npm run dev如果没有任何报错,浏览器访问Vite提示的本地地址即可看到网站首页。
6.2 前后端联调中的跨域处理策略
前后端分离最常见的联调问题是跨域。开发环境下,我推荐用Vite的代理配置解决,而不是在后端开启全局CORS。
在vue.config.js或Vite配置文件中添加代理:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/attraction/list时,Vite开发服务器会把请求转发到后端http://localhost:8080/api/attraction/list。前端代码中不需要写完整地址,联调时非常清爽。后端也无需配置跨域,因为从后端的视角来看,所有请求都来自同一个源。
只有一种情况需要后端开启CORS:前端部署在一台服务器,后端部署在另一台服务器,并且不使用反向代理。这种情况下可以写一个WebMvcConfigurer配置类,统一添加跨域映射。
6.3 打包构建与生产环境部署
开发完成后需要把系统打包部署到Linux服务器。前端执行npm run build,产物会生成在dist目录中。这个目录里全部是静态文件,由Nginx托管。
Nginx配置中除了静态文件托管,还要配置接口反向代理:
server { listen 80; server_name your-domain.com; location / { root /opt/yunnan-travel/frontend/dist; 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; } }注意try_files $uri $uri/ /index.html这一行,这是Vue Router使用history模式时必不可少的配置。如果不写,用户刷新详情页或直接访问某个子路由,Nginx会返回404。
后端打包时我推荐跳过测试直接构建:
mvn clean package -DskipTests生成的jar文件复制到服务器,用nohup java -jar xxx.jar &方式启动。是生产环境建议用systemd的方式管理进程,可以实现开机自启、异常退出自动重启。
6.4 开发过程中踩过的典型问题记录
记录几个我在开发中实际遇到并排查过的问题,按出现频率排序:
第一,数据库连接报错Public Key Retrieval is not allowed。解决办法是在JDBC连接串中添加allowPublicKeyRetrieval=true参数。这个问题只会在MySQL 8.0以上版本使用caching_sha2_password认证时出现。
第二,前端请求接口返回404。排查思路先分清是前端路由404还是后端接口404。如果是后端接口404,看请求路径是否经过代理,看Controller中的@RequestMappiing路径是否匹配。
第三,MyBatis的Mapper接口与XML文件绑定失败,提示Invalid bound statement (not found)。高概率原因是application.yml中没有配置MyBatis的XML路径,或者在target目录下XML文件没有被打包进去。检查一下maven打包配置中是否包含xml资源即可。
第四,图片上传成功但页面显示不出来。优先排查图片保存路径是否在静态资源可访问目录下,以及Nginx是否配置了对应location。我自己就吃过一次亏,图片保存到服务器本地目录后,前后端应用都在但在浏览器里无法访问,后来定位到是Nginx缺少映射规则。
7. 个人实操建议与扩展方向
做完整套系统之后,我个人最大的体会是:这类全栈项目真正考验人的不是某一个技术点有多深,而是把各个模块串联起来的能力。从需求分析、数据库设计,到后端接口开发、前端页面搭建,再到最后部署上线,每一步都存在信息差,而补齐这些信息差只能靠实际动手。
一个小技巧分享给准备拿这套源码做二次开发的朋友:先不要急着看全部代码,先把数据库脚本执行起来,然后通过后端接口把数据调通,最后再看前端页面。按照“数据→接口→页面”的顺序理解一套系统,效率远高于从头到尾泛泛阅读源码。
如果之后想在这个系统上做功能扩展,我有几个建议方向:一是可以给景点模块增加一个相册功能,用之前提到的图片数组就能快速扩展;二是可以在攻略模块中加入浏览量统计和热门推荐逻辑,对用户粘性提升比较明显;三是可以增加一个简单的数据统计仪表盘,让管理员看到每天的访问量和新增用户数。这几个方向都不需要改动架构,基于现有代码结构就能逐步扩展。
最后说一句,旅游文化类网站的核心竞争点始终在于内容质量和浏览体验,代码只是承载内容的框架。把接口写稳、页面做流畅、数据管理清晰,这套系统就已经有了扎实的基础,后续可以根据实际运营需求持续迭代。