基于SpringBoot与Vue的旅游管理系统毕业设计实战
2026/9/16 16:04:52 网站建设 项目流程

简介:一套基于Spring Boot与Vue的旅游管理系统毕业设计资源,面向计算机相关专业正在做毕设的学生,以及需要项目实战的Java学习者。系统覆盖管理员与用户双端功能:管理员可进行用户管理、景区分类与信息管理、景区商城、商品分类、用户分享、投诉建议、系统管理及订单管理;用户端支持个人中心、用户分享、投诉建议、我的收藏与订单管理,功能完整,页面简洁易用。资源包共1893个文件,以Java、Vue、HTML、JavaScript、CSS等前后端源码为主,同时包含SQL数据库脚本、Maven配置、项目文档及部署说明,压缩包整体约139MB。已有1358人学习下载。项目已通过导师指导并完成调试,附带数据库脚本与部署视频,可直接用于课程设计或毕业设计参考,也适合用于Spring Boot与Vue整合开发的项目实战练习。

1. 旅游管理系统这个毕设题,SpringBoot + Vue 前后端分离是对的打开方式

本科毕业设计的旅游管理系统,最常见的毛病是把题做小了:只写景点表的增删改查,答辩时被问一句“订单状态怎么流转”就没了下文。这个题目的正确打开方式,是围绕用户、景点、线路、酒店、订单、评论六个实体,用 SpringBoot + Vue 前后端分离跑通完整闭环。

这套方案的价值在于,每个环节都是能被追问的技术点:JWT 登录鉴权、MyBatis-Plus 分页、下单事务、vue-router 路由守卫。吃透这几个点,既能避免“只做了一个 CRUD 系统”的评价,也能当 springboot+vue 前后端分离的练手项目。

适合两类人:Java 基础一般、需要稳妥过审的本科生,以及想快速搭管理后台 demo 的开发者。下面按“设计 → 后端 → 前端 → 联调”展开,每个模块都能直接照做。

2. 数据库设计与 SpringBoot 工程骨架:先把旅游系统的数据模型立住

2.1 旅游系统的核心实体:六张表的关系不用画太复杂

先想清楚一个旅游管理系统里有哪些“名词”。用户、景点、线路、酒店、订单、评论,这六个就够。很多网上的表结构会拆出七八张关联表,对毕设来说反而增加维护负担。关系上只需要明确三点:一个用户下多张订单;一张订单只属于一个用户;订单可以同时承载“门票、酒店、线路”三类商品,用 item_type 区分。评论挂在景点和用户之间,一对多,不需要额外建中间表。

表名核心字段设计说明
userid, username, password, phone, role, create_timerole 用 tinyint,0 普通用户,1 管理员
scenicid, name, city, ticket_price, open_time, description, imageimage 存相对路径或文件名,不存完整 URL
routeid, title, days, price, spots, descriptionspots 用逗号分隔的景点 id,简单但好实现
hotelid, name, city, price, star, imagecity 字段用于和景点做同城推荐
ordersid, order_no, user_id, item_type, item_id, item_name, quantity, total_price, statusitem_type 1 门票,2 酒店,3 线路
commentid, user_id, scenic_id, content, score, create_timescore 控制在 1-5,前端做星级展示

2.2 订单表为什么设计成“通用订单”

很多毕设会把订单拆成景点订单和酒店订单两张表,理由是“类型不同字段不同”。但实际写起来会发现,两张表意味着两个 Controller、两套 CRUD、两套列表页,而字段差异其实只有 item_id 指向的目标不同。通用订单的做法是用 item_type + item_id 组合指向任意商品,再冗余一个 item_name 字段,这样订单列表页不需要 join 就能显示商品名称。

这个设计在答辩时是加分点。导师问“为什么订单表只有一张”时,回答是“所有订单状态的流转逻辑是一致的,拆表会导致重复代码,后期加线路订单只需要新增一个 item_type 值”。这个回答同时展示了数据建模能力和对代码复用的理解。

2.3 用 SQL 脚本把用户表和订单表建出来

