☰
Vue+SpringBoot旅游网站全栈开发:架构设计、数据库与部署实战
2026/10/13 8:41:55 网站建设 项目流程

这个项目我前前后后理顺过两遍,最先吸引我的是它把“游记分享、酒店预订、特产商城”这三块完全不同的业务塞进了一个系统里,而且用的是 Vue + SpringBoot 这套当前最主流的前后端分离技术栈。对正在做毕业设计、学期课设,或者想完整走一遍全栈开发流程的朋友来说,这类旅游网站是一个很好的练手载体:既有内容展示,又有交易闭环,还有后台管理,业务场景足够覆盖数据建模、权限控制、订单状态流转等大量核心知识点。这篇就围绕这套系统,从架构思路、数据库设计、后端接口、前端联动到上线排查,完整拆一遍我实操下来的理解。

1. 整体设计与技术选型拆解

1.1 为什么是 Vue + SpringBoot,而不是 JSP 或纯前端

很多人在选型时会纠结:旅游网站这种偏展示型的项目,用 JSP + Servlet 好像也能做,为什么非要用前后端分离?

从开发效率角度来看,SpringBoot 对项目依赖和配置做了大量收敛,不需要像传统 SSM 那样写一堆 XML 配置,启动就是一个内嵌 Tomcat,跑起来方便很多。Vue 则把页面交互和接口数据彻底分开,前端只关心渲染和交互,后端只关心业务和数据,两边可以通过接口文档并行开发,不用互相等。

再从后期维护角度看,这套系统里“首页推荐列表”“游记详情”“酒店下单”“购物车结算”这些页面状态多、交互频繁,用 Vue 组件化开发拆分起来非常顺手。比如一个旅游卡片可以抽成HotelCard组件,首页用、列表用、搜索结果也用,改一次样式全站同步,这种复用性在传统模板引擎里实现起来会别扭很多。

另外,现在多数实训项目和企业外包项目都在往前后端分离架构上走,尽早熟悉这套模式,对找实习和工作都有直接帮助。毕设答辩时,面试官或导师问到“为什么前后端分离”,你也能从开发效率、团队协作、维护成本三个角度给出有说服力的理由。

1.2 核心模块怎么划分

这套系统虽然叫“旅游网站”,但实际业务线很清晰,可以拆成四个前台域和一个后台域。

  • 内容域:游记发布、浏览、点赞、评论。核心是 UGC 内容,需要处理富文本、图片、浏览量统计。
  • 资源域:酒店信息展示、房型选择、日期预订。核心是库存和日期的碰撞校验。
  • 交易域:特产商城,包含商品分类、购物车、订单、支付状态。核心是订单状态机和库存扣减。
  • 用户域:注册、登录、个人信息、我的订单、我的游记。核心是用户身份与各业务数据的关联。
  • 管理域:后台维护用户状态、审核游记、管理酒店房型、管理商品上下架、查看订单发货。

把这几个域想清楚后,后面的表设计、接口拆分、前端页面规划全都有了依据。很多新手一上来就急着写代码,结果游记放一张表、订单也挤在一张表里,字段越堆越乱,最后根本没法维护。

1.3 工程结构怎么组织

使用 Maven 多模块还是单工程分包?我的建议是学习阶段用单工程分层就够,结构清晰还容易理解。

后端典型包结构:

  • controller:接收请求、参数校验、返回统一结果
  • service:业务逻辑层
  • mapper:数据库操作层
  • entity:实体类
  • config:跨域配置、拦截器配置、分页插件配置
  • common:统一返回类、异常处理、常量

前端 Vite 或 Vue CLI 工程里则分:

  • api:按模块拆分的接口方法
  • views:页面级组件
  • components:公共组件
  • router:路由配置
  • store:全局登录状态

这么说可能有点抽象。记住一个原则:一个业务域的请求入口、逻辑处理、数据访问都放在同一个垂直链路里,但每个层次独立。比如酒店下单,前端在api/hotel.js里写请求方法,后端在controller/HotelOrderController接收,再调HotelOrderService处理业务,最后通过HotelOrderMapper落库。这样哪天订单逻辑出了问题,能快速定位到层,而不需要在几百行的 Controller 里挖。

1.4 几个关键选型的取舍

接口鉴权用 JWT 还是 Session?我推荐 JWT。前后端分离之后,Session 需要处理跨域 Cookie、同源策略等问题,要额外配置很多。而 JWT 的 Token 由前端存储(localStorage),每次请求在 Header 里带上,后端拦截器统一解析,无状态、跨域友好。这套系统有用户端和管理员端,用 JWT 同时区分身份和接口权限都很方便。

