说实话,做旅游路线类的管理系统,市面上能搜到的教程十有八九是那种“纯增删改查”的demo,数据库两张表,页面套个模板,能跑起来就完事。但这几年实际带项目、看别人交上来的作业,我发现真正能把一套Spring Boot + JSP的Web系统做“通”的人并不多。不是框架不会用,而是整个链路里那些“技术上不难、但没人提醒你就容易栽跟头”的细节,才是拉开差距的地方。
这篇博客我会结合自己做过的一个基于Spring Boot的旅游路线管理系统,把从需求拆解、数据库设计、核心模块实现到部署上线的完整思路讲清楚。这套代码结构不复杂,但覆盖了Java Web开发最常用的一套组合:Spring Boot做后端、JSP + JSTL做页面渲染、MyBatis操作MySQL,权限上用拦截器处理。适合正在做课程设计、毕业设计,或者想找个“带业务逻辑”的项目练手的同学参考——它比单纯的后台管理多了路线浏览、收藏、下单这些前端用户行为,比纯电商又轻量不少,作为练手项目非常顺手。
1. 项目整体设计与技术选型思路
1.1 这个系统到底要解决什么问题
先给这个系统画个像:用户分游客、注册用户、管理员三类角色。游客能看路线列表和详情;注册用户可以收藏路线、提交预订订单、对已经去过的路线发表评论;管理员在后台维护路线信息、处理订单、管理用户和评论。
说白了,它就是一个轻量级的旅游资讯 + 预订管理平台。业务上比单纯的“内容展示”多了一层用户行为闭环:浏览 -> 收藏 -> 下单 -> 评论。这四个动作刚好覆盖了Web系统里最高频的场景:列表查询、详情查看、数据写入、关联表操作。对新手来说,把这四件事做通,CRUD基本就毕业了;再往后加什么功能都是换汤不换药。
1.2 为什么选Spring Boot + JSP这套组合
这是很多人会纠结的点:前端用Vue不香吗?或者直接用Thymeleaf不好吗?我的回答是:看场景。
如果你是做毕设或者课程设计,学校那边通常对“前后端分离”的接受度反而没那么高,老师更看重你能不能讲清楚数据在服务端是怎么被处理的。JSP + JSTL + EL表达式这套东西,能够让你直接在页面上看到后端数据的流转过程,对理解MVC很有帮助。而Spring Boot又是当前Java后端事实上的标准,学完一定不亏。
从技术选型的合理性来讲,Spring Boot 2.x + MyBatis + MySQL + Tomcat内嵌是特别稳的组合。MyBatis比JPA更容易控制SQL,尤其是做路线关键词搜索、按价格区间过滤这种动态SQL时,写起来非常直白。JSP虽然听起来“老”,但和Spring Boot整合好之后,开发效率其实很高——改个页面刷新就能看到效果,不像前后端分离那样还要处理跨域和接口联调。
还有一个实际原因:这个组合的资料多、坑也有现成的答案。遇到问题搜一搜,基本都能解决。对初学者来说,这比技术的新旧更重要。
1.3 项目结构怎么规划才清晰
我见过很多同学的代码,一上来就在src/main/java下面啪啪建了七八个包,controller、service、dao、pojo,看着规整,实际上包和包之间的依赖关系是乱的。我这次用的是按“功能模块”区分层次的方式,骨架结构大概这样:
controller:接收请求、参数校验、返回页面或JSONservice:写业务逻辑,事务边界也都开在这一层dao:MyBatis的Mapper接口entity:和数据库表对应的实体类interceptor:登录权限拦截器vo:一部分页面需要的聚合对象,比如路线详情页要把路线、收藏状态、评论列表拼在一起
没有把vo和entity混在一个包,是个很小的习惯,但后期会省很多事。你要知道,JSP页面里直接操作entity对象,有时候会把不想暴露的字段(比如密码哈希值、内部状态码)渲染到前端,用VO做隔离会安全很多。
前端结构方面,因为JSP页面是直接放在webapp/WEB-INF/views下面的,我建议所有页面都通过Controller转发,不允许用户直接通过URL访问JSP物理路径。WEB-INF目录天然就有限制访问的作用,这一点要养成习惯,别图省事把页面丢到webapp根目录static下面。
2. 数据库设计与建模核心要点
2.1 五张核心业务表这么设计就够了
这套系统的核心数据不外乎五块:用户、旅游路线分类、旅游路线、收藏记录、订单。评论功能我在早期版本里是单独一张表的,后来考虑到订单和评论的关联关系已经足够表达“用户去过这条路线”这个语义,就合并处理了。实际建表时通常还是评论单列一张表更清晰,方便后续扩展点赞、回复等功能。
先说说最核心的旅游路线表,它的字段直接决定了页面能展示什么信息。我当时的表结构关键字段是:
id:主键自增route_name:路线名称route_type:关联分类表,比如“周边短途”、“国内长线”、“出境游”route_price:价格,用decimal(10,2)而不是float/double,避免金额精度问题route_days:行程天数,用tinyint就够route_pic:封面图路径,存的相对路径,图片文件本身在本地磁盘或对象存储里route_detail:路线详情,text类型,存富文本内容,前端JSP页面原样输出route_hot:热度值,int类型,每次用户查看详情时 +1,用来做热门路线排行status:上下架状态,0下架 1上架
有同学问我为什么不用一个字段把route_type直接存字符串。我想说:如果你永远不做分类统计、不按类型筛选,那确实可以直接存字符串。但一旦你有了“后台要维护分类”、“首页要按分类展示路线”这种需求,独立一张分类表会让后端的Mapper写起来干净得多,也方便以后扩展分类属性。
订单表的字段需要特别说一下。除了常规的order_code(订单编号)、user_id、route_id、order_price(下单时的价格快照)之外,一定要有一个order_status字段。这个字段别用字符串,建议用int存储订单状态码,比如 0待支付、1已支付、2已完成、3已取消。为什么不用字符串?因为代码里判断状态的时候,如果用字符串就要记住各种拼写,很容易写错,int比字符串在运行效率上也好一些。页面展示的时候用枚举或者Map做状态码和中文名称的映射即可。
2.2 表关系梳理:一对多和多对多的处理
用户和订单是一对多;路线和订单是一对多;用户和收藏是多对多。多对多关系我用了中间表favorite_route来处理,而不是在用户表或者路线表里加一个“收藏列表”字段。很多新手一开始容易犯的错就是把一对多关系通过拼接字符串存到一个字段里,前期觉得简单,后期统计查询的时候痛不欲生。
订单和路线其实是“多对一”,因为多条订单可以指向同一条路线。但我还会在订单表里冗余一个route_name字段,存下单时候的路线名称快照。这么做是为了防止后台把路线删了或者改了名字,用户的历史订单仍然能查到当时买的是什么东西。这就是典型的“快照冗余”,用空间换业务正确性,在实际业务系统里非常常见。
2.3 建表SQL的几个容易忽略的细节
created_at和updated_at这两个字段,强烈建议每张表都带上。不是每个项目都要做数据审计,但这俩字段在排查问题时能救命。比如用户跟你说“我的订单不见了”,你查一下created_at就能知道他什么时候下的单;后台管理员改错了价格,查updated_at就知道什么时候被改的。
另一个细节是user表的密码字段。有些教程直接存明文,我建议至少用MD5加盐,或者直接用BCrypt。计算机课设里不需要做到加密算法级别,但起码别明文存。密码字段的长度建议设置到varchar(128),为可能的加密哈希预留空间。顺便说一句,MyBatis用User做实体名的时候注意避开MySQL关键字,user在某些MySQL版本里确实是保留字,建表时候可以用t_user或者加反引号。
3. 核心功能模块的实现细节
3.1 注册登录:从明文到加盐的正确姿势
登录注册模块本身不复杂,但它是整个系统里最容易被人摸到安全底裤的地方。我的实现思路分三步:
第一步,注册接口接收用户名、邮箱、密码三个字段。校验用户名不能重复,这个校验一定要在数据库层面也做一层——给t_user.username字段加唯一索引,不能只靠业务代码判断。因为业务判断不是原子操作,并发请求同时注册同一个用户名时,两个请求都可能通过检查,然后都插入成功,数据库唯一索引能兜住这个并发问题。
第二步,密码加密存储。用Java的标准库MessageDigest做MD5加盐。具体做法是生成一个随机盐值,然后把密码和盐拼在一起做MD5,数据库里同时存密码哈希值和盐值。校验的时候,用输入密码+盐值再做一次MD5,和库里的哈希值比对。这么说起来有点绕,但你记住一句话就行:永远不要直接对明文密码做哈希,也不要全系统用同一个固定盐。
第三步,登录后把用户信息放进Session,同时把用户id塞到Session里。这一步之所以用Session而不是自己造一个Token,是因为JSP天然就是服务端渲染,Session控制起来最简单。登录之后在页面通过${sessionScope.user.username}就能直接显示用户名,非常顺手。
3.2 旅游路线浏览与搜索:动态SQL的用武之地
首页和路线列表页是游客第一眼看到的东西,当时我在搜索功能上是花了点心思的。页面上有三个搜索条件:关键词(模糊匹配路线名称)、路线分类、价格区间(最低价/最高价)。这三个条件用户可以任意组合,也就是说SQL语句必须能动态拼接WHERE子句。
MyBatis的<if>标签在这里就好用起来了。核心思路是先写一个基础查询语句,然后根据传入的条件逐步拼接:
<select id="searchRoutes" resultType="com.example.entity.Route"> SELECT * FROM t_route <where> <if test="keyword != null and keyword != ''"> AND route_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="routeType != null and routeType != 0"> AND route_type = #{routeType} </if> <if test="minPrice != null"> AND route_price >= #{minPrice} </if> <if test="maxPrice != null"> AND route_price <= #{maxPrice} </if> </where> ORDER BY route_hot DESC LIMIT #{offset}, #{size} </select>这里有两个细节很多人会栽跟头。第一个是<where>标签会自动去掉多余的AND,所以每个<if>里都可以放心以AND开头。第二个是大于号和小于号在XML文件里必须转义,用>和<,不然XML解析直接就报错了。至于分页,我这版用的是简单的LIMIT手工分页,因为数据量不大,没有必要一定上PageHelper插件。但如果你是想在项目里表现一下,集成PageHelper也就十几分钟的事,它可以让你少写不少重复的LIMIT代码。
3.3 路线详情与收藏:一个典型的关联查询
路线详情页是信息聚合的地方,一进去要展示路线的基本信息、封面图、富文本详情,还要判断“当前登录的用户有没有收藏过这条路线”,顺便把最近的几条评论列出来。这意味着Controller返回的Model里要放好几个对象。
我当时定义了一个RouteDetailVO,包含路线实体、是否收藏、收藏数量、评论列表。Service层做的事情是:查路线基本信息 -> 查收藏数 -> 如果当前用户已登录则查收藏标记 -> 查已审核评论列表。这四步操作都在一个事务里,保证数据一致性。
收藏按钮用的是表单POST提交还是Ajax?我建议收藏这个动作一定要用Ajax。因为用户收藏完不希望整个页面刷新,一旦刷新他还要重新找刚才看的位置。Ajax的接口返回一个JSON,包含收藏成功与否的标识和新收藏数,前端JS改一下按钮状态就行。Spring Boot里用@ResponseBody注解返回Map,直接就把Map序列化成JSON了。
还有一个细节:同一个用户对同一条路线反复点击收藏,后端不能傻傻地每次insert一条新记录。所以t_favorite表要给user_id和route_id加联合唯一索引,插入之前先查一下是否已经收藏过。如果已收藏,这次点击就变成取消收藏。这个业务逻辑在Service层做完判断,一个接口同时承担“收藏”和“取消收藏”两个动作。
3.4 订单提交流程:别把事务忘在角落里
用户点击“立即预订”,后端要做的事情是:校验用户是否已登录、校验路线是否还在上架状态、生成唯一订单号、插入订单记录。这四件事必须放在同一个事务方法里。如果哪天校验通过了但订单没插进去,或者订单插进去了但订单号重复了,整个系统就会处于一个很奇怪的状态。
订单号我用的生成规则是:时间戳 + 用户ID + 三位随机数。比如20250115103012001523,这样生成的订单号既能体现下单时间,又足够唯一。数据库层面再给order_code加唯一索引,双保险。
这里要提一个比较隐蔽的坑:订单金额不能直接拿前端传过来的价格存库,必须服务端根据route_id重新查询一遍价格。因为前端传过来的数据理论上都是不可信的,如果用户把价格参数从页面内存里改成1分钱,那系统就亏大了。这种问题不常见,但你要有这根弦。
3.5 后台管理:表单提交与文件上传
后台管理的核心是路线的增删改查。编辑路线时有个封面图上传,这个功能如果不用云存储,直接在本地磁盘存文件就行。Spring Boot里配置一个上传目录,然后用MultipartFile接收文件,写到磁盘,数据库存相对路径。代码如下:
@PostMapping("/admin/route/save") public String save(@RequestParam("picFile") MultipartFile picFile, Route route) { if (picFile != null && !picFile.isEmpty()) { String fileName = System.currentTimeMillis() + "_" + picFile.getOriginalFilename(); picFile.transferTo(new File(uploadDir, fileName)); route.setRoutePic("/upload/" + fileName); } routeService.saveOrUpdate(route); return "redirect:/admin/route/list"; }文件名的处理有个关键点:不要直接使用用户上传的原始文件名,因为可能包含中文、空格、特殊字符,导致URL访问时出问题,也可能有两个用户传了同名文件互相覆盖。加上时间戳前缀就能避免碰撞。上传目录建议绝对路径,不要用相对路径,否则放到不同环境启动,目录位置不一致会导致图片丢失。
4. 工程配置与部署:从能跑起来到跑得稳
4.1 application.yml的关键配置
Spring Boot的配置文件我建议用application.yml而不是application.properties,因为层次结构看起来更清楚。核心配置有这几块:
spring: datasource: url: jdbc:mysql://localhost:3306/travel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 10MB jmx: enabled: false server: port: 8080 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity数据源URL里一定要有characterEncoding=utf8和serverTimezone=Asia/Shanghai这两个参数。前一个保证从数据库读出来的中文不乱码,后一个避免MySQL 8.x版本和Java时区不一致导致的“The server time zone value”报错。这两个参数忘了配置,项目极大概率会出问题,而且报错信息还特别不直观,排查起来很浪费时间。
上传大小限制配置也很重要。默认的Spring Boot上传限制只有1MB,页面里传一张手机拍的照片就报MaxUploadSizeExceededException,所以一定要显式调大。
4.2 让Spring Boot识别JSP的额外操作
Spring Boot原生对JSP的支持“不那么友好”,从打包方式上就要注意:必须用war包而不是默认的jar包。因为JSP需要运行在Servlet容器中,内嵌的Tomcat对JSP的支持需要额外的依赖。
具体要改两处:
- pom.xml里把
<packaging>从jar改成war - 添加两个依赖:
tomcat-embed-jasper(用于编译解析JSP)和jstl(用于页面里的JSTL标签)
<dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>还有一个配置项:需要在application.yml里指定视图解析器的前后缀:
spring: mvc: view: prefix: /WEB-INF/views/ suffix: .jsp这样Controller里return "route/list"的时候,Spring Boot会自动拼接成/WEB-INF/views/route/list.jsp。这套配置记熟它,每次新建Spring Boot + JSP的工程都能用上。
4.3 拦截器实现登录校验的正确姿势
后台的路线管理、订单管理这些页面,肯定不能让没登录的人直接通过URL访问。我用Spring Boot的HandlerInterceptor做了一个登录拦截器。关键代码逻辑是这样的:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); if (session.getAttribute("admin") != null) { return true; } response.sendRedirect(request.getContextPath() + "/admin/login"); return false; } }注册拦截器的时候要注意放行哪些路径。登录页面本身要放行,静态资源比如CSS、JS、上传的图片要放行,否则拦截器会把它们也拦下来,用户连登录界面都加载不出样式。我当时的设置大概是:
registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/login", "/admin/doLogin", "/static/**", "/upload/**");这里有一个很容易被忽视的坑:如果你用相对路径重定向,项目部署在根路径下没问题;但如果加了server.servlet.context-path,比如部署在/travel下,重定向路径就必须带上request.getContextPath(),否则会跳到一个不存在的URL。这个细节我在部署到服务器时才踩到,前端同事调了一个小时都没找到原因。
5. 常见问题与排查经验速查
5.1 JSP页面404或者白屏
这是Spring Boot + JSP组合里出现频率最高的问题。原因通常是三种:
- 没有添加
tomcat-embed-jasper依赖,JSP文件不会被编译 - 页面放在
webapp根目录但视图解析器配置的前缀指向了WEB-INF/views,路径对不上 - 使用的是jar包而不是war包运行
排查思路:先看控制台启动日志有没有关于JSP的异常;确认一下target目录里war包解压后能不能看到WEB-INF/views下面的jsp文件。如果target目录里根本没有jsp文件,那就是maven打包的资源配置问题,需要在pom.xml里加一条maven-war-plugin配置,把src/main/webapp加进去。
5.2 MyBatis的Mapper文件报了Invalid bound statement错误
错误信息是Invalid bound statement (not found),通常意味着接口方法和XML语句没对上。原因基本是以下几种:
- XML文件的namespace不是Mapper接口的全限定名
- XML文件的
id和接口方法名不一致 application.yml里的mapper-locations配错了,没有扫到XML文件
我排查这类问题的惯用方法:先检查Mapper接口的包名、接口名和XML的namespace是不是完全一致;再检查mapper-locations配置的路径是不是和实际放XML的目录一致。这里有个经验之谈:XML文件千万别放在src/main/java目录下面,因为Maven默认只把resources目录下的文件打包进classes,放在java目录下多半不会被打包进去。标准做法是放在src/main/resources/mapper/下面。
5.3 页面中文乱码:一个字符编码引发的连锁反应
乱码是Java Web的老问题,Spring Boot里通常出现在三个环节:
- 数据库连接URL没加
characterEncoding=utf8,数据存进去或读出来是乱码 - JSP页面头部没设置
pageEncoding="UTF-8",页面显示乱码 - Controller接收POST请求参数时出现中文乱码,这是Spring Boot内置的
CharacterEncodingFilter没生效造成的
Spring Boot其实默认就带了编码过滤器,但只有当spring.http.encoding.force=true时才会强制覆盖每种请求和响应。我一般在配置里显式加上:
spring: http: encoding: charset: UTF-8 enabled: true force: true这样所有HTTP请求和响应都会强制使用UTF-8,乱码的概率会降到很低。
5.4 上传图片无法访问
图片上传成功,数据库也存了路径,但在页面里显示不出来。这种问题十之八九是静态资源配置的问题。Spring Boot默认只把classpath:/static/目录当作静态资源位置,但图片上传到了磁盘的某个绝对路径,两者对不上。
需要在配置里把上传目录也注册为静态资源路径,或者写一个映射配置:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir); } }registry.addResourceHandler("/upload/**")的意思是将URL中以/upload/开头的请求映射到本地的文件目录。这个配置加完之后,页面上<img src="/upload/xxx.jpg">就能正常加载了。
5.5 后台修改数据后,列表页不刷新
这个问题其实不是缓存,而是大家在JSP页面里用EL表达式取值时,没有意识到Session或Request作用域的问题。修改完路线信息后重定向到列表页(redirect:),这时重定向后的新请求里是拿不到之前请求里设置的request属性的。要展示最新的数据,必须在新请求里重新查询一次数据库。
我当时在这种问题上花过一个多小时,后来养成了个习惯:涉及“修改/删除后跳到列表页”一律用redirect:重定向,让列表页的Controller方法重新查库。这样既避免表单重复提交,也能保证数据实时。
6. 一些我实际踩过的坑和优化建议
整个项目做下来,我最大的感受是:这种课程设计级别的Web系统,代码量不大,但五脏俱全,特别适合用来梳理Web开发的核心链路。前端发一个请求,后端经过Controller -> Service -> Dao -> 数据库,数据再一层层传回JSP页面渲染,这条路走通了,后面学更复杂的框架都是在这个基础上套壳。
做这个项目时我还额外做了两个小优化,虽然不影响功能,但对体验提升很明显。
第一个是路线的热度值。每次有人查看详情,热度值就加1,但这不能算太当真,因为爬虫或者用户反复刷新都会让数据失真。但这个设计做出来后,首页的“热门路线推荐”就很自然了,排出来基本都是真实浏览量高的路线。
第二个是评论审核机制。用户发表评论后不会立刻在前台显示,而是先置为待审核状态,由管理员在后台审核后才会展示。这个设计看着不起眼,但很多评分表里会关注到细颗粒度的权限和状态管理,属于加分项。
至于后续扩展,你可以考虑加一个简单的“行程计划”功能,用户收藏多条路线后生成一份定制行程单,这会引入更多的复杂关联逻辑,但也能让你多练几个表的设计和查询。又或者按照路线热度做一套简单的推荐排序算法,都不复杂,但对项目的完整度很有帮助。
这个项目我前后开发了不到五天,真正复杂的地方不多,倒是很多零零碎碎的小配置容易让人反复踩坑。如果你正在做类似的项目,建议先把“Spring Boot整合JSP跑通一个最简单的helloworld”,再把用户模块做完,之后再一步步加功能。别一上来就铺开全部代码,不然出了问题你连是哪一层引起的都定位不了。
玩转这类Web系统,核心其实就是四个字:链路清晰。链路通了,其他都顺了。