数据库用 MySQL 8.0,字符集统一 utf8mb4。下面给出最核心的两张表,景点、酒店、线路、评论表结构一致,不单独展开。

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint NOT NULL DEFAULT '0' COMMENT '0普通用户 1管理员', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,业务上唯一', `user_id` bigint NOT NULL COMMENT '下单用户ID', `item_type` tinyint NOT NULL COMMENT '1门票 2酒店 3线路', `item_id` bigint NOT NULL COMMENT '对应商品的ID', `item_name` varchar(100) DEFAULT NULL COMMENT '商品名称,冗余避免join', `quantity` int NOT NULL DEFAULT '1' COMMENT '数量', `total_price` decimal(10,2) NOT NULL COMMENT '总金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

密码字段长度给到 varchar(100),是因为 BCrypt 加密后的字符串是 60 位,长度 50 会截断。order_no 建唯一索引有两个原因:订单号作为对外展示的业务编号,不应该让用户看出自增主键;下单接口被重复提交时,唯一索引能把并发重复插入挡在数据库层。这里刻意不加外键约束,MyBatis-Plus 做逻辑删除和批量插入时,外键会引入不必要的锁开销,外键关系由 Service 层保证。答辩时可以说“外键由应用层维护,避免高并发下单时触发级联锁”。

注意:MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver,老教程里的 com.mysql.jdbc.Driver 在 8.0 下会直接启动失败。

2.4 SpringBoot 工程骨架:用 2.7.x + JDK 8 还是 3.x + JDK 17

建工程遇到的第一道坎是 SpringBoot 版本。SpringBoot 3.x 强制要求 JDK 17,不少毕设环境还是 JDK 8,环境变量配来配去,启动就报 UnsupportedClassVersionError。建议直接用 SpringBoot 2.7.18,它是 2.x 的最后一个版本,JDK 8 和 JDK 17 都能跑,MyBatis-Plus 3.5.x 也完全兼容,省掉一堆版本适配问题。java 环境变量配置只需要确认 JAVA_HOME 指向 JDK 安装目录、PATH 里有 %JAVA_HOME%\bin。

依赖用 Maven 管理,pom.xml 里最核心的依赖如下。jjwt 需要同时引入 api、impl、jackson 三个包,只引第一个会在运行时抛 ClassNotFound。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <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.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

MyBatis-Plus 承担单表 CRUD 和分页,BaseMapper 自带的 selectById、selectPage 能省掉手写 XML 的时间,也避免“mapper.xml 路径写错导致启动报 Invalid bound statement”这类低级问题。实体类用 @TableName("orders") 显式指定表名,防止 Order 这种类名和 SQL 关键字冲突。

工程目录按 controller / service / mapper / entity / config / common 分包,common 里放统一返回和异常处理。这个结构不花哨,但答辩时能顺口说出每层职责:Controller 只做参数接收和路由转发,Service 放业务逻辑,Mapper 只碰 SQL。

2.5 application.yml 配置:数据源、驼峰映射和日志

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false

url 里的三个参数是必写项:useUnicode=true 打开 Unicode 开关,characterEncoding=utf8 保证中文不乱码,serverTimezone=Asia/Shanghai 避免驱动和数据库时区不一致导致日期差 8 小时。map-underscore-to-camel-case 让数据库的 create_time 自动映射到实体字段 createTime,否则查出来的字段全是 null。log-impl 配成 StdOutImpl 后,控制台会打印每条 SQL 和参数,联调时定位问题比看接口日志直接得多。

3. SpringBoot 后端接口:JWT 登录、统一返回与景点分页查询

3.1 统一返回结果 Result:让前端拦截器只认一个 code

前后端分离项目里,接口返回格式不统一是联调阶段最浪费时间的坑。有的接口返回 {code:200, data:...},有的直接返回裸数组,前端每个页面都得单独判断。封装 Result 是第一步,所有 Controller 都返回它。

