简介:面向计算机专业毕业设计场景的SSM+MySQL房屋租赁系统论文文档,适合正在准备Java Web选题、需要参考系统设计与论文写法的学生。内容围绕房屋租赁平台展开,涵盖研究背景与意义、国内外研究现状、SSM框架与JSP技术介绍、系统需求分析、功能模块与数据库设计,以及房产新闻推广、房东与租户注册、在线预约和交流等实现思路,并附中英文摘要、关键词与目录结构,便于对照梳理论文框架和技术选型。压缩包内共1个docx文件,约1.48MB,以完整论文文档形式组织,适合直接查阅章节安排、论述逻辑与设计表达。已有92人学习,可作为毕业设计选题论证、开题报告和系统设计章节的参考材料,也能帮助读者理解SSM项目从需求分析到功能实现的写作路径。
1. 一套 SSM 房屋租赁系统源码,够不够撑起一次毕设
租房这个场景天生适合拿来练 CRUD:房东挂房源、租户搜房源、约看房、签租、退房、报修,一条链路串起七八张表,正好把 SSM 三层架构里的增删改查、分页、文件上传、Session 权限全过一遍。这套基于 SSM+MySQL 的房屋租赁系统源码,配一份建表脚本和一篇成体系的论文文档,覆盖管理员、房东、租户三种角色的前后台,在 Java Web 方向的毕业设计选题里属于比较扎实的一类——业务不虚,表结构能画得出来,用例图也编得圆。
适合谁:正在找 Java Web 毕设底稿、需要一份能跑通且讲得清分层的同学;也适合工作两三年、想回头补一遍传统 SSM 项目结构的人,毕竟现在新项目基本都 Spring Boot 了,纯 XML 配置的手感反而生疏。不适合谁:想直接拿去上线做真实租房生意的人。这套代码没有支付、没有实名、没有地图选点,定位就是教学演示,把它当产品用是给自己找麻烦。
后面的内容按「先把工程跑起来 → 权限怎么落 → 业务链路怎么串 → 坑在哪 → 怎么改成自己的」推。跑不通的那一步,我把它当成排错主线。
2. 环境与工程结构:把 SSM 老项目在本地跑起来
SSM 项目跑不起来的锅,一大半不在代码,在版本组合。Spring 4.x 那套 XML 配置是 JDK 8 时代的产物,直接扔到 JDK 17 上,十有八九是NoSuchMethodError或者 CGLIB 抛InaccessibleObjectException;MyBatis 的老版本配上 MySQL 8 的驱动,连接串少个时区参数就给你卡在启动阶段。先定版本,再谈导入,能省掉两个小时的无用排查。
2.1 组件版本:为什么这套代码对 JDK 8 最友好
这套工程的典型形态是 Spring + SpringMVC + MyBatis 三层用 XML 装配,视图层用 JSP,跑在 Tomcat 上。它依赖的是 Spring 的ClassPathXmlApplicationContext、DispatcherServlet、SqlSessionFactoryBean这些老牌组件,以及 CGLIB 动态代理。JDK 9 之后模块系统收紧,CGLIB 反射访问java.lang下的类需要额外开--add-opens,老代码不会给你写这些启动参数。
我一般按下表配,能少踩很多莫名其妙的坑:
| 组件 | 建议取值 | 主要影响 |
|---|---|---|
| JDK | 8 | Spring 4.x 的 CGLIB 代理在 9+ 上有模块访问限制 |
| Servlet 容器 | Tomcat 8.5 | JSP 编译与 EL 表达式行为和老代码一致 |
| 数据库 | MySQL 5.7 | 8.x 驱动类名、时区参数、默认字符集都变了 |
| 编译级别 | 1.8 | pom 里maven-compiler-plugin的 source/target |
| 项目编码 | UTF-8 | JSP 页面、properties、数据库三处必须统一 |
有同学会问,机器上只有 JDK 17 怎么办?装个 JDK 8 做多版本并存,IDEA 里 Project Structure 单独给这个模块指 SDK,比硬改代码便宜得多。改代码迁到 JDK 17 是另一件事,那要动的是 Spring 版本,不是一行两行的事。
2.2 从压缩包到可运行工程:导入、依赖与 web.xml
解压之后先看目录形态,这一步决定你走哪条导入路径。有pom.xml的是 Maven 工程,直接File → Open选 pom;只有WebRoot或WebContent加lib/*.jar的,是 Eclipse Dynamic Web Project 结构,IDEA 里要手动加 Web facet。
Maven 工程先在命令行验证依赖能不能下全:
# 先只做编译,不跑测试,能最快暴露依赖缺失和编译错误 mvn -DskipTests clean compile # 编译通过再打包,看 target 下有没有生成 war mvn -DskipTests packageclean清掉上一次的 target,避免残留的 class 干扰判断;-DskipTests是因为老工程的测试类往往引用了本地才有的数据源,跑测试会先失败在连库上;compile比package快,适合快速迭代排查。如果卡在依赖下载,看报错里的 groupId 去本地仓库.m2/repository对一下,多半是私服或版本不存在。
非 Maven 工程麻烦一点。IDEA 里进Project Structure → Modules,给模块加一个 Web facet,Web Resource Directory 指向WebContent,Deployment Descriptor 指向WebContent/WEB-INF/web.xml,再把lib目录下的 jar 全部 Add as Library。做完这步,Artifacts 里才会出现xxx:war exploded,Tomcat 配置里才有东西可选。
web.xml是整个应用的入口,两个地方最容易写错:
<!-- web.xml:DispatcherServlet 与编码过滤器是启动阶段的两个关键点 --> <servlet> <servlet-name>springmvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <!-- 指向 classpath 下的 spring-mvc.xml,路径写错不报错,只表现为所有请求 404 --> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <!-- 大于 0 表示容器启动时就初始化,配 0 则第一次请求才初始化,报错时机不一样 --> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvc</servlet-name> <!-- 拦根路径,交给 SpringMVC 决定静态资源怎么处理 --> <url-pattern>/</url-pattern> </servlet-mapping> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <!-- forceEncoding 为 true 时连 response 的 ContentType 一起改 --> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <!-- 用 /* 覆盖所有请求,写成 / 只匹配精确路径,等于没配 --> <url-pattern>/*</url-pattern> </filter-mapping>contextConfigLocation写错是典型静默失败:容器照样起,日志里没有明显异常,浏览器访问就是 404。url-pattern写/*还是/是另一个分水岭,前者连静态资源和 JSP 一起过滤,后者只走 DispatcherServlet。load-on-startup建议保持 1,让 Spring 容器在启动阶段就把 Bean 装配完,配置错误第一时间暴露,而不是等用户点第一下才炸。
2.3 建库建表与连接配置:一次连通的检查清单
数据库这块,先建库再导表,字符集统一用utf8mb4。房源的租金、面积这类字段,原工程里常见用varchar存数字,能跑但不严谨,排序和范围查询都会出问题,我自己会改成decimal。
-- 房源主表:只保留最关键的字段,其余按实际脚本补 CREATE TABLE `house` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(128) NOT NULL COMMENT '房源标题', `area` decimal(10,2) DEFAULT NULL COMMENT '建筑面积,平方米', `rent` decimal(10,2) DEFAULT NULL COMMENT '月租金,元', `address` varchar(255) DEFAULT NULL COMMENT '详细地址', `orientation` varchar(16) DEFAULT NULL COMMENT '朝向,如 南北通透', `cover_img` varchar(255) DEFAULT NULL COMMENT '封面图相对路径', `landlord_id` int(11) NOT NULL COMMENT '所属房东 id', `status` tinyint(4) DEFAULT '0' COMMENT '0 待租 1 已租 2 下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), -- 房东后台按人查自己的房源,这个索引必须有 KEY `idx_landlord` (`landlord_id`), -- 前台列表页按状态过滤,数据量上来后差别明显 KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源信息表';decimal(10,2)表示总共 10 位、小数 2 位,租金到万级别足够;status用tinyint存枚举,比字符串省空间、也比字符串好加索引;idx_landlord和idx_status是两张最常用的查询入口,前期数据少感觉不出差别,等表里几千行、前台又做了分页加筛选,没索引的查询会肉眼可见地慢。
连接配置一般单独抽一个jdbc.properties,别硬编码在 XML 里:
# 驱动类名:MySQL 8 用 com.mysql.cj.jdbc.Driver,5.x 用 com.mysql.jdbc.Driver jdbc.driver=com.mysql.cj.jdbc.Driver # useUnicode + characterEncoding 解决中文,serverTimezone 解决 8.x 的时区异常 jdbc.url=jdbc:mysql://127.0.0.1:3306/house_rent?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=改成你自己的serverTimezone不写,MySQL 8 会在建立连接时抛The server time zone value is unrecognized;useSSL=false是本地调试省掉证书告警;allowPublicKeyRetrieval=true在 8.x 的默认认证插件下是必需的,否则报Public Key Retrieval is not allowed。这四个参数是本地连通的最小集合,少一个都可能卡在启动阶段。
Spring 侧的装配大概是这样:
<!-- spring-mybatis.xml:数据源、SqlSessionFactory、Mapper 扫描三件套 --> <context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 初始化连接数别设太大,本地开发 5 足够,设 20 只会拖慢启动 --> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 别名包,配了以后 Mapper XML 里 resultType 可以只写类名 --> <property name="typeAliasesPackage" value="com.rent.entity"/> <!-- 通配符要能覆盖所有 Mapper XML,路径对不上就是 BindingException --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <!-- 扫描 Mapper 接口,生成代理对象 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.rent.mapper"/> </bean>mapperLocations和basePackage是新手最容易对不上的两个值:接口在com.rent.mapper,XML 在resources/mapper/,两边包名不一样是正常的,但 XML 里的namespace必须写接口全限定名,写错就抛Invalid bound statement (not found)。这个报错 90% 的成因就是 namespace 或方法 id 对不上,跟数据源没关系,别一头扎进连接池里查。
启动后先别急着点页面,用一条最简单的 SQL 验证链路:在 Mapper 里加个select count(*) from house,写个单元测试或者在 Controller 里打一行日志,能出数字说明数据源、SqlSessionFactory、Mapper 扫描全通了。这一步过了,后面的问题都是业务问题,不会再有环境问题混在里面。
3. 三角色权限落在代码里:拦截器、Session 与密码存储
角色一多,权限就容易散。这套系统里管理员管全站、房东管自己的房、租户管自己的预约和退房,如果每个 Controller 方法里都写一遍if (session.getAttribute("role") != 1) return "redirect:/login";,代码会迅速烂掉。合理的做法是把判断收敛到一个拦截器,路由前缀做第一层隔离,Session 做第二层校验。
3.1 角色表设计:三张表还是一张表加类型字段
原工程的思路是管理员、房东、租户各一张表,管理员表还带了个is_super标记超级管理员。分表的好处是字段各管各的:房东要存身份证、银行卡,租户要存工作单位、紧急联系人,硬塞一张表会有大量空字段。坏处是登录要写三套逻辑,Session 里的 key 也得区分。
我一般这么处理:登录入口按角色分开(/admin/login、/landlord/login、/user/login),登录成功后统一往 Session 里放三样东西——用户 id、角色标识、展示名。这样拦截器只认角色标识,不关心你从哪张表来的。
| 表 | 关键字段 | 说明 |
|---|---|---|
| admin | id、username、password、is_super | 后台运营用,数量极少 |
| landlord | id、username、password、phone、id_card | 房源归属方 |
| user | id、username、password、phone | 租户,字段最少 |
| house | id、landlord_id、status | 通过 landlord_id 关联归属 |
| appointment | id、house_id、user_id、status | 预约看房,双外键 |
| checkout | id、house_id、user_id、status | 退房申请,双外键 |
外键字段统一用xxx_id命名,别用拼音也不用缩写。这一点原工程做得不算好,字段名里混着拼音和英文,后面维护时容易看岔,改字段的成本在第 5 章单独说。
3.2 登录拦截器:HandlerInterceptor 与放行名单
SpringMVC 的拦截器比 Filter 更贴近业务,能拿到 HandlerMethod,也就知道用户访问的是哪个 Controller 方法。
public class LoginInterceptor implements HandlerInterceptor { // 三个角色各自的后台入口前缀,用于第一层路由隔离 private static final String[] ROLE_PREFIX = {"/admin/", "/landlord/", "/user/"}; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); String ctx = request.getContextPath(); // 去掉 contextPath,只留应用内路径,否则部署名一改判断就失效 String path = uri.substring(ctx.length()); // 静态资源和登录页直接放行,否则拦截器会把 css/js 也拦下来 if (path.startsWith("/static/") || path.startsWith("/upload/") || path.contains("/login")) { return true; } HttpSession session = request.getSession(); Object role = session.getAttribute("role"); if (role == null) { // 未登录统一回登录页,带上来源地址便于登录后跳回 response.sendRedirect(ctx + "/login?redirect=" + URLEncoder.encode(path, "UTF-8")); return false; } // 第二层校验:/admin/ 开头的只允许管理员,越权直接打回首页 for (String prefix : ROLE_PREFIX) { if (path.startsWith(prefix) && !prefix.substring(1).startsWith(role.toString())) { response.sendRedirect(ctx + "/403"); return false; } } return true; } }preHandle返回 false 表示中断请求,不再进入 Controller;返回 true 才继续往下走。这里做了两层:先看 Session 有没有角色,再看路径前缀和角色是否匹配。静态资源、上传目录、登录相关路径必须放行,否则会出现「登录页自己被拦截」这种死循环。getContextPath()那一步很容易漏,本地部署名是/rent,上线改成了/,硬判断/admin/就会全线失效——这类问题在本地永远复现不出来。
注册到spring-mvc.xml:
<mvc:interceptors> <mvc:interceptor> <!-- 拦截所有请求,具体放行规则在 preHandle 里做 --> <mvc:mapping path="/**"/> <bean class="com.rent.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>path="/**"是拦截全部,/ *只拦一层,两者区别在处理多级路径时很明显。
3.3 密码存储:明文改加盐哈希,顺带把老数据兼容掉
这类毕设代码里,密码字段十有八九是明文或者裸 MD5。裸 MD5 的问题是同一密码哈希值一样,撞库成本极低。改造成本其实很低,半小时的事。
public final class PasswordUtil { // 单次 SHA-256 + 随机盐,够用且不引入额外依赖 public static String encode(String raw, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); // 盐拼在密码前面,避免相同密码产生相同摘要 md.update((salt + raw).getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(); StringBuilder sb = new StringBuilder(); for (byte b : digest) { // & 0xff 把 byte 转成 0-255,否则负数会拼出错误字符 sb.append(String.format("%02x", b & 0xff)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 不可用", e); } } // 每次注册生成独立盐值,长度 16 位足够 public static String newSalt() { byte[] buf = new byte[16]; new SecureRandom().nextBytes(buf); StringBuilder sb = new StringBuilder(); for (byte b : buf) { sb.append(String.format("%02x", b & 0xff)); } return sb.toString(); } }& 0xff是这段代码里唯一有点技术含量的地方:Java 的 byte 是有符号的,直接格式化负数会输出ffffffxx之类的东西,摘要长度就不对了。SecureRandom比Random慢,但盐值本来就不该可预测。
老数据兼容有个小技巧:登录时先按新算法校验,失败再按老算法(明文或裸 MD5)校验一次,如果老算法通过,就顺手把库里的密码更新成新格式。用户无感知,数据逐步迁移完。这个思路在真实系统里做密码升级时很常见,答辩时讲出来也是个加分项。
4. 房源到退房的业务链路:上传、状态流转与事务边界
三种角色的后台里,真正有业务含义的只有三条链路:房东发房源、租户约看房、租户退房。其余都是围绕这三条的增删改查。把这三条走通,整个系统就通了。
4.1 房源发布:MultipartFile 上传与图片路径落库
图片上传是这类项目里最容易出问题的一环,因为它同时涉及 Spring 配置、文件系统、数据库三个层面。
@Controller @RequestMapping("/landlord/house") public class HouseController { @Autowired private HouseService houseService; @PostMapping("/save") public String save(House house, MultipartFile cover, HttpServletRequest request) throws IOException { if (cover != null && !cover.isEmpty()) { // 按天分目录,单目录文件过多时文件系统检索会明显变慢 String day = new SimpleDateFormat("yyyyMMdd").format(new Date()); String relativeDir = "/upload/" + day; // war exploded 模式下能拿到真实磁盘路径,war 包模式拿到的是临时解压目录 String realDir = request.getServletContext().getRealPath(relativeDir); File dir = new File(realDir); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("上传目录创建失败:" + realDir); } // 原始文件名不可信,用 UUID 重命名,只保留后缀 String ext = cover.getOriginalFilename() .substring(cover.getOriginalFilename().lastIndexOf('.')); String fileName = UUID.randomUUID().toString().replace("-", "") + ext; cover.transferTo(new File(dir, fileName)); // 库里只存相对路径,换服务器时不用改数据 house.setCoverImg(relativeDir + "/" + fileName); } house.setStatus(0); houseService.save(house); return "redirect:/landlord/house/list"; } }getOriginalFilename()返回的是客户端提交的名字,可能带路径、可能带中文、可能重名,直接拿来做文件名迟早出事,UUID 重命名是标准做法。transferTo内部处理了流的关闭,比手写FileOutputStream干净。库里存相对路径而不是绝对路径,是因为绝对路径一换部署环境就全废了,这个习惯值得从毕设阶段就养成。
对应的解析器配置不能少:
<!-- spring-mvc.xml:不配这个,MultipartFile 参数永远是 null --> <bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <!-- 单位字节,10MB 对房源图片足够,设太大容易被大文件拖垮 --> <property name="maxUploadSize" value="10485760"/> <property name="defaultEncoding" value="UTF-8"/> </bean>maxUploadSize超过限制时抛的是MaxUploadSizeExceededException,页面表现是 500,不是提示「文件太大」。想友好一点,加个@ExceptionHandler捕获它,跳回上传页给提示。这个细节答辩老师很爱问。
4.2 预约看房:状态机比你想的重要
预约看房看着简单——租户填个时间提交,房东确认。不做状态管理的话,很快会出现「已取消的预约还能被房东确认」「同一房源重复提交十条预约」这类脏数据。
我给预约表定四个状态:0 待确认、1 已确认、2 已完成、3 已取消。每次状态变更都带上前置状态做条件更新,而不是先查再改:
<!-- AppointmentMapper.xml:条件更新,避免并发下的状态覆盖 --> <update id="updateStatus"> UPDATE appointment SET status = #{toStatus}, update_time = NOW() WHERE id = #{id} <!-- 只有当前状态等于期望状态才更新,返回 0 行说明状态已被别人改过 --> AND status = #{fromStatus} </update>这条 SQL 返回的影响行数是关键:返回 1 说明抢到了,返回 0 说明在你读和写之间有人改过了。Service 层拿到 0 就抛一个业务异常,前端提示「该预约状态已变更,请刷新」。这种写法叫乐观锁,不用加锁字段,靠WHERE条件本身做并发控制,在预约、抢单这类场景里非常实用。
前台列表页的分页和筛选,用 MyBatis 的动态 SQL 拼:
<select id="selectByCondition" resultType="Appointment"> SELECT * FROM appointment <where> <if test="landlordId != null">AND landlord_id = #{landlordId}</if> <if test="userId != null">AND user_id = #{userId}</if> <if test="status != null">AND status = #{status}</if> </where> ORDER BY create_time DESC <!-- offset 由 Service 按 (pageNum-1)*pageSize 算好传进来 --> LIMIT #{offset}, #{pageSize} </select><where>标签的作用是自动去掉开头多余的AND,全条件为空时不会拼出WHERE AND。LIMIT的两个参数顺序是「偏移量, 条数」,写反了会返回空结果但又不报错,属于典型静默失败。
4.3 退房与租房:@Transactional 该加在哪一层
退房这个动作涉及两张表:退房申请状态改成「已通过」,房源状态改回「待租」。两条 UPDATE 必须同生共死,中间挂了就是数据不一致。
@Service public class CheckoutServiceImpl implements CheckoutService { @Autowired private CheckoutMapper checkoutMapper; @Autowired private HouseMapper houseMapper; // rollbackFor 显式指定 Exception,默认只回滚 RuntimeException @Override @Transactional(rollbackFor = Exception.class) public void approve(Integer checkoutId, Integer houseId) { int rows = checkoutMapper.updateStatus(checkoutId, 0, 1); if (rows == 0) { // 抛异常触发回滚,不要 return false 让上层判断 throw new BizException("退房申请状态已变更"); } houseMapper.updateStatus(houseId, 1, 0); } }三个要点。第一,@Transactional加在 Service 的 public 方法上,加在 Controller 上没意义,加在 private 方法上直接失效(Spring 用代理实现,私有方法代理不到)。第二,rollbackFor = Exception.class必须写,默认只对运行时异常回滚,业务里抛的受检异常不会触发回滚,这是个经典翻车点。第三,别在 Controller 里try-catch把异常吞掉,异常不到达代理层,事务根本不会回滚,表面上看代码「执行完了」,数据却只改了一半。
5. 避坑与排查:五个从 404 到乱码的翻车现场
下面这五条,是我在本地跑这类 SSM 老项目时反复遇到的。每一条都按「现象 → 原因 → 解决」写,对着排查能省时间。
5.1 启动即 404:contextPath 与 welcome-file 的误判
现象:Tomcat 起来了,控制台没有明显异常,浏览器访问http://localhost:8080/返回 404 白页。
原因:两种可能。一是访问地址漏了应用名,war exploded 部署后实际路径是http://localhost:8080/rent/,直接访问根路径访问的是 Tomcat 默认应用;二是web.xml里没有配welcome-file-list,访问目录时容器不知道该渲染哪个页面。
解决:先在 IDEA 的 Tomcat 配置里确认 Application context,再检查web.xml有没有welcome-file-list,值要指向真实存在的文件(比如index.jsp),写一个不存在的名字同样是 404。另外 SpringMVC 的viewResolver前缀后缀配错也会导致「Controller 进去了、页面出不来」,这种情况日志里能看到方法执行记录,区分得开。
5.2 图片能上传却显示 404:getRealPath 与部署方式
现象:上传接口返回成功,数据库里路径也写进去了,但页面<img>标签裂图,直接访问图片 URL 是 404。
原因:getRealPath()拿到的是「当前部署方式下的磁盘路径」。war exploded 模式指向项目目录,war 包模式指向 Tomcat 的临时解压目录,重启一次换一个地方。存进数据库的路径是相对路径,但文件实际落在临时目录里,重启后目录被清空,图就没了。
解决:开发阶段用 war exploded,别用 war 包模式调试上传;上线要么配 Tomcat 的<Context docBase>做虚拟路径映射,要么干脆换成本地磁盘固定目录 + Nginx 静态托管。这套改法在答辩时讲清楚,比单纯说「实现了图片上传」有说服力得多。
5.3 中文乱码:filter 之外的三处漏网
现象:表单提交的中文进库变成问号,或者页面显示成乱码。
原因:CharacterEncodingFilter只处理请求体,管不到三处——数据库表的字符集、JDBC 连接串、JSP 页面的pageEncoding。任何一处不是 UTF-8,就会在某个环节断掉。
解决:按顺序查一遍。建库语句要带DEFAULT CHARSET=utf8mb4;连接串要带useUnicode=true&characterEncoding=utf8;JSP 头部要有<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>。三处统一之后还乱码,就看 Tomcat 的server.xml里 Connector 有没有配URIEncoding="UTF-8",GET 请求的中文走的是这一层。血泪经验是:乱码问题不要猜,从浏览器 → Tomcat → Spring → JDBC → 表,一层一层打印实际字节,比反复改配置快。
5.4 拼音字段名撞上 Linux 表名大小写
现象:Windows 上跑得好好的,换到 Linux 服务器上启动就报Table 'xxx.House' doesn't exist。
原因:Linux 下 MySQL 默认lower_case_table_names=0,表名大小写敏感;Windows 下默认是 1,不敏感。代码里 SQL 写的是House,建表脚本建的是house,Windows 上蒙混过关,Linux 上直接炸。
解决:统一小写下划线命名,建表脚本和 Mapper XML 全部对齐。这套工程里还有拼音字段(比如房屋名称、租房价格这类),跑是能跑,但维护起来很别扭,改字段要同步改实体类、Mapper XML、JSP 的name属性三处,漏一处就是空值。要改建议一次性改完,别改一半。
5.5 分页插件版本与 MyBatis 不匹配的黑匣子
现象:列表页数据永远只有第一页,或者分页参数传了但 SQL 里没有LIMIT。
原因:工程里如果用了分页插件(PageHelper 这类),插件版本和 MyBatis 版本必须匹配。版本不匹配时插件不会报错,只是拦截器没生效,SQL 原样执行,于是「分页失效但功能正常」,特别难查。
解决:先在日志里打开 MyBatis 的 SQL 输出(log4j.logger.com.rent.mapper=DEBUG),看实际执行的 SQL 有没有LIMIT。没有就查插件配置,SqlSessionFactoryBean的plugins属性里有没有注册;有但参数不对,查pageNum、pageSize是不是从请求里取到 0 或 null。分页这种「看起来很简单」的功能,恰恰是黑匣子最多的地方。
6. 二次开发与答辩自检:让论文里的图和代码对得上号
把工程跑通只是第一步,真要拿去交,得让论文里的 E-R 图、用例图、模块图和代码里的类、表、路由一一对应。我见过太多情况是:论文写「实现了在线签约」,代码里连合同表都没有;论文的 E-R 图画了七个实体,数据库脚本里只有四张表。答辩老师不用看代码,随手翻两页对一下就能问出来。
我的习惯是先做一张对照表,把论文的每个功能点落到具体实现上:
| 论文里的功能点 | 代码落点 | 验收方式 |
|---|---|---|
| 房源发布 | HouseController.save+house表 | 登录房东账号发一条,查库有记录、前台能看到 |
| 图片上传 | MultipartFile+/upload/目录 | 上传后直接访问图片 URL 能打开 |
| 预约看房 | AppointmentController+appointment表 | 状态从 0 变 1,再重复确认应失败 |
| 退房申请 | CheckoutServiceImpl.approve | 造一个异常验证两条 UPDATE 一起回滚 |
| 权限隔离 | LoginInterceptor | 用租户账号直接访问/admin/应跳 403 |
这张表做完,你心里就有数了:哪些功能是真做了,哪些只是论文里写了。没做的要么补,要么从论文里删掉,别留着让人问。
再分享一个验证事务是否真的生效的小技巧,不用写单元测试也能验:在approve方法的第二条 UPDATE 前面手动throw new RuntimeException("test"),跑一次,然后查库——如果退房申请状态也回滚了,说明事务配置正确;如果退房申请变了、房源没变,说明事务没生效,多半是注解加错层或者被try-catch吞了。验证完记得把这一行删掉,我干过一次忘了删然后提交的事,后悔药没得吃。
还有一个容易被忽略的点:JSP 页面里的表单字段名要和实体类属性严格一致。SpringMVC 的参数绑定靠名字匹配,<input name="fangwumingcheng">对不上private String title,绑不上就是 null,而且不报错,页面上看就是「提交成功但数据没存进去」。这种问题查起来最耗时间,因为每一步看起来都正常。
从那以后我每次接手这类老 SSM 工程,都强制走一遍固定流程:先确认 JDK 和 Tomcat 版本,再单独验证数据库连通,然后跑一遍登录、发房源、预约、退房这四条主链路,最后才对论文目录逐条核功能。流程走完再动代码,比上来就改要快得多。希望帮到你。
本文还有配套的精品资源,点击获取