数据库访问层用 MyBatis-Plus 还是原版 MyBatis?做这类管理后台项目,我强烈建议 MyBatis-Plus。绝大多数增删改查、分页查询、条件构造器都能覆盖,单表查询完全不用写 SQL,只有多表关联时才需要手写 XML 里的自定义 SQL。它和老 MyBatis 兼容,不会让你失去写 SQL 的能力。

前端 UI 库选 Element Plus。因为这套系统有大量后台表格、表单、弹窗场景,Element Plus 的组件齐全、文档友好,在社区里遇到的问题基本都能搜到解决方案,对新手非常友好。

2. 数据库建模与接口约定

2.1 用户与角色设计

用户表是所有业务数据的挂靠点。一般情况下设计一张sys_user表就够了:

字段名类型说明
idbigint主键自增或雪花算法
usernamevarchar(50)登录账号,唯一
passwordvarchar(100)BCrypt 加密后的密码
nicknamevarchar(50)用户昵称
avatarvarchar(255)头像地址
phonevarchar(20)手机号
rolevarchar(20)USER / ADMIN
statusint1 正常,0 禁用
create_timedatetime注册时间

密码必须加密存储,这是底线。Spring Security 的BCryptPasswordEncoder或者自定义 MD5 加盐都行,但千万别把明文直接落库。这个表里比较容易被忽视的是status字段,后台管理里需要禁用某个用户时,只需要把这个值改成 0,登录接口里加上状态判断就够了。

用户和游记是一对多,用户和订单也是一对多。所以游记表、订单表里都只需要存user_id,查询时再关联用户名和头像,不需要冗余存储。

2.2 游记与评论模块的建模思路

游记是整个网站的内容核心。我设计的表结构大概是:

CREATE TABLE travel_note ( id bigint PRIMARY KEY, user_id bigint NOT NULL, title varchar(100) NOT NULL, content longtext COMMENT '游记正文', cover_img varchar(255) COMMENT '封面图', location varchar(100) COMMENT '目的地标签', like_count int DEFAULT 0, view_count int DEFAULT 0, status tinyint DEFAULT 1 COMMENT '1已发布 0待审核', create_time datetime );

浏览量view_count是一个高频更新字段。如果每次访问都去UPDATE travel_note SET view_count = view_count + 1,在并发高的时候会有锁竞争。小项目里可以先直接更新,等并发上来再考虑用 Redis 做异步计数,或者前端加节流控制请求频率。

评论表则单独设计,用note_id和parent_id支持两级评论:

CREATE TABLE note_comment ( id bigint PRIMARY KEY, note_id bigint NOT NULL, user_id bigint NOT NULL, parent_id bigint DEFAULT 0 COMMENT '0表示一级评论', content varchar(500), create_time datetime );

这条 SQL 里我特意把content限制在 500 字内,是为了避免有人恶意提交大文本。很多刚接触项目的同学在设计字段时忽略了长度限制,结果别人一个接口就能把你数据库请求拖垮。

2.3 酒店、特产、订单的表结构

酒店模块至少要包含两张表:酒店表和房型表。酒店表存酒店名称、地址、封面、简介、评分;房型表存所属酒店、房型名、价格、剩余房间数。

细节在于预订,不能像普通商品那样只减总库存,因为同一个房型 7 月 1 日和 7 月 2 日的可用房间数可能不同。比较简单的处理方式是设计一张“房型日期库存表”:

字段名类型说明
idbigint主键
room_idbigint房型 ID
stock_datedate具体日期
stockint当日剩余房间数

下单时,锁住预订日期区间对应的多行记录,逐日校验并扣减。这个设计比较严谨,也方便在酒店列表里展示“今日可订”状态。

特产商城的表和普通电商很像:商品表、商品分类表、购物车表、订单表、订单明细表。订单表的关键字段是订单编号、用户 ID、总金额、状态。状态一般有:待支付、已支付、已发货、已完成、已取消。订单明细表则记录商品 ID、购买数量、下单时的商品快照价格。为什么存快照?因为商品价格会变,如果用户下单时是 99 元,发货前商家改成 119 元,那就该按下单时价格结算,所以订单明细里一定要有独立的价格字段,而不是去实时关联商品表。

2.4 统一返回与分页约定

前后端联调最怕各接口返回结构不统一。有的接口返回{code: 200, data: ...},另一个返回{status: 1, result: ...},前端封装请求的时候就很难处理。我在这个项目里统一一个Result类:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("success"); result.setData(data); return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }

