做文旅网站这个选题,在毕设和个人项目里一直挺常见。但“七彩云南文化旅游网站”这类项目有个特点:名字听着像内容站,实际上踩的是完整的全栈开发链路——从表结构设计到接口鉴权,从组件拆分到Nginx部署,每一环都会暴露问题。我最近帮一个学弟完整调通了这套前后端分离的系统,技术栈是SpringBoot + Vue + MyBatis + MySQL,顺手复盘一下整个开发和部署过程中真正有价值的细节,希望能帮你少走点弯路。
1. 项目定位与技术选型:为什么这套组合成了文旅网站的主流方案
1.1 先想清楚:这个系统解决的是什么问题
文化旅游网站表面上是“展示景点 + 发布攻略”,但剥开来看,它覆盖了一套标准的信息管理场景:游客要查景点、看详情、收藏感兴趣的路线;管理员要维护景点信息、管理评论、发布推荐内容。这意味着系统必须同时具备面向C端的展示能力,和面向B端的后台管理能力。
前后端分离的价值就在这里体现出来了。传统JSP直接渲染页面,首页和后厨揉在一起,改一个展位样式可能要动整个工程。而用SpringBoot做纯后端接口,Vue单独维护页面,两端通过JSON通信,发布和迭代互不干扰。对于一个人单打独斗做毕设或作品集的场景,这种结构也更容易分阶段开发——先把后端接口跑通,再写前端页面,最后联调,不至于一开始就卡在某个环节动弹不得。
1.2 这套技术栈的取舍逻辑
SpringBoot为主流选择,原因不用多说:配置简化、生态成熟、招工时面试官认可度也高。MyBatis这个点反而是不少同学容易纠结的地方——既然SpringData JPA也很好用,为什么选MyBatis?
我的判断是,文旅网站这类业务查询条件多、动态SQL频繁(按地区筛选、按分类筛选、按关键词搜索、标签组合查询),MyBatis对SQL的可控性最强。写一个动态<where>标签就能搞定多种条件组合,SQL调优时也能直接看到最终执行的语句。相比JPA的复杂查询,MyBatis的学习成本更集中在XML语法上,一旦熟练了,写业务接口的效率非常高。
前端选择了Vue,而不选React或Angular,核心原因是上手曲线平滑,中文资料多,遇到问题随手能搜到答案。Vue的响应式数据绑定在做“收藏列表”“评论展示”这类频繁交互的场景里非常顺手,v-for渲染列表、v-if控制权限按钮、computed计算收藏状态,都是直观的写法。
MySQL作为数据库则完全没有争议,免费、稳定、应用广泛,配合Navicat或命令行工具就能完成建库、导数据、调试。
提示:如果你看到市面上很多同类项目用MyBatis-Plus,也不用纠结。这个项目用纯MyBatis,锻炼的是手写SQL的能力;MyBatis-Plus只是在此基础上简化CRUD。学写过一遍手写SQL,再用任何ORM框架都轻松。
2. 前后端分离的模块规划与数据库设计
2.1 功能模块怎么拆
我建议先按角色切分:普通游客和管理员。游客能看到网站展示面,管理员能进入后台维护数据。基于这个思路,前端拆成两个端——门户端和管理端;后端则统一对外暴露接口,按模块组织Controller。
门户端的核心模块通常是:
- 首页:轮播图、热门景点推荐、精选旅游资讯
- 景点模块:景点列表(含筛选分页)、景点详情(含图集和简介)
- 攻略/资讯模块:旅游攻略文章,支持分类查看
- 用户模块:注册、登录、个人中心、我的收藏
- 互动模块:评论景点、收藏景点
管理端的核心模块则是:
- 管理员登录
- 景点管理:增删改查、上下架
- 资讯管理:发布和编辑文章
- 评论管理:审核和删除
- 用户管理:查看注册用户列表
一个常见的错误是功能越加越多,最后做出一堆没用的页面。文旅网站的核心价值在于“信息展示 + 用户互动”,先把这两个主链路打通,再考虑扩展。
2.2 核心表结构设计
数据库设计是整个项目的地基。我建议建这几张核心表,字段不必多,但要满足业务闭环:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 用户表 | id、username、password(加密)、nickname、avatar、role、create_time |
| category | 景点分类表 | id、name、sort |
| attraction | 景点表 | id、name、intro、content、image、category_id、region、status、view_count、create_time |
| article | 资讯/攻略表 | id、title、content、cover、category、author、create_time |
| comment | 评论表 | id、user_id、attraction_id、content、create_time、status |
| favorite | 收藏表 | id、user_id、attraction_id、create_time |
其中有几个字段值得单独说说。
一是password必须存加密后的密文,不能明文入库。常见做法是用MD5加盐或BCrypt。这个项目里我用BCrypt,因为Spring Security里可以直接用BCryptPasswordEncoder,但如果你没引入Spring Security,也可以单独引入spring-security-crypto依赖,只取加密工具类。
二是景点表里的status字段,用于上架/下架。很多同学不做这个字段,导致删景点只能物理删除,数据一旦被误删就找不回来了。加了状态字段后,后台“下架”其实就是把状态改成0,用户端查询条件带status = 1,实现简单又安全。
三是region字段,用于按地区筛选。云南文旅的典型场景就是“按目的地找景点”,所以这个字段在筛选查询中会频繁用到,建议建索引。
CREATE TABLE attraction ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, intro VARCHAR(500), content TEXT, image VARCHAR(255), category_id INT, region VARCHAR(50), status TINYINT DEFAULT 1, view_count INT DEFAULT 0, create_time DATETIME );2.3 联表查询的设计意图
MyBatis联表查询通常有两种实现思路:resultMap嵌套查询和分步查询。这个项目里,我推荐一个直接但实用的做法:写一个AttractionVO,包含景点基本信息加上分类名称,用一条LEFT JOIN查出来。
<select id="selectAttractionVOList" resultType="com.xxx.vo.AttractionVO"> SELECT a.*, c.name AS categoryName FROM attraction a LEFT JOIN category c ON a.category_id = c.id <where> <if test="region != null and region != ''"> AND a.region = #{region} </if> <if test="categoryId != null"> AND a.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (a.name LIKE CONCAT('%', #{keyword}, '%') OR a.intro LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY a.view_count DESC </select>这种写法在数据量不大时性能完全够用,同时还能让前端一次性拿到展示所需的数据,不用额外再调一次分类查询接口。
3. 后端实现要点:鉴权、检索、评论三个核心功能
3.1 登录注册与登录状态保持
登录鉴权是几乎所有全栈项目的入口,也是新手最容易做得“假”的地方。不少同学做登录只是校验一下用户名密码,然后跳转页面,刷新后状态就丢了。这种做法在前后端分离项目里是行不通的,因为后端接口无状态,必须有一种机制让服务器识别“当前请求来自谁”。
稳定的做法是采用JWT(JSON Web Token)方案?用户登录成功后,后端生成一个Token返回给前端,前端把它存在本地,后续每次请求都在请求头里带上Authorization: Bearer <token>。后端通过拦截器统一校验Token,从Token中解析出用户ID等信息。
流程拆开是三步:
- 登录接口校验用户名密码,成功后用
io.jsonwebtoken.JJWT生成Token,把用户ID、用户名、角色塞进去,设置过期时间(一般2小时)。 - 写一个拦截器
LoginInterceptor,实现HandlerInterceptor接口,在preHandle里校验Token,校验通过就把用户信息放入request。 - 在
WebMvcConfigurer里注册拦截器,并配置放行路径——登录、注册、首页、景点列表这些公开接口要放行,收藏、评论、后台管理接口要拦截。
@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); if (jwtUtil.validateToken(token)) { Integer userId = jwtUtil.getUserId(token); request.setAttribute("userId", userId); return true; } } response.setStatus(401); return false; } }这里有个细节值得强调:拦截器里校验通过后,一定要把userId放进request里,后续Controller的方法参数上加@RequestAttribute("userId") Integer userId就能拿到当前登录用户,不需要再查一遍数据库。这是很实用的性能优化习惯。
注册接口稍微简单点——检查用户名是否重复,加密密码,然后插入user表。这里建议在创建用户时直接给定role为普通用户,管理员账号可以手动在数据库里插一条,或者预留一个固定账号初始化,避免把“创建管理员”的逻辑暴露成普通接口。
3.2 景点筛选与分页查询的落地写法
景点列表页几乎必然要用到“按分类筛选 + 按地区筛选 + 按关键词搜索 + 分页”。在前后端分离模式下,这些条件都应该通过Query参数传递,后端用一个统一的分页参数接收。
分页实现有两种路径:MyBatis手写LIMIT #{offset}, #{pageSize},或者用PageHelper插件。我建议新手第一版手写分页,多写几行代码但能把原理吃透——offset = (page - 1) * pageSize,然后查总数时候SELECT COUNT(*)。把这些逻辑封装到一个简单的PageResult<T>类里,包含total、list、pageNum、pageSize四个字段,前端就能完整展示分页组件。
不推荐用PageHelper的原因在于:它通过拦截器自动改写SQL,偶尔会出现统计查询和多表查询的边界case,出了问题不好排查,而且依赖一旦不维护了,项目的升级成本会变高。
接口的Controller层建议保持“薄”的状态,只做参数接收和结果封装,具体业务逻辑放到Service层。这样一个Controller对应一组接口,后面管理端和门户端共用一套Service,减少重复代码。
3.3 评论、收藏模块的实现思路
评论模块的业务逻辑很清晰:用户提交评论(内容校验)、保存进评论表、景点详情页按时间倒序展示评论列表。但这里有一层“审核”维度容易被忽略——评论表里要有status字段,默认待审核。展示接口只查status = 1(已通过)的评论,管理端可以查看全部评论并修改状态。不用做太复杂的审核流,但这个字段一定要有,否则评论区被灌垃圾内容时你没有兜底手段。
收藏模块要考虑的是“幂等”问题。用户连续点击两次收藏按钮,后端不能插入两条收藏记录。规范做法是给favorite表加一个唯一索引(user_id, attraction_id),插入前先查一次是否已存在;收藏和取消收藏两个接口可以合并成一个“切换收藏”逻辑:查询到记录就删除,查不到就插入。
ALTER TABLE favorite ADD UNIQUE INDEX uk_user_attr (user_id, attraction_id);个人中心展示“我的收藏列表”时,用收藏表关联景点表查出来即可。前端显示的是风景卡片列表,数据源来自这个关联查询。
4. 前端Vue实现:从页面拆分到接口联调的完整过程
4.1 页面与路由规划
Vue项目我用Vue CLI搭建。页面结构推荐按“两个端”组织:
src/ views/ portal/ # 门户端 Home.vue AttractionList.vue AttractionDetail.vue ArticleDetail.vue Login.vue Register.vue Profile.vue admin/ # 管理端 AdminLogin.vue AttractionManage.vue ArticleManage.vue CommentManage.vue router/ index.js api/ request.js attraction.js user.js路由层面要有两个重点。一是用vue-router的懒加载(component: () => import(...)),这样首屏只加载需要的页面,不用把所有组件的JS都下载下来。二是对管理端路由做守卫——beforeEach里判断本地Token和用户角色,普通用户访问管理路由时直接重定向到首页。
4.2 axios封装与接口对接
前后端分离项目里的第一个大坑就是接口调用。我习惯在api目录下统一封装axios实例,设置基础URL和请求拦截器:
import axios from 'axios' const request = axios.create({ baseURL: 'http://localhost:8080', 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 => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } ) export default request统一封装的好处很明显:每个页面不需要重复写Token逻辑和错误处理。后续如果接口地址变了,只需要改一个文件。页面里直接调用:
import { getAttractionList } from '@/api/attraction' getAttractionList({ page: 1, pageSize: 8, region: '滇西北' }).then(res => { this.attractions = res.list this.total = res.total })这里有个容易搞混的点:如果SpringBoot后端配置了server.servlet.context-path=/api,那么baseURL应该写http://localhost:8080/api。前后端联调最头疼的问题就是路径对不上,建议一开始就统一接口前缀,比如所有控制器都加上/api前缀,这样规范又省事。
4.3 组件交互细节
景点详情页是互动功能最密集的页面,包括景点图片展示、介绍文本、评论列表、收藏按钮。收藏按钮的状态需要根据“当前用户是否已收藏该景点”来决定。做法是:加载详情的同时调一个“查询是否已收藏”的接口(如果用户已登录),返回true或false,按钮样式随之切换。
评论区建议做成“登录后才能评论”的逻辑。前端在提交按钮上判断本地是否存在Token,没有Token就弹窗提示跳转登录页;后端拦截器也做同样的校验,双保险。评论列表的渲染很简单,v-for遍历即可,难点在于评论时间和日期的格式化。后端返回的时间格式一般是2025-01-15T10:30:00带T的字符串,前端需要处理一下,用JavaScript的Date对象或者一个小函数把它格式化成2025-01-15 10:30展示。
5. 本地运行与服务器部署的完整链路
5.1 本地开发环境的搭建
环境准备这一步看似基础,但很多人卡在版本不匹配上。我推荐的版本组合是:JDK 1.8、Maven 3.6+、MySQL 5.7或8.0、Node 14+、Vue CLI 4.x。如果JDK版本和SpringBoot版本对不上,启动时会有一堆莫名其妙的依赖报错。
数据库初始化建议写一个init.sql脚本,把建库、建表、初始数据都放进去。初始数据很关键——首页一打开就要有景点展示,如果数据库是空的,前端页面显得特别难看。可以插入10到20条景点样本数据和5条资讯示例数据,图片地址先用网络占位图或者项目内的静态资源地址,后面再替换。
导入数据时如果遇到中文乱码,多半是MySQL客户端连接字符集和表字符集不一致导致的。建库时明确指定utf8mb4,并在application.yml里把连接参数的characterEncoding设为utf-8。
5.2 后端打包与前端构建
本地开发跑通联调后,就要准备部署。后端打jar包的命令很简单:
mvn clean package -DskipTests打包时两个细节一定要检查。一是application.yml里的数据库账号密码和服务器环境保持一致;二是本地如果用了文件上传功能,上传目录的路径最好配置成可配置的,不要写死成C:/xxx,否则换到Linux服务器就崩。
前端构建命令:
npm run build构建完成后会在dist目录生成静态文件。这里需要把vue.config.js里的publicPath设置成'/',否则部署到服务器后,页面加载的JS、CSS路径会出现相对路径错乱,首页能打开但样式全丢。
5.3 Nginx反向代理与静态资源托管
服务器上最省心的部署方案是:后端jar包用nohup常驻运行,前端dist目录交给Nginx托管,Nginx再把/api路径的请求反向代理到后端的127.0.0.1:8080。
Nginx配置核心部分:
server { listen 80; server_name your_domain; location / { root /www/wwwroot/travel/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-Forwarded-For $remote_addr; } }try_files $uri $uri/ /index.html;这行特别重要,它能解决Vue Router的history模式刷新404问题。如果不写这行,用户刷新页面时Nginx会去磁盘找不存在的文件路径,返回404;加了这行,所有前端路由都会回退到index.html,由前端路由接管。
后端jar包的使用方式:
nohup java -jar travel-app.jar --server.port=8080 > app.log 2>&1 &后端日志写进app.log,出问题直接tail -f app.log查看,比在控制台干瞪眼高效得多。
6. 开发部署中踩过的坑与排查思路
6.1 跨域问题:现象最吓人,本质最简单
前后端分离开发时,前端跑在http://localhost:8081,后端跑在http://localhost:8080,浏览器会拦截跨域请求。遇到的典型报错是:No 'Access-Control-Allow-Origin' header is present,页面看起来像是接口挂了,但后端日志里接口明明被调用了。
排查链路是这样走的:先用Postman直接调用后端接口,发现能正常返回数据,说明后端逻辑没问题。那么问题定位在前端到后端的请求链路被浏览器策略拦截了,也就是跨域。
解决办法两种:一种是给前后端配上统一域名端口,这需要改前端打包配置;另一种更常用——后端开启CORS支持。SpringBoot里写一个配置类:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }本地开发最简单的方式其实是配置Vue的devServer代理,把/api代理到后端地址,这样浏览器看到的是同源请求,完全绕开跨域:
devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }6.2 日期格式返回带T的问题
开发评论列表时,前端发现时间字段显示成2025-01-15T10:30:00.000+00:00,看着很别扭。根因是Jackson默认序列化Java时间类型时的格式不规范。
排查过程:先看数据库里存的时间对不对——没问题;再用Postman调用详情接口——发现返回的JSON里时间带T。问题定位到后端JSON序列化配置。
解决办法是在application.yml里配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8time-zone这行非常关键,不加GMT+8的话,存进数据库的时间跟北京时间会有8小时时差。
6.3 图片上传后前端访问不到
景点管理功能里,管理员需要上传景点图片。我第一版只在后端Controller里接收MultipartFile并保存到本地目录,结果上传成功后图片URL返回的是http://localhost:8080/upload/xxx.jpg,但浏览器访问这个地址直接404。
排查思路:先确认保存文件的目录下有没有这个文件——有;再确认后端有没有静态资源映射——没有。问题根因是SpringBoot默认只映射classpath:/static/下的静态资源,自定义的上传目录不在映射范围内。
解决方案是加一个WebMvc配置,把/upload/**映射到磁盘目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); }服务器部署时,uploadDir要改成Linux下的绝对路径,例如/www/wwwroot/upload/,并且注意目录权限,否则保存文件时会报权限不足。
6.4 数据库初始化与SQL版本兼容
同学在本地MySQL 8.0导入SQL时遇到一个报错:Unknown collation: utf8mb4_0900_ai_ci。这个排序规则是MySQL 8.0的新特性,如果用很老版本的数据库工具导入,就会报错。
排查过程是:检查SQL文件里建表语句的字符集写法,发现使用了MySQL 8.0默认的排序规则;再确认本地数据库版本,发现是8.0,理论上不会冲突,但导入工具解析出错。
解决办法是调整SQL文件里的字符集配置,统一改成utf8mb4_general_ci,这个排序规则在各个版本都能识别。这类问题在数据库版本差异上很常见,建议写初始化SQL时尽量用通用性强的配置。
最后想说的
做这个项目,我最深的体会是:八成的时间不是花在写功能上,而是花在解决环境、配置、边界条件上。你可能会因为一个跨域问题折腾一个小时,然后发现只是少加了一行注解;也可能会因为数据库时区配置不对,整个项目的时间全部错乱。这些调试过程看起来琐碎,但恰恰是它们真正锻炼了一个开发者排查问题的能力。
如果你要用这套代码做毕业设计或作品集,我的建议是:先耐着性子把数据库表设计完整,把初始数据准备好,再写后端接口,最后做前端页面。顺序一定不要颠倒。表结构不稳,后面所有接口和页面都要跟着返工。另外,部署到服务器后一定自己完整走一遍“游客浏览—注册登录—收藏—管理员登录—修改景点信息”的主流程,确认没有遗漏,再做演示。文旅网站这类项目,功能不用贪多,主链路完整流畅,答辩时的说服力远比堆砌十几个半成品功能要强得多。