简介:这是一套面向Java Web开发学习与毕业设计的山东红色旅游信息管理系统完整源码包,采用SSM(Spring+SpringMVC+MyBatis)整合框架、JSP/Vue前端,搭配MySQL 5.7+与Tomcat7+。系统涵盖景点介绍、旅游线路推荐、红色文化教育、在线预订、评价反馈及后台管理等模块,项目经过严格调试,可直接部署运行,适合用来理解企业级Web项目从架构设计到功能落地的全过程。压缩包共1319个文件,大小19.21MB,包含136个Java源码、125个JSP页面、364个JavaScript脚本、172个PNG图片及SQL数据库脚本、项目文档等,前端样式、交互逻辑与后端业务代码分离,目录结构清晰,便于按模块学习。资源附带数据库脚本与功能说明文档,能帮助快速还原项目环境。目前已有3513人学习下载,适合正在做SSM毕设或希望系统掌握Java Web全栈开发的读者参考借鉴。
1. 一套SSM+JSP的红色旅游信息管理系统,说到底解决的是资源散落的问题
前阵子帮一个县级纪念馆做信息化摸底,发现管理员的日常工作还是“Excel登记 + 微信群传图”,几十处红色旧址的信息分散在不同人的电脑里,游客想查哪个景点开放、哪条线路串联合理,根本无从下手。标题里这套“基于Web的山东红色旅游信息管理系统”,做的就是这类事:用SSM作为后端框架,MySQL存数据,JSP做服务端页面,把景点维护、线路推荐、新闻发布、留言互动这些典型业务收敛到一个Web系统里。它的核心价值不是技术多前沿,而是把散落的红色旅游资源变成一套可检索、可更新、可权限控制的在线台账。
这个课题的受众也很明确:要复现毕业设计的高校学生,以及预算不多、想给景区或纪念场馆做信息化的小团队。SSM加JSP在今天不算新潮,但胜在资料密集、原理透明、单机就能跑通。对“信息管理系统”这类以增删改查为主线的课题,这套组合反而是翻车成本最低的选项。下面从框架拆解讲到建表、写代码、踩坑,按一条能真正跑通的路径走一遍。
2. 选SSM这套组合的理由与配置骨架:先弄明白三层各自干什么
2.1 Spring、SpringMVC、MyBatis在系统里的角色分工
常见做法是把SSM拆成三层来看:Spring作为容器总管,负责Service层对象的创建、依赖注入和事务控制;SpringMVC负责Web层,把浏览器发来的请求路由到对应的Controller方法,再把Controller返回的视图名解析成JSP;MyBatis负责数据访问,把Java方法和SQL语句做映射,避免在代码里硬拼JDBC。三者各管一段,出了问题能很快定位到具体层。
JSP在这个架构里的位置是视图层。它和现在流行的前后端分离不一样,页面在服务器端渲染,JSP里可以直接用${}、<c:forEach>配合JavaBean输出数据。对信息管理系统来说这种模式够用,且调试时能直接看到渲染错误堆栈,不用额外起Node服务。代价是前端交互做不了太复杂,但红色旅游管理系统的核心交互是表单提交、列表展示、图片定位这类程度,JSP完全扛得住。
这套组合的成熟度是最大筹码。网上随便搜“SSM 景点管理系统”就能找到大量同类课题,遇到配置问题几乎都能搜到对应现象和修复方式。对工期紧张的学生来说,这个“生态红利”比技术先进性更值钱。
2.2 分层目录结构与请求流转链路
我一般会把一个SSM Web项目按这样的包结构组织:
src/main/java ├── com/example/entity // 实体类,对应数据库表 ├── com/example/mapper // MyBatis接口,方法名和SQL绑定 ├── com/example/service // 业务层接口和实现 ├── com/example/controller // SpringMVC控制器,接收请求 └── com/example/util // 工具类,比如坐标解析、分页 src/main/webapp ├── WEB-INF │ ├── views // JSP页面,按模块放子目录 │ └── web.xml // 部署描述符,配置DispatcherServlet ├── static // 静态资源:CSS、JS、上传图片 └── index.jsp一个“景点列表”请求的完整路径是:浏览器输入/scenic/list,web.xml里配置的DispatcherServlet把请求转给SpringMVC,由ScenicController.list()方法接收;Controller调用ScenicService,Service调用ScenicMapper,MyBatis执行对应的select语句并返回List<Scenic>;Controller把这个列表塞进Model,返回视图名scenic/list,DispatcherServlet再用视图解析器拼出/WEB-INF/views/scenic/list.jsp路径,渲染成HTML回给浏览器。
弄清这条链路比会敲代码更重要。后面遇到的“页面404”“数据不显示”“表单提交不了”等问题,本质上都是在排查这条链路里某一个环节断了。目录结构里需要注意:JSP放在WEB-INF/views下,用户在浏览器地址栏没法直接访问,必须通过Controller转发,这个设计在权限控制上有先天优势——如果JSP直接放在webapp根目录,用户就能绕过登录拦截直接打开页面。
2.3 最小可用配置:pom依赖和Spring配置文件
依赖我用的是Spring 4.3.x、SpringMVC 4.3.x、MyBatis 3.4.x这一档位的组合,兼容性好,教程多。Maven的pom.xml核心部分长这样:
<properties> <spring.version>4.3.18.RELEASE</spring.version> </properties> <dependencies> <!-- Spring核心与MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis与Spring整合包,注意这个包的版本要与MyBatis匹配 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.4.6</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>1.3.2</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency> <!-- JSTL和Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> </dependencies>依赖版本的选择有一条血泪经验:Spring的版本请尽量选“ .RELEASE”结尾的正式版,不要选带M或RC的里程碑版;mybatis-spring的版本必须和mybatis主版本对应,比如MyBatis 3.4.x对mybatis-spring 1.3.x。混搭版本最典型的翻车现场是启动时抛NoSuchBeanDefinitionException,实际上是整合包的版本不匹配,排查起来很费时间。
SpringMVC的核心配置,spring-mvc.xml:
<!-- 开启注解驱动,处理JSON和请求参数绑定 --> <mvc:annotation-driven/> <!-- 静态资源放行,JS/CSS/图片不经过DispatcherServlet --> <mvc:resources mapping="/static/**" location="/static/"/> <mvc:resources mapping="/upload/**" location="/upload/"/> <!-- 扫描Controller包 --> <context:component-scan base-package="com.example.controller"/> <!-- 视图解析器:Controller返回"scenic/list"时拼接成/WEB-INF/views/scenic/list.jsp --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>这里最容易被忽略的是mvc:resources。如果不放行静态资源,用户打开页面会发现CSS完全失效、图片全部裂开,因为请求被DispatcherServlet拦截后找不到对应的Handler,直接返回404。放行路径的location要写成目录末尾带斜杠的形式,否则资源路径拼接会出错。
MyBatis配置里有一个参数值得单独说:实体类属性是驼峰命名(比如scenicName),而数据库字段是下划线命名(scenic_name),需要在mybatis-config.xml里开启驼峰映射:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> </configuration>不开启这个选项,查询结果里scenic_name字段就无法自动装进scenicName属性,表现通常是页面表格“这一列是空的”,SQL单独跑又能查到值。很多新手把这种问题误判成SQL写错,其实只差这一行配置。
3. 数据表设计:红色旅游系统的核心业务对象与关联关系
3.1 从业务对象反推表结构
红色旅游信息管理系统的核心业务对象,常见做法是拆成五个表:景点表、线路表、线路与景点关联表、新闻表、用户表,另外加一个留言表承载游客互动。景点表是主表,保存名称、简介、图片、所在城市、经纬度坐标、开放时间等信息;线路表保存线路名称、主题分类、推荐天数;线路和景点是多对多关系,一个线路串起多个景点,一个景点也能出现在不同线路里,所以需要一张关联表来解耦。
建表SQL我习惯找一段最小可跑的版本:
CREATE TABLE scenic ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '景点ID', scenic_name VARCHAR(100) NOT NULL COMMENT '景点名称', city VARCHAR(50) COMMENT '所在城市', address VARCHAR(200) COMMENT '详细地址', intro TEXT COMMENT '简介', image_url VARCHAR(200) COMMENT '主图路径', pos_x INT COMMENT '图片定位坐标X', pos_y INT COMMENT '图片定位坐标Y', open_time VARCHAR(50) COMMENT '开放时间', ticket_price DECIMAL(10,2) DEFAULT 0 COMMENT '门票价格', status TINYINT DEFAULT 1 COMMENT '1显示 0隐藏', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;pos_x和pos_y这两个字段是给景点图片加“坐标定位”功能用的,后面会在实现章节详细展开。status字段属于软删除设计,删除景点时只改状态不删记录,避免前台页面突然出现图片裂图,也方便后台做恢复操作。所有表统一用InnoDB引擎和utf8mb4字符集,前者支持事务,后者能存生僻字和特殊符号,红色旅游景点名称里偶尔会有方言字,用utf8会报“字符集不匹配”的错。
3.2 表结构拆分的关键决策:逻辑外键与索引
线路表与关联表的设计,有一个值得展开的决策点:是否创建物理外键。常见做法是保留关联字段但不建FOREIGN KEY约束,原因有二:一是MyBatis开发中删除、更新数据时经常被外键约束卡住,业务逻辑里要先删子表再删主表,顺序写错就报错,处理成本高于收益;二是这套系统数据量不大,靠代码层保证关联完整性完全够用。用逻辑外键,也就是在关联表里存主表ID,查询时用JOIN关联,后续要做批量操作会灵活很多。
为了提高查询效率,关联表的两列都要建普通索引:
CREATE TABLE route_scenic ( id INT PRIMARY KEY AUTO_INCREMENT, route_id INT NOT NULL, scenic_id INT NOT NULL, sort_no INT DEFAULT 0 COMMENT '景点在线路中的顺序', INDEX idx_route (route_id), INDEX idx_scenic (scenic_id) );sort_no字段容易被忽略,但实际做线路展示时,游客希望看到“第一天去哪、第二天去哪”的顺序,如果没有这个字段,只能按ID排序,体验很差。在MyBatis的XML里查询线路详情时,用ORDER BY rs.sort_no ASC就能保证顺序。
3.3 预留免登录浏览与后台权限控制的拆分
游客访问前台不需要登录,但后台管理必须登录。常见做法是给用户表加一个role字段,0表示普通管理员,1表示超级管理员;超级管理员可以管理管理员账号,普通管理员只能维护景点和新闻。这比拆成“用户表+角色表+权限表”的方案轻量得多,对毕设和中小型系统完全够用。
用户表的密码字段有一个细节:长度预留到100以上,不要用默认的50。原因是你不可能明文存密码,会用MD5加盐或BCrypt加密后存储,加密后的字符串长度远超原始密码。我见过表建好后存不了密文导致登录接口一直报错的案例,根源就是VARCHAR(50)长度不足。另外表里补一个last_login_time字段,不参与业务但能在后台界面展示“最近登录时间”,一眼看出账号是否活跃,这类小细节在答辩时很加分。
4. 核心功能落地:从Mapper写起,到JSP页面完整跑通
4.1 用MyBatis写景点分页查询,SQL和参数一次说清
MyBatis里写分页查询,常见做法是不引入PageHelper插件,直接手写LIMIT参数,这样依赖更少、逻辑透明。Mapper接口和XML是成对出现的,接口只定义方法签名,SQL写在XML里:
// ScenicMapper.java public interface ScenicMapper { // 分页查询景点列表,offset是偏移量,pageSize是每页条数 List<Scenic> selectScenicPage(@Param("offset") int offset, @Param("pageSize") int pageSize); // 统计总数,用于前端计算总页数 int countScenic(); }<!-- ScenicMapper.xml --> <select id="selectScenicPage" resultType="com.example.entity.Scenic"> SELECT id, scenic_name, city, image_url, ticket_price FROM scenic WHERE status = 1 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>Mapper接口里要注意每个方法参数都用@Param注解声明名字,否则XML里#{offset}会解析失败。普通单参数可以不写,一旦有两个以上参数必须写,这是MyBatis的硬性规则。SQL里LIMIT #{offset}, #{pageSize}两个占位符显示的是JDBC的预编译参数,MyBatis会自动生成?占位符并设置值,避免SQL注入风险。
Controller层的调用方式:接收页面传来的pageNum(当前页码)和pageSize,计算出offset传给Mapper:
@RequestMapping("/scenic/list") public String list(@RequestParam(defaultValue = "1") int pageNum, Model model) { int pageSize = 8; // 计算偏移量:第一页从0开始,第二页从8开始 int offset = (pageNum - 1) * pageSize; List<Scenic> scenicList = scenicService.getScenicPage(offset, pageSize); int total = scenicService.countScenic(); int totalPages = (int) Math.ceil((double) total / pageSize); model.addAttribute("scenicList", scenicList); model.addAttribute("totalPages", totalPages); model.addAttribute("currentPage", pageNum); return "scenic/list"; }@RequestParam(defaultValue = "1")这个写法的价值在于,第一次不带页码访问时也能正常加载第一页;如果不写defaultValue,用户直接访问/scenic/list会抛MissingServletRequestParameterException。分页条在JSP里的渲染用JSTL的<c:forEach>遍历页码,上一页和下一页加参数判断,这是信息管理系统最常见的交互模式。
4.2 前台景点首页与线路详情页:JSP这样组织不踩坑
前台页面我习惯用一份统一的header.jsp和footer.jsp做公共部分,避免每个页面重复写导航栏。header.jsp里放导航链接和当前登录状态的判断:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <nav> <a href="${pageContext.request.contextPath}/scenic/list">景点列表</a> <a href="${pageContext.request.contextPath}/route/list">红色线路</a> <a href="${pageContext.request.contextPath}/news/list">新闻动态</a> <c:if test="${sessionScope.adminUser != null}"> <a href="${pageContext.request.contextPath}/admin/index">后台管理</a> </c:if> </nav>这里最容易被忽略的是${pageContext.request.contextPath}这个表达式。如果不加项目部署名,链接会变成/scenic/list,而项目实际部署路径可能是/redtourism/scenic/list,所有导航链接都会404。凡是JSP里的路径,都要用pageContext.request.contextPath拼接,这条规则适用于href、src、表单action三处。<c:if>标签用于判断session里是否存了登录用户,没有登录就不显示后台入口。
线路详情页要展示“线路下按顺序排列的景点”,这块在Service里完成关联查询。Controller先拿到线路基本信息,再通过Mapper查关联表得到景点ID列表,再批量查出景点内容。较高效的做法是写一个多表JOIN一次查全:
<select id="selectRouteDetail" resultMap="RouteWithScenicMap"> SELECT r.id, r.route_name, r.theme, s.id AS scenic_id, s.scenic_name, s.image_url FROM route r LEFT JOIN route_scenic rs ON r.id = rs.route_id LEFT JOIN scenic s ON rs.scenic_id = s.id WHERE r.id = #{routeId} ORDER BY rs.sort_no ASC </select>这种JOIN查询的结果集,需要用resultMap来映射“一线路多景点”的结构关系。如果不会写resultMap,退而求其次的办法是循环查两次,数据量小、排序可控,完全能接受,只是SQL条数多一点。对毕设项目,代码的可维护性和可解释性比极致性能重要,答辩时你也能更清楚地讲出每一步在做什么。
4.3 景点图片坐标定位:JSP里的交互实现方式
热词里关于“jsp图片如何对坐标定位”的检索量不小,放在这个系统里就对应一个很实用的功能:后台管理景点时,在景点主图上标记一个位置,前台详情页显示这个标记。常见做法是后台用一张可点击的图片接受坐标,前台用CSS position把标记点按比例定位显示。
后台管理的JSP页面里,先用原生JavaScript监听图片的点击事件:
<input type="hidden" id="posX" name="posX" value="${scenic.posX}"/> <input type="hidden" id="posY" name="posY" value="${scenic.posY}"/> <div> <img src="${pageContext.request.contextPath}/${scenic.imageUrl}" id="mainImg" style="width:600px; height:400px;"/> </div> <script> // 获取图片实际显示尺寸,换算点击坐标与图片真实坐标的比例 document.getElementById('mainImg').addEventListener('click', function(e) { var rect = this.getBoundingClientRect(); // e.clientX是浏览器窗口坐标,减去rect.left得到图片内坐标 var x = e.clientX - rect.left; var y = e.clientY - rect.top; // 按显示尺寸与真实尺寸的比例缩放 var realX = Math.round(x * (this.naturalWidth / rect.width)); var realY = Math.round(y * (this.naturalHeight / rect.height)); document.getElementById('posX').value = realX; document.getElementById('posY').value = realY; }); </script>这段代码的关键在比例换算。naturalWidth和naturalHeight是图片的真实像素尺寸,rect.width是图片在页面上的布局尺寸,两者往往不一致(因为CSS限制了显示宽度)。如果不做换算,直接把点击坐标存进数据库,前台展示时用不同尺寸的CSS渲染,标记点就会偏移。这是一个非常典型的坑:后台点了正确位置,前台标记偏移几十像素。
前台展示时,只需要按比例定位:
<div style="position: relative; display: inline-block;"> <img src="${pageContext.request.contextPath}/${scenic.imageUrl}" style="width:600px;" id="showImg"/> <div style="position: absolute; left: ${scenic.posX * 600 / scenic.imgWidth}px; top: ${scenic.posY * 400 / scenic.imgHeight}px;"> <span style="color:red;">●</span> </div> </div>前台页面把后台存的真实坐标按当前图片的显示尺寸重新换算定位。这里要求后台还要保存图片的真实宽高(imgWidth、imgHeight),所以建表时不要只存坐标,要把图片尺寸一起存上,并在Controller层上传图片后用Java原生ImageIO读取尺寸写入数据库。这是一个先在后台点击、传坐标,再到前台展示定位的完整链路,红色旅游景点介绍里标记重点展品、标注旧址位置都很有用。
4.4 文件上传与静态资源映射的配合
景点图片和新闻配图都要走文件上传。SpringMVC的文件上传需要配置CommonsMultipartResolver:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <!-- 限制上传大小,单位是字节,这里设置10MB --> <property name="maxUploadSize" value="10485760"/> <!-- 指定上传时使用的字符编码 --> <property name="defaultEncoding" value="UTF-8"/> </bean>Controller层接收上传文件:
@RequestMapping("/admin/scenic/save") public String saveScenic(@RequestParam("file") MultipartFile file, Scenic scenic, HttpServletRequest request) { if (!file.isEmpty()) { // 用时间戳加随机数重新构造文件名,避免中文和重名问题 String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = System.currentTimeMillis() + "_" + (int)(Math.random()*1000) + ext; // 上传保存的物理路径:在项目根目录的upload文件夹下 String savePath = request.getServletContext().getRealPath("/upload"); File dir = new File(savePath); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(savePath + "/" + newName)); // 数据库只存相对路径,页面访问时结合项目域名使用 scenic.setImageUrl("upload/" + newName); } scenicService.saveScenic(scenic); return "redirect:/scenic/list"; }文件名重造是必须做的。很多JSP项目直接用中文文件名,Tomcat在Windows和Linux上对中文路径的URL编码处理不一致,同一个文件有时候能访问有时候404,非常玄学。用System.currentTimeMillis()加随机数是最省事的保证唯一的方式。数据库只存“upload/xxx.jpg”这样的相对路径,这样将来把整个upload目录迁移到CDN或对象存储时,只需要改访问前缀,不需要动数据库。
上传路径getRealPath("/upload")有一个必须知道的边界:如果你用IDEA的Tomcat插件启动,上传文件会被写到IDEA的out目录里;如果用外部Tomcat启动,就会写到Tomcat的webapps目录下。两种启动方式下的路径不一致,容易让新手以为文件“丢了”,实际是被写到不同地方了。我一般会在代码里把保存路径打印到日志,这个习惯能省大量排查时间。
5. 避坑清单:SSM+JSP这条路上常翻车的5个地方
5.1 IDEA 2024创建Web项目时找不到WEB-INF目录
现象:新建的Maven项目里没有src/main/webapp/WEB-INF/web.xml,甚至没有webapp目录,不知道把JSP和配置文件放哪。
原因:新版的IntelliJ IDEA在创建普通Maven工程时不再自动生成Web目录结构和web.xml,需要手动添加Web支持,这个操作藏得比较深。
解决:打开“Project Structure”,选中模块后点“Add”里的“Web”,然后在“Deployment Descriptor”区域指定web.xml路径为src/main/webapp/WEB-INF/web.xml,最后在“Web Resource Directories”里把src/main/webapp设为资源目录。操作完成后,就能正常把JSP放到webapp/WEB-INF/views下,Tomcat配置里“Deployment”选项选war exploded模式来跑。
5.2 JSP页面中文乱码,改了一堆编码还是乱
现象:页面中文全部变成问号或者“汉嗔,重写字符集声明也没用。
原因:乱码有三个叠加来源,只改一处不够。JSP文件本身的编码、JSP页面声明的contentType编码、Tomcat连接器的URI编码,这三层都要统一成UTF-8。
解决:JSP文件头写<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>;IDEA右下角确认文件编码是UTF-8而不是系统默认的GBK;Tomcat的server.xml里给Connector加URIEncoding="UTF-8"。数据库连接串里也要加characterEncoding=utf8,否则乱码会一路传到数据库再传回页面。四层全部统一,才能真正解决乱码。
5.3 MyBatis查询某列数据为空,SQL单独跑有值
现象:页面表格里“景点名称”列有数据显示,“所在城市”列空白,但把SQL粘到Navicat里执行能看到数据。
原因:最常见的是mapUnderscoreToCamelCase没开,或者数据库字段命名和实体类属性名对不上。比如字段叫city_name,实体类属性叫cityName,没有驼峰映射时MyBatis不知道要映射到哪个属性,默认按字段名找同名属性,找不到就留空。
解决:在mybatis-config.xml里开启<setting name="mapUnderscoreToCamelCase" value="true"/>,或者用resultMap显式指定每个字段对应哪个属性。不要用写别名的方式解决,SELECT city_name AS cityName在查询简单时有效,但后期改表结构时别名容易漏改,维护成本高。
5.4 上传图片后访问404,CSS也时好时坏
现象:上传一张图片,在后台能看到保存成功的提示,但访问/upload/xxx.jpg一直404,页面CSS样式也一会儿有一会儿没有。
原因:SpringMVC的DispatcherServlet默认拦截所有请求,包括静态资源和上传文件。如果你的配置里没有放行/upload/**和/static/**,所有来到Tomcat的请求都会先找Controller里有没有对应的@RequestMapping,找不到就返回404。
解决:在spring-mvc.xml里加两行<mvc:resources>放行静态资源,这是最标准、最不容易出错的做法。注意mvc:resources放行的目录和getRealPath指向的目录要一致,很多项目把文件写到/upload,放行路径却写成了/uploads,这种错位排查很费眼。另外,上传目录要建在webapp根目录下,而不是WEB-INF下,否则Tomcat把它当成受保护资源,静态放行了也访问不了。
5.5 JSP页面提交表单后跳回原页面,数据是旧值
现象:编辑景点信息后点保存,浏览器返回列表页,发现刚才修改的内容没有生效。但数据库里查询已经有新值了。
原因:Controller里保存成功后用了forward转回页面,而不是redirect重定向。forward时模型数据还是内存里的旧数据,页面直接渲染出来,浏览器地址栏也没变,刷新后数据又变成数据库里的值,看起来像“没保存成功”。
解决:保存、删除这类修改数据的操作,成功后一律用return "redirect:/scenic/list"做重定向,让浏览器重新发起一次全新的GET请求,这样展示的数据是刚查的最新数据。forward只适合保存完成后马上要在当前页面展示成功提示的场景,但要同步更新Model里的数据才能避免旧值问题。这个习惯能避免一大类“改了没变”的假象。
6. 从跑通到能交付:一条验证链路和两个加分改造
6.1 用浏览器开发者工具验证一个完整请求
系统跑起来后,不要只在页面里点来点去,打开浏览器开发者工具的Network面板,逐一检查请求的状态码和耗时。一个正常通过的景点列表请求,通常会看到一条/scenic/list的文档请求,状态码是200,紧接着有若干条CSS、JS、图片资源请求。如果发现某个静态资源状态码是404,马上能对应到静态资源放行配置的问题;如果/scenic/list本身是500,点开标签页能看到完整的Java异常堆栈。
再验证表单提交链路:编辑景点并保存,在Network里找到POST请求,查看Payload里的参数名是否和Controller方法参数对得上,比如表单里提交了posX、posY、imageUrl,后端的Scenic实体有没有对应属性。用这个视角配合IDEA的Debug模式,在Controller和Service里各打一个断点,拖动一次请求就能把整个调用链走完。建议把这套操作作为一个固定动作,每次写完一个功能模块都跑一遍,比最后联调时集中排错高效得多。
6.2 安全加固:登录拦截和SQL注入的两条底线
把系统交付给老师或真实使用之前,至少要过两个安全底线。第一个是后台页面的登录拦截,写一个HandlerInterceptor,在preHandle里检查session里有没有adminUser,没有就重定向到登录页。SpringMVC的拦截器配置在spring-mvc.xml里,只拦截/admin/**路径,放行前台的所有请求。这个拦截器不需要很复杂,20行代码就能完成,但能挡住绝大多数“直接猜URL进后台”的尝试。
第二个是SQL注入和XSS的防线。MyBatis的#{}是预编译写法,只要不在XML里用${}拼接字符串,SQL注入基本堵得住;${}的用途只有表名、排序字段等没法预编译的场景,这个系统里尽量少用,就写ORDER BY固定的字段而不接受用户传参。XSS方面,在留言和新闻评论这类接受用户输入的地方,用JSTL的<c:out>替代直接输出${},它会自动转义HTML标签,防止用户在内容里注入脚本。
6.3 一个加分的进阶改造:在线PDF打印
做红色旅游信息系统时,有一个高频需求是“把某个红色景点的介绍页保存成PDF发给游客”。JSP项目不用引入前端打印框架,利用浏览器的原生打印功能就能实现这个效果:在详情页加一个“打印/存PDF”按钮,点击后调用window.print(),配合一段@media print的CSS把导航栏和按钮区域隐藏,只保留介绍内容区。代码量很小,但在真实场景里很实用,运营人员可以一键把景点介绍发给线下柜台。
@media print { /* 打印时隐藏导航、按钮、侧栏 */ .navbar, .footer, .print-btn { display: none !important; } /* 强制背景色和图片都输出 */ * { -webkit-print-color-adjust: exact; } }打印功能放在“红色线路”详情页时效果更好——用户可以把一条线路的行程按顺序打印出来,作为实地游览的手册。这个功能在答辩演示时容易被评委当成亮点,因为它是从真实使用场景倒推出来的功能,而不是教科书的CRUD。
做完上面这些,这套SSM+JSP的红色旅游信息管理系统就已经不是“能跑通的代码”了,而是能给人用的工具。我自己做这类课题时,最大的教训就是:技术选型新不新不重要,先把增删改查的闭环做透,再把用户在使用过程中会遇到的边角问题处理干净,就是一份对得起投入的交付。希望帮到你。
本文还有配套的精品资源,点击获取