分页查询则统一用一个PageResult,包含total(总条数)、records(当前页数据)、current(当前页码)、size(每页条数)。这样前端的表格组件、瀑布流列表都能复用同一套分页逻辑。

约定清楚了,再配合后端的全局异常处理,任何业务错误都返回code=500或业务码,前端只需要在拦截器里统一读取code,不用每个接口单独判断。

3. 后端 SpringBoot 实战:从登录到下单

3.1 工程初始化与依赖清单

建一个 SpringBoot 工程,Java 8 或 11 都可以。核心依赖给我留下印象最深的几个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>

application.yml里配置数据源、MyBatis-Plus 的日志输出和驼峰映射。特别要注意时区配置,连接串里带上serverTimezone=Asia/Shanghai,否则数据库时间和本地时间经常会差 8 小时,我在联调时因为这个排查了很久。

3.2 游记分页接口开发

以“首页热门游记”“分页查询游记”“按目的地搜索”为例,一个完整的后端接口链路是这样的。

Controller 层:

@RestController @RequestMapping("/api/note") public class NoteController { @Resource private NoteService noteService; @GetMapping("/page") public Result<PageResult<NoteVO>> page( @RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "8") Integer size, @RequestParam(required = false) String keyword, @RequestParam(required = false) String location) { return Result.success(noteService.pageNotes(current, size, keyword, location)); } }

Service 层用 MyBatis-Plus 的LambdaQueryWrapper做条件拼接,用Page做分页:

public PageResult<NoteVO> pageNotes(Integer current, Integer size, String keyword, String location) { Page<TravelNote> page = new Page<>(current, size); LambdaQueryWrapper<TravelNote> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(TravelNote::getStatus, 1) .like(StringUtils.hasText(keyword), TravelNote::getTitle, keyword) .eq(StringUtils.hasText(location), TravelNote::getLocation, location) .orderByDesc(TravelNote::getCreateTime); Page<TravelNote> result = noteMapper.selectPage(page, wrapper); // 额外关联用户信息、格式化内容等 return PageResult.of(result); }

这一段核心思路是“条件构造器代替字符串拼接 SQL”。以前用 MyBatis 写动态 SQL,需要大量<if>标签,现在一个LambdaQueryWrapper就搞定了。需要注意的是,like条件一定要有StringUtils.hasText判断,否则传空字符串时LIKE '%%'会扫全表,数据量一大就是慢查询。

3.3 JWT 登录鉴权

登录流程是:用户提交用户名密码,后端校验通过后生成 Token 返回,前端存储 Token,后续请求带上Authorization: Bearer <token>。

我用 jjwt 库来做。生成 Token 的逻辑不复杂:

public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

然后把 token 解析逻辑写进一个拦截器里,在preHandle中校验 Header 里的 Token,解析出用户 ID 后放到ThreadLocal或请求域中,方便后续 Service 获取当前登录用户。

这里有一个新手容易踩的坑:前端的某些接口是公开的,比如首页列表、游记详情、酒店列表。如果拦截器把所有接口都拦住,游客就无法浏览。解决办法是给公开接口加白名单,在拦截器配置类里排除/api/note/page、/api/hotel/list等,只拦截需要登录的接口,比如发布游记、加入购物车、创建订单。

3.4 房间预订与库存扣减

下单是典型的写操作,必须考虑事务和并发。我用@Transactional包裹整个下单逻辑,先查房型是否有效,再逐日校验并扣减日期库存:

@Transactional(rollbackFor = Exception.class) public Order createHotelOrder(HotelOrderRequest req) { for (LocalDate d = req.getStartDate(); !d.isAfter(req.getEndDate()); d = d.plusDays(1)) { int rows = hotelRoomStockMapper.deductStock(req.getRoomId(), d); if (rows == 0) { throw new BusinessException("所选日期房间不足"); } } // 计算总价、生成订单号、插入订单表 }

deductStock对应的 SQL 是:

UPDATE hotel_room_stock SET stock = stock - 1 WHERE room_id = #{roomId} AND stock_date = #{date} AND stock > 0

这里最核心的是最后的stock > 0条件。依靠数据库行锁来保证并发场景下不会超卖,再配合影响行数判断是否扣减成功。如果先查再扣,两个请求同时查到库存为 1,然后各减一次,库存就变成 -1 了。先写后查的原子操作能避免这个问题。

3.5 图片上传与静态资源处理

游记封面、酒店展示图、商品主图都需要上传。本地开发阶段,我通常在项目目录下建一个upload/文件夹,通过MultipartFile接收文件,用 UUID 重命名后保存:

String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; file.transferTo(new File(uploadPath + filename));

然后在配置类里加一个虚拟路径映射,把/api/upload/**映射到本地磁盘目录。这样前端访问http://localhost:8080/api/upload/xxx.jpg就能直接看到图片。

生产环境部署时,推荐用 Nginx 单独托管上传目录,或者把图片放到云存储对象服务里,后端只返回 URL。上传一定要校验文件类型和大小,否则别人传个脚本文件进来就有安全风险。最简单的校验就是扩展名白名单加文件大小上限。

4. 前端 Vue 落地:页面与接口的联动

4.1 前端工程结构与请求封装

前端用 Vue 3 + Vite + Element Plus 起步。工程创建后,我习惯先做 axios 封装,这一步能省掉后面所有页面里的重复代码:

import axios from 'axios' 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) { ElMessage.error(res.msg) return Promise.reject(new Error(res.msg)) } return res.data }, err => { if (err.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(err) } )

把 baseURL 配置为/api,同时在后端统一加了跨域配置和接口前缀,这样前端开发用 Vite 代理到后端 8080,生产部署则用 Nginx 反向代理到/api,同一套代码不用改来改去。

4.2 首页布局与模块组合

首页基本是四个区域:顶部轮播 Banner、热门景点或目的地推荐、最新游记瀑布流、特价酒店和特色商品。

Banner 直接用 Element Plus 的el-carousel组件,数据来自后端“轮播图表”。游记瀑布流可以用 CSS 多列布局,也可以用el-row加栅格。个人推荐每个卡片组件抽出来:

  • HotelCard:展示酒店封面、名称、地址、评分、价格。
  • NoteCard:展示游记封面、标题、作者、浏览量。
  • ProductCard:展示商品图、名称、价格、加入购物车按钮。

首页不需要每个模块都写大量逻辑,更重要的是把“内容展示 → 点击进入详情页”的跳转链路接好。路由跳转参数通常在 query 里带上 id,详情页再拿 id 调接口。

4.3 游记发布与浏览

发布游记是 UGC 系统的核心功能。前端用富文本编辑器让用户输入正文,配上封面上传和目的地选择。富文本编辑器我用的是常见开源方案 wangEditor,对 Vue 3 有官方支持,工具栏可以裁剪,保留标题、加粗、插入图片、插入代码块等能力。

发布时需要注意:编辑器输出的是一段 HTML 字符串,保存到数据库 longtext 字段。详情页展示时直接用v-html渲染。这里必须配合后端的 XSS 过滤处理,否则用户可以在内容里注入脚本。轻量方案是用jsoup的clean方法白名单过滤标签,比如只允许p、img、strong等常用标签。

浏览页的浏览量增加、点赞操作都是典型的写接口。点赞我建议做“去重点”:用户没登录不显示点赞按钮,登录后通过一个接口查询当前用户是否点过赞,点击后切换状态。

4.4 酒店预订与价格计算

酒店详情页里,用户先选房型,再选入住日期和离店日期,右侧会实时计算出总价。这个计算在前端做只是辅助展示,最终价格以后端计算为准。

选择日期的组件我用的是 Element Plus 的日期范围选择器,禁用已经全部售罄的日期。这块要拿到后端返回的“房型日期库存列表”,前端再转换成不可选日期集合,体验会比较好。

价格计算的伪逻辑:

const days = (endDate - startDate) / 86400000 const totalPrice = roomPrice * days * roomCount

要注意毫秒差计算跨时区的问题,建议先格式化再算天数,或者直接用日期字符串相减。我的经验是后端返回日期时统一用yyyy-MM-dd格式,前端不做时间解析,直接用字符串比较,能避开很多时区坑。

4.5 商城购物车与订单流程

商城这块的购物车我选择了本地存储方案,而不是后端建购物车表。因为购物车本质上是一个临时状态,存在 localStorage 里能减轻服务端压力,用户每次重新进入网站也能恢复。

加入购物车时把商品 ID、数量、价格存进去。结算时前端把购物车明细传到后端,后端生成订单。生成订单后前端跳转到支付页。真实对接支付渠道需要商户资质,学习项目里一般做成模拟支付,用户点击“确认支付”后直接把订单状态改成已支付。

订单完成后,用户可以在“我的订单”里看到订单列表、订单状态、商品快照。后台管理员则能看到所有订单,把待发货订单改成已发货。这里订单状态的流转最好在后端常量类里定义好,比如:

public interface OrderStatus { int UNPAID = 0; int PAID = 1; int SHIPPED = 2; int FINISHED = 3; int CANCELED = 4; }

前后端用同一套数字约定,前端展示状态时再映射成中文文本。

5. 上线部署与问题排查记录

5.1 本地联调阶段最常见的 5 个坑

前后端联调阶段,我踩过并帮忙排过最多的坑集中在这几个方面:

首先就是跨域。浏览器控制台报Access to XMLHttpRequest ... has been blocked by CORS。前端页面跑的 5173 端口,后端跑 8080 端口,跨域了。解决方法在后端写一个 WebMvcConfigurer 配置跨域,允许前端地址访问,或者更省事的方式是前端 Vite 配代理,把/api代理到http://localhost:8080。

其次是接口 404。后端接口路径写的是/note/page,前端请求的是/api/note/page,没加前缀。这种问题最好在定义接口时就用全路径,请求封装里固定加/api前缀。

然后是 401 无权限。Token 过期、没有把 Token 放进请求头、后端拦截器解析失败都可能。排查顺序一般是:打开浏览器开发者工具看网络请求的 Header,确认 Authorization 是否存在;再到后端看日志里拦截器有没有打印 Token 解析异常。

还有图片不显示。前端上传后拿到的是一个相对路径/api/upload/2024/xx.jpg,没加域名。开发环境没问题,部署后域名变了就挂了。所以前端展示图片时建议对相对路径统一拼接一个 baseURL 前缀。

最后是日期差 8 小时。MySQL 连接串没加serverTimezone,或者 JSON 序列化时没有格式化LocalDateTime。解决办法是全局配置 Jackson 的日期格式,同时数据库连接串带上serverTimezone=Asia/Shanghai。

5.2 打包部署后的经典问题

前端执行npm run build生成 dist 目录,后端用 Maven 打成 jar 包。部署时如果只有一台服务器,我推荐用 Nginx 托管前端静态文件,同时反向代理后端接口:

server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /opt/travel-front/dist; index index.html; try_files $uri $uri/ /index.html; } }

try_files $uri $uri/ /index.html;这行极其重要。因为前端用了 history 路由,直接刷新/hotel/12时 Nginx 找不到这个真实文件,会返回 404。加上这行配置,所有找不到的路径都会回退到index.html,由前端路由接管,刷新就不出问题了。

后端启动时推荐用nohup java -jar travel.jar > logs/app.log 2>&1 &挂后台运行,再配合一个启动脚本管理。日志文件一定要看,很多部署问题本质上就是数据库连不上、端口被占用,看启动日志一眼就能定位。

5.3 数据量与并发场景的优化建议

这个项目跑在低并发场景下完全没问题,但如果真要上线接流量,有几个优化点值得提前考虑。

首页热点数据缓存到 Redis。游记列表、酒店推荐这些数据不会秒级变化,但访问量最大。用 Spring Cache 在 Service 层加@Cacheable,第一次查库,后面直接查 Redis,能省掉大量重复查询。

点赞、浏览量的计数走异步或 Redis。把VIEW_COUNT的递增改成先写 Redis,再定时同步到数据库,避免高频写库把行锁打满。

数据库层面给常用查询字段加索引。游记表按create_time排序就加联合索引(status, create_time),酒店表按城市检索就加(city_id, star_level)。不加索引时数据量上到十万级,ORDER BY全表扫描,接口响应时间能明显感受到变慢。

订单接口要防重复提交。前端点击“提交订单”后立刻把按钮置灰,后端再加一层:根据用户 ID 和订单内容生成唯一流水号,插入时发现重复就直接拒绝。

最后

做这类全栈项目,最大的收获往往不在写代码本身,而在于把“数据怎么流转”这件事想明白。从用户注册登录拿到 Token,到浏览游记、选择酒店房型、计算价格、生成订单,再到后台审核、发货,每个环节的数据在哪个表、哪个字段、哪个状态里,前后端是怎么约定的,一开始就想清楚,后面写起来会非常顺。

我个人体会是,新人完成这套系统不建议一步到位,先砍掉商城的全部功能,只做游记和酒店预订,跑通后再把商品订单加进去。否则一边写着一边改表结构,很容易把自己绕晕。另外,写完一个模块就马上用日志方式验证一下数据入库是否正常,别等所有功能写完再一起调试,到时候上百个错误堆在一起,连从哪查起都不知道了。

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

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

立即咨询