☰
SSM商铺租赁管理系统:从需求分析到部署实战
2026/10/12 3:54:05 网站建设 项目流程

SSM这个词,在高校和培训机构的项目清单里出现频率极高,但凡做过Java Web开发的,基本都绕不开Spring + SpringMVC + MyBatis这套组合。前两天有个读者问起商铺租赁管理系统的设计思路,加上我手头正好整理过类似的项目资料,干脆把整个从需求拆解到最终部署的完整过程写出来,希望对正在做同类毕设或刚接触SSM开发的同学有点参考作用。

这个系统表面上是一个典型的“管理系统”模板,但实际做下来会发现,它比普通的CRUD多了不少逻辑细节:商铺状态变更的约束、合同租期和租金账单的时间联动、不同角色的权限边界、还有逾期提醒这类偏业务的功能点。这些问题处理好了,系统才算真正“能用”,而不只是一个增删改查的展示品。

1. 项目定位与需求设计

1.1 商铺租赁管理的核心痛点

商铺租赁管理,本质上是一个资产状态跟踪和合同履约管理的问题。

我见过很多中小型商业管理公司还在用Excel表格管理商铺信息,问题非常多:商铺是空置还是已租,靠人工记忆;合同到期时间写在备注里,没人提醒;租金收没收、收了多少,月底对账全靠翻聊天记录。效率低不说,最容易出问题的是信息不一致:业务员登记了一个租户,运营那边却不知道合同已经签了,导致同一间铺子被租出去两次。

所以这个系统的第一设计原则就是:以商铺为中心,把跟商铺相关的所有业务动作串成一条完整的链条。从商铺建档、挂牌出租,到租户洽谈、签订合同,再到租金账单生成、收款记录登记,最后到合同到期提醒、退租释放商铺,全部在系统里留痕。这样任何一个环节的数据,都能在商铺详情页里查到完整的历史。

1.2 角色划分与用例梳理

系统里的角色不能太复杂,但也不能没有区分。结合这类业务的实际场景,我把角色拆成两类用户:

  • 管理员(运营人员):拥有全部权限,包括商铺信息维护、租户管理、合同签订与审核、租金账单处理、数据统计查看。
  • 普通操作员:日常录入租户信息、登记收款记录、查看商铺状态,但不能删除合同、不能修改租金单价等敏感字段。

这里特别要说一句,很多SSM项目的角色权限做得过于简陋,就是页面上判断一下“是不是管理员”,然后决定显不显示某个按钮。这种做法在论文答辩时经常被问到“如何保证接口安全”,容易答不上来。更好一点的做法是:在SpringMVC的拦截器层面做登录态校验,在方法层面再做一次权限点校验,双保险。

1.3 功能模块全景图

我梳理出的核心功能模块包括:

  • 商铺管理:商铺编号、位置、面积、租金单价、状态(空置/已租/维修)、备注信息。商铺列表支持按状态、按区域、按租金区间筛选。
  • 租户管理:租户基本信息(姓名、电话、身份证号、经营品类)、历史租赁记录。
  • 合同管理:合同编号、关联商铺与租户、租赁起止日期、月租金、押金金额、合同状态(生效/到期/已退租)。
  • 租金账单管理:根据合同自动生成账单,按月或按季生成应收记录,登记实收金额和时间,统计欠费情况。
  • 到期提醒:合同到期前30天、7天自动高亮提示。
  • 数据看板:商铺总数、空置率、本月应收租金、实收租金、欠费总额。

2. 技术选型思路

2.1 为什么选SSM而不直接用Spring Boot

这个话题在知乎上吵过很多轮。我的观点是:如果目标是快速开发上线,选Spring Boot;如果是毕设、深入学习、或者要体现对经典架构的理解,SSM是更合适的选题。

SSM分层的边界感比Spring Boot默认的工程结构更“显性”。SpringMVC控制层、Spring业务层、MyBatis持久层,三层各自负责什么,在SSM项目里特别清晰。面对“你这个项目架构是怎么分层、每一层的作用是什么”这种高频答辩问题,SSM的天然分层结构几乎是送分题。