@Data public class Result<T> { private Integer code; // 200 成功,401 未登录,其余为业务失败 private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }

code 的含义要和第四章的 axios 响应拦截器对齐:200 直接取 data,401 统一跳登录页,其他 code 弹 message 提示。不要把业务状态和 HTTP 状态码混用,“库存不足”应该返回 200 + code 500,而不是 HTTP 500,否则 nginx 访问日志里全是误报的 5xx,答辩演示时很难看。

3.2 JWT 登录鉴权:拦截器里解析 token,别用 session

管理员接口必须鉴权。用 JWT 而不是 Session,原因有两个:接口是前后端分离的,前端可能部署在另一个端口,SessionId 跨域传递要额外配置;后续如果接小程序端,同一套 token 机制能直接复用。登录接口校验完用户名密码后生成 token 返回前端,前端存 localStorage,以后每次请求带在 Authorization 头里。

public class JwtUtil { private static final String SECRET = "travel-demo-change-me-2024"; private static final long EXPIRE_MILLIS = 24L * 60 * 60 * 1000; public static String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_MILLIS)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } }
写法对比jjwt 0.9.1jjwt 0.11.5
构建解析器Jwts.parser().setSigningKey(secret字符串)Jwts.parserBuilder().setSigningKey(Keys.hmacShaKeyFor(...)).build()
签名密钥字符串直接传必须用 Keys.hmacShaKeyFor 生成 SecretKey
依赖单个 jjwt 包jjwt-api / jjwt-impl / jjwt-jackson 三件套

这里是最常见的版本坑:jjwt 0.9.1 的写法在 0.11.5 下编译直接报错。另外 SECRET 字符串必须超过 32 字节,否则 HS256 初始化会抛弱密钥异常。密钥硬编码在代码里属于毕设阶段的妥协,更合理的做法是放进 application.yml,用 @Value 注入。

拦截器负责在请求进 Controller 之前完成校验。preHandle 里取 Authorization 头,去掉 Bearer 前缀后解析 claims,把 userId 和 role 放进 ThreadLocal 供 Service 使用,接口方法里就不用每层都传 userId。请求结束后在 afterCompletion 清理 ThreadLocal,防止线程池复用导致用户数据串号。

3.3 景点分页查询:MyBatis-Plus 的 Page 直接返回给前端

景点列表是管理系统最典型的接口:关键字模糊搜索、城市筛选、分页。Controller 接收四个参数,Service 层用 LambdaQueryWrapper 组装条件,避免字符串拼接 SQL 的注入风险。

@RestController @RequestMapping("/api/scenic") public class ScenicController { @Autowired private ScenicService scenicService; @GetMapping("/page") public Result<Page<Scenic>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) String city) { return Result.ok(scenicService.pageQuery(pageNum, pageSize, keyword, city)); } }

Service 实现:

public Page<Scenic> pageQuery(Integer pageNum, Integer pageSize, String keyword, String city) { Page<Scenic> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Scenic> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Scenic::getName, keyword) .eq(StringUtils.hasText(city), Scenic::getCity, city) .orderByDesc(Scenic::getTicketPrice); return scenicMapper.selectPage(page, wrapper); }

LambdaQueryWrapper 的 like 和 eq 第一个参数是 boolean,为 false 时该条件不拼进 SQL。用 StringUtils.hasText 判断参数为空的情况,可以做到一个接口同时支持全量列表和条件筛选。Page 对象序列化到前端后包含 records 和 total,records 是当前页数据,total 是总条数,正好对应 Element Plus 的分页组件。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

这个配置注册的是 MyBatis-Plus 分页插件,它会把 Page 参数转换成带 LIMIT 的 SQL 再执行,同时自动生成 count 查询。不配这个拦截器,selectPage 返回的是全表数据的内存假分页,数据量一大就卡,这是分页查询最容易踩的坑。

3.4 下单接口:@Transactional 和订单号生成

下单涉及插入订单、更新商品余量两个写操作,必须放在同一个事务里。Spring 的 @Transactional 默认只在抛出 RuntimeException 时回滚,如果方法里 catch 住异常再返回 Result.fail,事务不会回滚,会出现余量扣了但订单没生成的情况。所以下单方法要么不 catch,要么在 catch 里手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

订单号用“日期 + 4 位随机数”生成,比拿自增 id 当订单号安全。日期前缀还有一个演示价值:答辩时连续下两单,屏幕上能看到两个不同的订单号,比 1、2、3 有说服力。订单表上的 order_no 唯一索引是最后一道防线,即使并发重复提交,数据库也会拒绝第二次插入并抛 DuplicateKeyException,这个异常被全局异常处理器转成“订单已存在”提示。

