计算机毕业设计项目里,基于 Spring Boot 的旅游网站属于那种看着普通、做起来却特别稳的选题:它业务链条完整、前台后台都有、页面多而不难,答辩时随便挑一个功能都能讲得清楚。每年我都会把它放到毕设推荐名单的前排,不是因为新潮,而是因为毕业设计最怕的其实是两个坑——"想做的太大做不完"和"做完了没什么可讲"。旅游网站恰好两头都不沾。这篇文章我就以一个带过不少毕设项目的人的身份,把我自己的选题理由、技术选型、数据库设计、核心功能写法和踩坑记录全部摊开来讲,适合拿着这个题目不知道从哪下手的同学,也适合已经写了一半、正在跟日期序列化或者上传图片较劲的朋友直接跳到对应章节。
1. 毕设选题的“安全牌”:旅游网站到底赢在哪里
1.1 为什么这个题目不容易翻车
很多学生一开口就想做商城,因为觉得电商系统"通用、有说服力"。但电商里光是SKU、库存、优惠券、物流状态就能把一个人拖进泥潭,三个月能做出一套能看的完整商城的人,我见得不多。旅游网站不一样,它保留了电商最核心的"浏览-下单-支付-评价"闭环,但商品模型简单:景点、线路、酒店,每个就是一个对象,顶多加一个日期和余票量,把复杂度控制在了一个毕设选手能真正驾驭的范围。
另外很重要的一点,旅游网站的展示性质很强。景点详情页、线路图文介绍、酒店房型列表这些东西天然需要大量图片和结构化数据,做好了整套系统的观感非常加分。答辩台下坐着的不只是看你代码的人,更多是先看你界面的人。界面正常、数据不假,基本就赢了一半。
1.2 技术栈选型:Spring Boot版本和持久层框架怎么定
先给一套我比较推荐的"稳妥型"组合,后面按这个讲:
| 层面 | 推荐选型 | 说明 |
|---|---|---|
| 开发语言/JDK | Java 8 | 兼容性最好,教师机和自己电脑都不容易出幺蛾子 |
| 后端框架 | Spring Boot 2.7.x | 别碰3.x,JDK17门槛和依赖兼容性对毕设是纯负担 |
| 持久层 | MyBatis-Plus 3.5.x | 单表CRUD省时间,复杂多表还能手写SQL兜底 |
| 数据库 | MySQL 5.7 / 8.0 | 教辅资料多,Navicat直接连就行 |
| 模板引擎 | Thymeleaf | 前后端不分离,一个Spring Boot工程打成一个jar就能跑 |
| 缓存(可选) | Redis + Spring Data Redis | 做一个景点详情缓存就算加分项,不做也不扣分 |
为什么我不推荐Spring Boot 3.x?不是新技术不好,而是你大概率要用的MyBatis-Plus、部分教程里的配置方式老版本只有文章,边界问题一旦出现,你排查的成本会非常高。毕设的目的是完整地做出一个系统、把原理讲清楚,不是给社区测bug。
Thymeleaf和Vue前后端分离怎么选?如果前端能力一般,Thymeleaf最省心:后端渲染、不用起两个服务、部署时一个jar搞定。如果你对Vue确实熟,也可以做成前后端分离,Spring Boot换成只出JSON的REST风格,工作量会多出联调和跨域配置这一块,要自己核算时间。我遇到的大多数同学,老老实实用Thymeleaf反而最后效果最好。
1.3 工程结构怎么搭才干净
后端包结构我习惯这样:
com.example.travel ├── TravelApplication.java ├── common // Result统一返回、异常处理、常量 ├── config // 拦截器、资源映射、MyBatis-Plus分页插件 ├── controller // 前台和后台的Controller ├── service // 业务接口和实现 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体 ├── dto // 前端参数对象(登录传参、下单传参) ├── vo // 视图对象(详情展示、后台列表) ├── utils // OrderNoUtil等工具类这个分层不是为了好看,是为了答辩时老师说"你的项目结构怎么样"你能说出话。Controller只收参数调Service,Service里做事务和业务判断,Mapper只处理SQL,Entity和数据库字段一一对应,VO长什么样由页面决定。写清楚了一层,后面所有功能都往固定的格子里放,反而快。
Response统一返回可以搞一个简单的Result类:
public class Result<T> { private Integer code; private String message; private T data; // 静态方法 ok()、fail() }再用@RestControllerAdvice做个全局异常处理器,把业务异常(比如"余票不足""订单已取消")统一抛出来转成JSON。这件事看着小,但我带过的项目里,凡是前期没做统一返回的,到写前端联调时全是囫囵吞枣式debug,非常拖进度。
2. 先把业务和表结构想清楚:五个核心实体与订单状态机
2.1 需求边界:前台六件事、后台五件事
写代码最忌讳拿着题目直接建表。我一般先做一张功能清单,把"哪些必须做、哪些是加分项"列出来:
前台(游客视角):
- 注册登录(密码加密存储)
- 首页展示热门景点和推荐线路
- 景点、线路、酒店三个列表:关键词搜索、分类筛选、分页
- 详情页:图片、介绍、价格、余量、用户评论
- 下单购买:门票、线路、酒店都能下单,订单能取消
- 个人中心:我的订单、订单评价、个人资料
后台(管理员视角):
- 景点管理:新增、修改、上下架、删除(逻辑删除)
- 线路管理:线路天数、包含景点、出发日期
- 酒店管理:房型、价格、房量
- 订单管理:查看所有订单、修改订单状态、退款标记
- 用户与评论管理:禁用用户、隐藏不当评论、简单统计
这套范围做出来大概2000多行核心代码,对毕设来说刚刚好。千万别再加什么优惠券、秒杀、直播带货,每加一个都会让答辩前的你更狼狈。
2.2 users、attractions、routes、hotels、orders:字段细节
核心就五张表加一张评论表。下面是字段设计里最容易踩坑的几个点:
| 表名 | 关键字段 | 设计备注 |
|---|---|---|
| users | id, username, password, nickname, avatar, phone, status, create_time | password存BCrypt密文,status控制禁用 |
| attractions | id, name, location, image, description, price, stock, hot, status | price用decimal(10,2),stock表示可售门票数 |
| routes | id, name, days, scenic_list, price, max_count, travel_date, description | scenic_list存景点id逗号串,毕设里够用 |
| hotels | id, name, address, star, room_type, price, room_count, image | star表示星级,不是数据库主键 |
| orders | id, order_no, user_id, item_type, item_id, quantity, total_amount, status, create_time, pay_time, cancel_time | item_type区分1景点/2线路/3酒店 |
| comments | id, user_id, item_type, item_id, content, rating, create_time, status | rating取值1-5,status控制是否展示 |
MyBatis-Plus里建议统一加create_time和update_time字段,用MetaObjectHandler做自动填充,这样插入更新不用手动set时间。所有表主键都用Long自增,别在毕设里去玩雪花算法。delete操作一律用逻辑删除字段is_deleted,这样后台数据统计不会被物理删除干扰,答辩时还能顺口说一句"我用了逻辑删除保护业务数据",这就是一个小亮点。
2.3 订单状态机与下单幂等性
订单是整站的业务核心,状态不要瞎存字符串,用数字常量:
| status | 含义 | 可流向 |
|---|---|---|
| 0 | 待支付 | 1已支付、2已取消 |
| 1 | 已支付 | 3已完成、2已取消(退款场景) |
| 2 | 已取消 | 终态 |
| 3 | 已完成 | 终态 |
所有状态流转的SQL都要带条件判断。举个例子,取消订单:
UPDATE orders SET status = 2, cancel_time = NOW() WHERE id = #{orderId} AND user_id = #{userId} AND status = 0如果更新影响行数是0,说明订单不是待支付状态,拒绝本次操作。这样即使前端按钮被快速点击两次,或者后端收到重复请求,也不会把同一单取消两遍。订单号生成我用的是"yyyyMMddHHmmss + 4位随机数 + 用户ID后四位",在做演示数据时看起来像正经订单号,又足够唯一。真要防并发幂等的话,给order_no建唯一索引,插入报DuplicateKey就说明重复下单,直接返回"请勿重复提交"。
3. 核心功能的开发节奏:登录、搜索、下单、评价
3.1 注册登录与登录拦截器
注册时密码一定要加密。我用的是spring-security-crypto里的BCryptPasswordEncoder,单独引这个依赖就行,不需要整个Spring Security。注册流程里做两次校验:第一次是Controller参数校验(用户名非空、密码长度、手机号格式),第二次是Service里查重。注意校验逻辑放在Service里去查数据库,Controller里只做格式校验。
登录成功后把用户信息放进Session,同时写一个简单登录拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute("user") != null) { return true; } response.sendRedirect("/login"); return false; } }然后注册到WebMvcConfigurer里,放行登录注册页和静态资源:
registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/", "/login", "/register", "/css/**", "/js/**", "/images/**", "/upload/**");这么做之后,所有需要登录的页面都会有拦截保护。老师如果问你"权限控制怎么做",你能答上来"登录态校验+角色判断",比空写个过滤器强得多。后台管理再单独加一个管理员判断,在Controller里检查session里的role字段,或者写一个AdminInterceptor,粒度不用太细。
3.2 关键词搜索、菜单分类与分页查询
景点列表最常规的写法是:
QueryWrapper<Attraction> wrapper = new QueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like("name", keyword) .or().like("location", keyword); } wrapper.eq("status", 1).orderByDesc("hot");注意or()和eq()之间的括号问题,如果不加条件拼接很容易把status条件也用or并联,导致筛选失效。稳妥写法是先构造好wrapper再交给service,或者直接手写XML里的动态SQL。
分页用MyBatis-Plus的PaginationInnerInterceptor,在config里注入:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }之后调用new Page<>(pageNum, pageSize),接口返回IPage对象,前端取records、total、pages。这对纯单表分页完全够用。景点详情页需要连表带评论一起统计时,我会写一个自定义Mapper方法,用Page当第一个参数,让拦截器帮我们生成limit和count,具体写法放在后面踩坑章节讲,因为这一块最容易出事。
3.3 下单的事务与余票防超卖
下单逻辑我拆成三步:查商品、扣余量、插订单,包在一个事务方法里。扣余量不能用"先查再set",要靠条件更新保证原子性:
int rows = attractionMapper.updateStock(id, 1); // update stock = stock - 1 where id = ? and stock > 0 if (rows == 0) { throw new BusinessException("余票不足,请选择其他日期"); }只有这条SQL执行成功才继续插入订单记录。订单插入完成后返回订单号,让前端跳到"下单成功"页。整个方法标上:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderParam param) { ... }rollbackFor一定要写,因为Spring默认只回滚RuntimeException,万一中间抛个SQLException或者自己包裹后的异常,事务会悄悄失效。线路和酒店的下单逻辑完全一样,只是表名不同。支付那一段我不建议真的接支付宝或微信,演示时做一个"去支付"按钮模拟支付成功,把status从0改成1就行。答辩老师都清楚毕设支付都是模拟,不会为难你。
3.4 评论评分与后台管理闭环
评论功能要写两个闭环:游客下单后可以评价,后台可以审核隐藏。游客评价时Controller里从session拿userId,评论表里存item_type和item_id,不直接存景点名,展示时再去关联查。评分用1到5的整数radio,存数据库后用SQL聚合出平均分:
SELECT ROUND(AVG(rating), 1), COUNT(*) FROM comments WHERE item_type = 1 AND item_id = #{id} AND status = 1景点详情页展示评价列表时,注意用Thymeleaf的th:text而不是th:utext输出评论内容,防止用户把脚本塞进评论里形成XSS攻击。这个点我在第4章还会展开,因为很多同学的毕设都栽在富文本和评论防XSS上。
后台管理就是一层crud循环,景点、线路、酒店三个Controller加上一个分页列表页面,用同一个后台模板改字段就行。订单管理页按status用下拉筛选,能改状态、能模拟退款。数据统计我做了最简单的三个数字:总订单数、已完成订单数、总收入,用一个SELECT SUM(total_amount) FROM orders WHERE status = 1搞定,够展示用。想看曲线的可以每月日期分组GROUP BY DATE_FORMAT(create_time, '%Y-%m'),两行SQL的事。
4. 一路踩坑的真实排查链路:序列化、上传、分页与事务
4.1 LocalDateTime序列化格式错乱
症状:前端页面或者接口里返回的create_time变成了一串数组[2024,5,1,10,30,0],或者显示成一长串数字。很多同学第一反应是去yml里配spring.jackson.date-format,但配了半天没用,因为那个配置作用于java.util.Date,对Java 8的LocalDateTime无效。
正确的两个做法:字段上直接加注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;或者在配置类里全局设置:
spring: jackson: serialization: write-dates-as-timestamps: false加上jackson的jsr310依赖(Spring Boot一般已自动引入),LocalDateTime就会序列化成ISO标准格式。我自己的习惯是两种都做,字段注解兜底,全局关掉时间戳序列化,保证所有接口时间格式统一。至于Thymeleaf页面上要显示时间,用#temporals.format(createTime, 'yyyy-MM-dd HH:mm:ss'),别直接用toString。
4.2 图片上传到本地却刷新404
这是我见过最普遍的怪问题。前台添加上传接口自己测的时候图片明明传上去了,地址也对,但是复制到浏览器却404。原因通常是这么几种:
第一,MultipartFile默认单文件最大1MB,传大一点的景点图直接被拒。配置里加上:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB第二,上传目录写了项目运行目录下的相对路径,比如/upload/,在IDEA里跑没事,因为路径是IDEA的临时工作目录;一旦打成jar在Linux上启动,相对路径变得不可捉摸,图片要么没写进去要么找不到。踩过几次这种坑后我彻底改成外部绝对路径:application.yml里配file.upload-path=/data/travel/upload/,上传代码把MultipartFile保存到这个目录,然后数据库里只存相对URL比如/upload/20240912/xxx.jpg,最后在WebMvcConfigurer里把/upload/**这个URL前缀映射到文件目录去。
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); }改完之后,无论本地还是服务器,页面都能稳定显示图片。记住:文件一定落到外部目录,绝对路径统一配在配置文件里,URL前缀和磁盘路径做映射,别把上传逻辑和Tomcat工作目录耦合。
4.3 MyBatis-Plus自定义SQL分页统计出错
做景点详情连评论、线路包含景点这类多表查询时,免不了写自定义SQL。你如果直接在@Select注解里写了SELECT ... LIMIT #{offset}, #{size},然后又把Page对象传进去,分页拦截器会再补一条LIMIT,结果SQL里出现两个LIMIT直接报错。
正确的姿势是:自定义SQL里不写LIMIT,只写业务查询,把Page<T>作为Mapper方法第一个参数传进去,PaginationInnerInterceptor会自动在查询后面加LIMIT,同时生成一个COUNT查询用于统计总条数。但多表联查时自动生成的COUNT SQL经常统计不准,比如LEFT JOIN导致重复行数变大。我最后的处理方式是自己控制计数SQL:
SELECT COUNT(*) FROM ( SELECT a.id, a.name, COUNT(c.id) AS comment_count FROM attractions a LEFT JOIN comments c ON c.item_type = 1 AND c.item_id = a.id AND c.status = 1 WHERE a.status = 1 <if test="keyword != null and keyword != ''"> AND (a.name LIKE CONCAT('%', #{keyword}, '%') OR a.location LIKE CONCAT('%', #{keyword}, '%')) </if> GROUP BY a.id ) t分页的主查询对应写成查明细的SQL,注意GROUP BY字段不要出现在SELECT里乱七八糟的别名上,否则MySQL ONLY_FULL_GROUP_BY模式下直接报错。这一块建议基础一般的同学直接用单表分页+Service里补评论数,能少一半麻烦。
4.4 下单事务静默失效的典型现场
有同学发来代码问我:下单方法明明加了@Transactional,但故意在插入订单后抛异常,发现余量照样扣了。我一看代码,他是在同一个Service类的另一个方法里调用了加了事务注解的方法,比如controller调saveOrder,saveOrder内部this.updateStock(...)。Spring的事务是基于AOP代理的,内部自调用相当于直接穿透代理,注解根本没生效。
解决的办法有三种,任选一个都行:
- 把要事务包裹的下单逻辑单独拆一个Service实现类,Controller调用这个Service的public方法,事务代理才会介入。
- 在类里注入自己的代理对象,用代理对象调用。
- 在方法内部用
AopContext.currentProxy()强转后调用(需要设置exposeProxy)。
我自己习惯用第一种,结构最清楚。另外还要检查调用链上有没有try-catch把异常吞掉。事务只有在你把异常抛出去的时候才能感知,你在方法内部catch住又return了个错误提示,Spring能怎么办?它只会觉得自己很成功。所以下单方法里所有的业务异常都直接throw,让全局异常处理去统一提示,不要return个false回来。
还有一点很少人注意:MySQL要是用了MyISAM这种不支持事务的引擎,@Transactional写了也白写,5.7默认就是InnoDB所以一般没问题,但导入老师给的模板库时很可能碰上旧表引擎,建表SQL里统一ENGINE=InnoDB最稳。
5. 部署上线与毕业答辩的加分细节
5.1 配置文件分环境:一处打包到处运行
我在application.yml最开头只写公共配置,然后拆出application-dev.yml和application-prod.yml,dev给本地连127.0.0.1的库,prod给服务器连远端数据库。启动时通过参数切环境:
java -jar travel-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod数据库密码、上传路径这类环境相关配置只放到环境配置文件里,不要写死在公共yml。好处是:本地开发用dev,答辩前部署到服务器用prod,同一个jar包,切个参数就行,也方便老师在你电脑上直接跑。配置文件里顺手把端口配成8080,然后打包时用mvn clean package -DskipTests,生成target目录下的jar。
5.2 Linux服务器部署与Nginx反代
整个部署流程其实半小时能走完:
# 1. 上传jar包和SQL文件 scp travel.jar root@服务器IP:/opt/travel/ # 2. 服务器上创建上传目录并授权 mkdir -p /data/travel/upload chmod -R 755 /data/travel/upload # 3. 数据库导入SQL mysql -uroot -p travel_db < travel.sql # 4. 启动服务 nohup java -Xms256m -Xmx512m -jar travel.jar \ --spring.profiles.active=prod \ > /opt/travel/travel.log 2>&1 &如果想让网站访问更正经一点,可以用Nginx做反向代理。配置一个server块,把域名或公网IP的80端口代理到127.0.0.1:8080,同时设置client_max_body_size 20m处理大图上传,请求头里加上X-Forwarded-For让Spring拿到真实IP。真到了答辩演示那天,老师如果问"你的系统能远程访问吗",你说我已经部署在云服务器上了,然后把手机流量打开访问给他看,这一下印象分基本就稳了。
5.3 演示数据准备与答辩高频提问清单
我见过很多同学系统做完,上答辩台却因为没有演示数据而冷场。景点只有一条"测试",评论也没有,订单列表空荡荡。演示前一定要自己把数据填满:至少8个景点覆盖不同分类和热门度,2到3条线路,3个酒店,然后用两个测试用户分别下单到不同状态,留下几条真实感评论。演示前把浏览器缓存清掉,从注册或登录开始一步一步走完"搜索景点→看详情→下单→我的订单→评价"这条链,中途不用快进,节奏反而稳。
下面是答辩环节被问概率最高的几个问题,直接背答案没问题:
- Spring Boot为什么能自动装配?核心是@SpringBootApplication里的@EnableAutoConfiguration,Spring Boot的starter包里的META-INF/spring.factories或AutoConfiguration.imports里声明了大量自动配置类,再配合@Conditional系列注解按条件生效,项目启动时把需要的Bean自动注册进容器。
- 分页是怎么实现的?MyBatis-Plus的分页插件是一个Mybatis拦截器,拦截带Page参数的Mapper方法,在执行前改写SQL,补上LIMIT语句,并额外执行COUNT查询拿到总记录数。
- 怎么防止用户把库存买超?核心是数据库条件更新
update attraction set stock=stock-1 where id=? and stock>0,影响行数为0就说明没库存了,加上事务保证扣库存和插订单要么都成功要么都失败。 - 为什么不接真实支付?毕设重点是业务流程闭环,支付作为第三方服务可通过模拟接口展示订单状态流转,真实对接支付宝/微信涉及资质和密钥,不适合个人毕设环境。
一节一节写下来,你会发现其实每个功能拆开都不是什么黑科技,但把它们串成一个能跑、能讲、能部署的系统,才是毕设真正的意义。我最后唯一想强调的还是那句话:不要为了显得厉害去堆一堆自己讲不清的技术栈,答辩老师最想看到的,是一个学生能把自己做的每一行代码说明白,而不是一个连自己项目都圆不回来的"高级工程师"假象。把这套旅游网站做扎实,从数据库到部署全自己走一遍,你毕业答辩那天会比大多数人从容得多。