当然,从开发效率来说,SSM的XML配置确实繁琐,一个简单的登录功能可能要先配置数据源、配置SqlSessionFactory、配置Mapper扫描、配置视图解析器,光配置文件就好几段。但我个人还是建议跑通一遍SSM的完整配置,因为理解了这些配置背后的逻辑之后,再去用Spring Boot会觉得一切都理所当然,之后排查问题的时候也不至于慌。

2.2 技术栈版本选择

这套系统选用的技术组合如下:

组件版本选择说明
JDK1.8稳定、生态好,兼容大部分服务器环境
Spring5.xIOC + AOP + 事务管理
SpringMVC5.xREST风格接口 + 参数校验 + 拦截器
MyBatis3.5.x动态SQL能力很强,适合复杂查询场景
MySQL5.7经典稳定版,InnoDB引擎
Tomcat8.5/9.x部署容器
Maven3.6+依赖管理

提示:JDK建议直接用1.8,不要追新。很多旧版本的MyBatis和Tomcat对JDK 11+的支持有兼容性问题,尤其是cglib代理和JAXB相关的组件,在新JDK下会有各种奇怪报错,犯不着折腾。

2.3 项目结构规划

分包我建议按“controller / service / mapper / entity / common”这种传统方式来,虽然网上的“按业务模块分包”更先进,但对SSM项目来说,按技术分层更直观,也更符合大部分学校对毕设代码结构的期望。

一个参考结构:

com.shop.lease ├── controller # 控制层:接收请求、参数校验、返回视图或JSON ├── service # 业务层接口 ├── service.impl # 业务层实现 ├── mapper # MyBatis接口 ├── entity # 数据库实体类 ├── common # 常量、工具类、统一返回结果 ├── interceptor # 登录拦截器 └── config # Spring集成配置

我个人习惯在service接口和实现类分离的时候,把核心业务逻辑写在实现类里,接口只做方法签名定义。这不是什么高级技巧,但能让代码结构一目了然。

3. 数据库设计与核心表结构

3.1 表关系梳理

商铺租赁系统的核心表关系不复杂,关键是状态字段的约束和业务表之间的关联要清晰。我设计了以下几张核心表:

  • t_user 用户表
  • t_shop 商铺表
  • t_tenant 租户表
  • t_contract 租赁合同表
  • t_rent_bill 租金账单表

关联关系是:商铺表跟合同表是一对多,租户表跟合同表是一对多,合同表跟租金账单表是一对多。其中,合同表是整个系统的业务核心,它同时关联了商铺和租户,下游又关联了账单。

3.2 核心表字段设计

以商铺表为例,核心字段如下:

CREATE TABLE t_shop ( id INT PRIMARY KEY AUTO_INCREMENT, shop_no VARCHAR(32) NOT NULL COMMENT '商铺编号', shop_name VARCHAR(100) DEFAULT NULL COMMENT '商铺名称', area DECIMAL(10,2) DEFAULT NULL COMMENT '建筑面积(平方米)', unit_price DECIMAL(10,2) DEFAULT NULL COMMENT '租金单价(元/月/平方米)', monthly_rent DECIMAL(10,2) DEFAULT NULL COMMENT '月租金(元)', status TINYINT DEFAULT 0 COMMENT '状态: 0-空置 1-已租 2-维修', location VARCHAR(255) DEFAULT NULL COMMENT '位置描述', create_time DATETIME DEFAULT NULL, update_time DATETIME DEFAULT NULL, UNIQUE KEY uk_shop_no (shop_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个细节:monthly_rent这个字段表面上看是冗余的,因为用area * unit_price就能算出来。但我还是保留了它。原因很简单:商铺出租时经常会有议价空间,实际成交价不一定等于挂牌单价乘以面积。所以商铺表里存“标准挂牌月租”,合同表里存“实际成交月租金”,两个字段各司其职,互不干扰。

合同表的核心字段:

CREATE TABLE t_contract ( id INT PRIMARY KEY AUTO_INCREMENT, contract_no VARCHAR(32) NOT NULL, shop_id INT NOT NULL, tenant_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, monthly_rent DECIMAL(10,2) NOT NULL COMMENT '实际合同月租金', deposit DECIMAL(10,2) DEFAULT 0 COMMENT '押金', status TINYINT DEFAULT 1 COMMENT '1-生效中 2-已到期 3-已退租', remark VARCHAR(500) DEFAULT NULL, create_time DATETIME DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.3 状态约束与数据一致性

设计表的时候最容易忽略的就是状态流转的约束。比如:

  • 一间状态为“已租”的商铺,能不能再关联一份新合同?——正常情况下不能。
  • 一间状态为“维修”的商铺,能不能被租出去?——不能。
  • 一个合同状态为“生效中”,它的账单删除操作应该被禁止。

这些约束可以在Java业务层写代码判断,但我建议在SQL查询层面也做配合。最简单的方式是,在查询空置商铺的SQL里直接加条件WHERE status = 0,只把可出租的商铺暴露给合同绑定页面。这样前端和后端的过滤逻辑是一致的,杜绝了脏数据。

4. 核心功能实现细节

4.1 MyBatis动态SQL处理商铺多条件查询

商铺列表是多条件筛选的重灾区:按状态筛选、按区域筛选、按租金范围筛选、按关键字搜索。用MyBatis的动态SQL处理这类需求极其顺手。

看一下这个mapper片段:

<select id="selectShopList" resultType="com.shop.lease.entity.Shop"> SELECT * FROM t_shop <where> <if test="status != null and status != ''"> AND status = #{status} </if> <if test="location != null and location != ''"> AND location LIKE CONCAT('%', #{location}, '%') </if> <if test="minRent != null"> AND monthly_rent &gt;= #{minRent} </if> <if test="maxRent != null"> AND monthly_rent &lt;= #{maxRent} </if> <if test="keyword != null and keyword != ''"> AND (shop_name LIKE CONCAT('%', #{keyword}, '%') OR shop_no LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY create_time DESC </select>

这里where标签会自动去掉第一个多余的AND,if标签让查询条件按需拼接。真实的项目里几乎不会用固定的“where 1=1”拼接法——虽然它也能跑,但写法太丑,还容易有SQL注入的隐患。用MyBatis的<where>是正规做法,也是面试官愿意看到的写法。

4.2 租期计算与账单自动生成

合同签了以后,租金账单不能靠人工一条条录。用一个自动生成逻辑来处理就会轻松得多。

核心实现思路是:合同生效后,按照计费周期(按月),拆分出整个合同周期内的所有应收账款记录。每一条账单记录包括账期(例如2023-06、2023-07等)、应收金额(合同月租金)、实收金额、收款状态。

关键代码大致是这个逻辑:

public void generateRentBills(Contract contract) { LocalDate start = contract.getStartDate(); LocalDate end = contract.getEndDate(); LocalDate cursor = start.withDayOfMonth(1); while (!cursor.isAfter(end)) { RentBill bill = new RentBill(); bill.setContractId(contract.getId()); bill.setPeriod(cursor.toString().substring(0, 7)); // 格式: 2025-01 bill.setDueAmount(contract.getMonthlyRent()); bill.setStatus(0); // 0-未缴 1-部分缴纳 2-已缴 rentBillMapper.insert(bill); cursor = cursor.plusMonths(1); } }

这里有个坑需要注意:**如果合同的开始日期不是月初,比如从1月15号开始,第一个月该不该收整月租金?**我处理的方式是:默认按整月生成账单,但在合同表里增加一个first_month_discount或者直接在remark里说明首月特殊情况。如果业务上需要按天折算,就必须再引入一个“起始计费日期”字段,逻辑会复杂不少。对大多数毕设场景,整月拆分已经够用了。

4.3 合同到期提醒的两种实现方式

到期提醒是业务上非常显眼的一个功能,实现方式有两种:

第一种:定时任务扫描

在Spring配置文件里开启定时任务:

<task:annotation-driven/>

然后写一个扫描逻辑:

@Component public class ContractRemindTask { @Scheduled(cron = "0 0 8 * * ?") // 每天早上8点执行一次 public void checkExpiringContracts() { LocalDate today = LocalDate.now(); LocalDate warnDate = today.plusDays(30); List<Contract> contracts = contractMapper.selectExpiring(today, warnDate); for (Contract c : contracts) { // 写入提醒记录 或 发送站内消息 } } }

第二种:查询时实时计算

在合同列表查询的SQL中,直接计算剩余天数:

SELECT *, DATEDIFF(end_date, CURDATE()) AS remain_days FROM t_contract WHERE status = 1 AND DATEDIFF(end_date, CURDATE()) &lt;= 30

然后在Java层判断remain_days的值,在前端页面上把剩余天数小于7天的记录高亮展示。

两种方案我建议同时用:定时任务负责后台扫描生成待办提醒,实时计算负责页面展示当前状态。只做定时任务,用户打开页面看不到醒目的提示;只做实时计算,系统缺少主动触达能力。双管齐下效果最好。

4.4 登录拦截器与权限控制

SSM的拦截器配置很经典,我每次带项目都会强调这块不要省。核心代码如下:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { // 判断是否是Ajax请求 String xhr = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(xhr)) { response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); } return false; } return true; } }

Spring配置文件里注册:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/user/doLogin"/> </mvc:interceptor> </mvc:interceptors>

这里有两个要注意的细节。第一,放行静态资源是一定要做的,不然CSS、JS、图片全部被拦截,页面样式全丢;第二,Ajax请求不能直接跳转页面,要返回一个JSON状态码,让前端JS判断后自己跳转登录页,否则用户在系统中做着操作突然被踢出,浏览器地址栏也没变化,体验很差。

4.5 事务管理

租金收款和账单状态更新必须是原子操作:

@Transactional(rollbackFor = Exception.class) public void receiveRent(Integer billId, BigDecimal amount, Integer operatorId) { RentBill bill = rentBillMapper.selectById(billId); if (bill == null) { throw new BusinessException("账单不存在"); } // 更新实收 bill.setPaidAmount(amount); if (bill.getDueAmount().compareTo(amount) == 0) { bill.setStatus(2); // 已缴 } else if (amount.compareTo(BigDecimal.ZERO) > 0) { bill.setStatus(1); // 部分缴纳 } rentBillMapper.updateById(bill); // 记录收款流水 paymentRecordMapper.insert(...); }

@Transactional默认只回滚RuntimeException,所以我习惯在注解里明确rollbackFor = Exception.class。另外,金额计算必须用BigDecimal——千万别用double。double在加减乘除时会丢失精度,比如0.1 + 0.2 = 0.30000000000000004,金额算错了那可是真金白银的问题。

5. 部署与调试实录

5.1 本地开发环境搭建

开发环境搭建有几个常见问题值得提前预防。

第一个是JDK版本不匹配。我建议装JDK 1.8,不要装11或17,除非你确认所有依赖都兼容新版本。装完之后,在IDE里把Project Structure和Maven的JDK配置都指到1.8,三处保持一致。

第二个是Maven依赖下载慢。国内直连maven中央仓库,下载Spring、MyBatis这些依赖会卡很久。提前在settings.xml里配好阿里云镜像,能节省大量时间。

第三个是数据库连接配置。jdbc.properties里要检查连接串是否包含时区参数:

jdbc.url=jdbc:mysql://localhost:3306/shop_lease?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

serverTimezone这个参数经常被漏掉。MySQL 5.7之后的驱动,默认时区跟系统不一致时会报The server time zone value '???й???' is unrecognized之类的问题,错误信息乱码,光看报错根本反应不过来,加上serverTimezone=Asia/Shanghai就解决了。

5.2 从源码到运行的四步流程

把源码在本地跑起来的大致流程如下:

  1. 导入数据库:用Navicat或命令行,执行项目里附带的shop_lease.sql脚本,建库建表,同时看看初始数据是否齐全,比如管理员账号是否存在。
  2. 修改数据库连接配置:在jdbc.properties里改成自己本机的数据库名、用户名、密码。
  3. 配置Tomcat:IDEA里添加Tomcat Server,Deployment选择war exploded模式,Application context建议设为/,这样访问路径不带项目名,清爽很多。
  4. 启动并测试:启动Tomcat,浏览器打开,看看能不能跳到登录页。用附带的初始账号登录,挨个功能点过一遍。

注意:如果页面出现404,首先检查Application context是不是多了一层项目名;如果页面样式全丢,先看静态资源有没有被拦截器放行;如果数据库中文乱码,检查连接串里的characterEncoding=utf8和数据库本身的默认字符集。

5.3 要准备的辅助材料

题目里写了“源码+lw+部署文档+讲解”,这几样东西在交付时的作用各不相同。

  • 源码:核心交付物,代码结构要清晰,注释量要适中。我个人不主张写大量废话注释,但关键的业务逻辑(比如账单生成、合同状态流转)必须有简短注释。
  • lw(论文):千万别只写“功能描述+代码粘贴”。论文的重点在于需求分析、数据库设计、系统设计、核心代码讲解这四个部分,核心代码讲明白逻辑,不要整段往上堆。
  • 部署文档:要写清环境版本、数据库导入步骤、遇到的常见问题。很多同学自己部署时花了半天,却懒得把步骤记录下来。这份文档写成PPT式的按步操作,对后面演示答辩很有价值。
  • 讲解(PPT/演示视频):重点讲“为什么这样设计”,而不是每页念代码。比如数据库表关系使用了什么设计,合同和账单之间的联动逻辑是怎么实现的,这些比读代码有说服力得多。

6. 常见问题与排查技巧实录

6.1 数据库连接失败

症状五花八门:Access denied for user、Communications link failure、Connection refused。排查顺序从简到繁:

  • 数据库服务是否启动:net start mysql看看状态。
  • 账号密码是否正确:在Navicat里测试一下。
  • 端口是否正确:默认3306,可能被占用或被改了。
  • 连接串是否有特殊字符报错:检查&需不需要转义。

6.2 SpringMVC返回JSON报406错误

这个坑出现的频率相当高。返回JSON时依赖Jackson库,如果pom.xml里没有引入:

<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.9.8</version> </dependency>

就会报406 Not Acceptable。虽然控制台没有明显的红色报错,但浏览器收到的响应不是JSON数据。加依赖、刷新Maven、重启Tomcat,三步搞定。

6.3 MyBatis的Mapper接口和XML映射不匹配

典型报错是Invalid bound statement (not found)。排查方向就三个:

  • Mapper接口的全限定名和XML的namespace是否一致。
  • XML里方法的id是否和接口方法名一致。
  • mybatis配置里是否扫描到了mapper XML文件,尤其是在pom里如果把XML放在src/main/resources之外,经常因为Maven默认不扫描xml文件导致找不到。

解决办法是在pom.xml里加上资源配置:

<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>

6.4 跨页面传递参数丢失

商铺列表页跳到新增合同页时,经常需要把商铺ID带过去。用session是最偷懒的但最容易出问题的做法,因为session不清理的话,下次新签合同还可能带着旧的商铺ID。我建议用URL参数传递:

return "redirect:/contract/toAdd?shopId=" + shopId;

在接收方用@RequestParam Integer shopId接收,并在合同提交时校验这个shopId对应的商铺状态是否仍为空置。前置校验和提交校验同时做,能有效防止并发问题。

7. 从项目中学到的经验

做这种带完整交付物的SSM项目,最有价值的其实不是把功能写出来,而是把每个功能背后的业务逻辑想清楚。比如说租金账单自动生成,第一次写的时候我直接每个合同硬编码循环for了三个月就完事,后来发现不同合同的起止日期不一样,就重新梳理了时间计算逻辑。类似这样的迭代,走一遍之后,对Java Web开发的理解深度完全不同。

另外,谈一点实话:SSM这套技术栈放在现在的生产环境里,新项目普遍不会从零搭建了,Spring Boot + MyBatis是更主流的选择。但对于学习来讲,SSM的三层架构依然是最好的教学样例。如果你能把SSM的配置从零到一自己写出来,理解每个配置项的作用,那么过渡到Spring Boot只是换个写法的问题,底层的设计哲学是完全相通的。

最后分享一个小技巧:部署文档里把每个功能模块的测试账号和预期结果列一张表,比如“管理员登录——进入后台——商铺列表显示全部商铺——新增商铺成功——状态变为空置”。这样自己答辩前按表格过一遍,能发现很多平时注意不到的流程断裂。我在带过的项目里帮别人检查时,至少遇到过三次“合同签订后商铺状态没变”这类逻辑漏洞,都是靠测试表格兜底才发现的。

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

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

立即咨询