4. Vue 前端:路由守卫、axios 封装与景点管理页面

4.1 用 Vite 创建前端工程并完成 vue 安装依赖

前端用 Vue3 + Vite,比 Vue2 的 webpack 模板启动快得多,npm install 和 npm run dev 就能跑起来。开始前先确认 Node 版本,vue 安装及环境配置这一步最容易出问题的是 Node 版本太老导致 Vite 5 无法启动,建议 Node 16 以上。

node -v npm create vite@latest travel-web -- --template vue cd travel-web npm install npm install element-plus vue-router@4 axios npm run dev

npm 安装依赖慢是国内环境的常态,装到一半失败通常不是代码问题,而是源的速度问题。执行 npm config set registry https://registry.npmmirror.com 换源后重新 install,多数报错能解决。这里刻意不装 pinia,旅游管理系统里需要跨页面共享的只有 token 和用户信息,都放 localStorage 就够。引入 pinia 后反而要回答“为什么需要状态管理”这个追问。

4.2 封装 axios:请求拦截器带头、响应拦截器统一处理

axios 不封装直接用在页面里,效果是每个页面都写一遍“取 token 塞 header、判断 res.code、处理 401 跳登录”。封装之后,页面里的调用变成 request.get('/scenic/page', { params }),返回值直接就是业务数据。

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:把 token 带到 Authorization 头 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 } ElMessage.error(res.message || '请求失败') return Promise.reject(res) }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error('网络异常,请检查后端服务') return Promise.reject(error) } ) export default request

baseURL 写成 /api 而不是 http://localhost:8080/api,是为了避免硬编码。开发时后端跑在 8080,可以在 vite.config.js 里把 /api 转发到后端端口;部署时由 nginx 把同一个域名的 /api 指向后端,前端代码一行不用改。响应拦截器直接 return res.data,调用方拿到的就是业务数据,页面里不需要 res.data.data 这种双重取值。

4.3 vue-router 路由配置与登录守卫:未登录一律回登录页

路由是前端权限体系的入口。页面分成三类:公开页面(首页、景点详情)、登录页、需要管理员权限的后台页面。后台页面的路由配置里挂 meta.requiresAuth 和 meta.role,全局 beforeEach 守卫根据标记做判断。

import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', component: () => import('../views/Login.vue') }, { path: '/admin', component: () => import('../layout/AdminLayout.vue'), redirect: '/admin/scenic', meta: { requiresAuth: true, role: '1' }, children: [ { path: 'scenic', component: () => import('../views/admin/ScenicManage.vue') }, { path: 'orders', component: () => import('../views/admin/OrderManage.vue') } ] }, { path: '/scenic/:id', component: () => import('../views/ScenicDetail.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.meta.role && to.meta.role !== role) { next('/') return } next() }) export default router
meta 字段类型作用
requiresAuthboolean需要登录,未登录跳 /login
rolestring需要的角色,与 localStorage 里的 role 比对

这里用到两种 vue 路由参数写法:列表页跳详情页推荐 path + query,即 router.push({ path: '/scenic/detail', query: { id: row.id } }),刷新后参数还在地址栏里,复制链接也能定位;只在组件内部流转的临时数据才用 params。路由守卫只判断 token 是否存在,不解析是否过期,过期判断交给 axios 的 401 响应处理,职责更清晰。登录后要把 role 也写进 localStorage,否则角色守卫永远放行。

4.4 Element Plus 景点管理页:表格、分页和查询条件的组合

后台管理页面的核心是表格 + 分页 + 搜索条件,几乎每个模块都是这个模式,写熟一个模块,线路管理、酒店管理就是复制改字段。下面是景点管理页的最小可运行版本。

<template> <div> <el-input v-model="keyword" placeholder="景点名称" clearable style="width: 200px" /> <el-button type="primary" @click="handleSearch">查询</el-button> <el-table :data="list" border stripe style="margin-top: 12px"> <el-table-column prop="name" label="景点名称" /> <el-table-column prop="city" label="城市" width="120" /> <el-table-column prop="ticketPrice" label="门票(元)" width="120" /> <el-table-column label="操作" width="160"> <template #default="{ row }"> <el-button size="small" type="primary" @click="router.push({ path: '/scenic/detail', query: { id: row.id } })"> 详情 </el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total" layout="total, prev, pager, next" style="margin-top: 12px" @current-change="loadData" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import request from '../../api/request' const router = useRouter() const list = ref([]) const keyword = ref('') const pageNum = ref(1) const pageSize = ref(10) const total = ref(0) async function loadData() { const data = await request.get('/scenic/page', { params: { pageNum: pageNum.value, pageSize: pageSize.value, keyword: keyword.value || undefined } }) list.value = data.records total.value = data.total } function handleSearch() { pageNum.value = 1 // 搜索时回到第一页,避免停在空页 loadData() } onMounted(loadData) </script>

keyword.value || undefined 这行值得展开:输入框被清空后 keyword 是空字符串,axios 序列化时会把 ?keyword= 发出去,后端 StringUtils.hasText 能兜住,但 URL 里挂着空参数不干净,传 undefined 则 axios 直接忽略该参数。el-pagination 的 current-change 只在页码变化时触发,所以查询按钮里要手动把 pageNum 重置为 1,否则用户在第三页搜索时,会带着旧页码请求,出现“明明有数据却搜不到”的假象。这个细节在答辩演示时很容易暴露,提前处理掉。

5. 联调、打包部署与答辩验证:SpringBoot + Vue 收尾的细节

5.1 开发联调阶段的跨域配置

前端 dev server 跑在 5173,后端在 8080,浏览器会拦截跨域请求。最省事的做法是在后端加全局 CORS 配置,前端不需要额外转发配置,dev 和 prod 行为一致。

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }

注意是 allowedOriginPatterns 而不是 allowedOrigins:前者可以和 allowCredentials(true) 共存,后者配 * 时,携带 cookie 的请求会被浏览器拒绝。OPTIONS 预检请求必须放行,因为 axios 带了 Authorization 自定义头,浏览器会先发一次 OPTIONS 探路。部署到同端口后,这段配置保留也不影响。

5.2 打包:前端 dist 交给后端,java -jar 一键启动

前端 npm run build 生成 dist 目录,后端 mvn clean package -DskipTests 生成 jar。答辩推荐把 dist 复制到后端 resources/static 下重新打包,一个 jar 里既有页面又有接口,在任何机器上都能 java -jar 跑起来,现场演示不依赖 Node。

npm run build cp -r dist ../travel-server/src/main/resources/static/ cd ../travel-server mvn clean package -DskipTests java -jar target/travel-0.0.1-SNAPSHOT.jar

用 nginx 托管 dist、把 /api 请求转发到 8080 是另一种方式,但答辩现场多一个进程就多一个故障点。前端使用 history 模式路由时,刷新 /admin/scenic 会 404,因为静态服务器没有这个物理文件,这是 hash 模式不存在的坑,演示前务必确认刷新页面能正常回显。

5.3 答辩前按这个清单验证,再处理时间格式这个隐藏坑

主线流程全部走一遍:管理员登录 → 新增景点 → 前台注册用户 → 搜索景点 → 下单 → 查看订单 → 修改订单状态。每条都要实际点一遍,不能只在接口工具里调。补两条异常路径:不登录直接访问 /admin,看是否被守卫弹回登录页;用普通用户账号访问管理接口,看是否被 role 校验拦下。

验证项预期结果常见失败原因
登录成功生成 tokenlocalStorage 出现 tokenBCrypt 密码校验失败
景点分页查询records 少于等于 pageSize 条分页拦截器没配置
不登录访问后台路由跳转 /login守卫里 meta 没写对
下单后订单列表可见item_name 正确显示item_type 映射错误

最后一个容易翻车的点在时间字段。数据库的 datetime 被 Jackson 序列化后带毫秒和时区偏移,页面显示类似 2024-01-01T08:00:00.000+00:00 的格式。在实体时间字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),列表和详情的时间显示就统一了。如果改用全局配置 spring.jackson.date-format 和 time-zone 要注意:它对 java.util.Date 生效,对 LocalDateTime 不一定生效,LocalDateTime 需要额外引入 jackson-datatype-jsr310 并单独配置。把 timezone 写成 GMT+8,是演示时最不容易翻车的一行配置。

本文还有配套的精品资源,点击获取

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

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

立即咨询